coderuday21 Cursor commited on
Commit
c682b53
·
1 Parent(s): 45a6b1d

Deploy latest uday app code to Hugging Face (skip research binaries).

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +0 -3
  2. Dockerfile +1 -1
  3. app/auth.py +16 -1
  4. app/database.py +14 -1
  5. app/dda/auto_detect.py +16 -6
  6. app/dda/bootstrap.py +40 -0
  7. app/dda/dashboard_routes.py +389 -57
  8. app/dda/detect_service.py +16 -3
  9. app/dda/exception_routes.py +1 -0
  10. app/dda/job_runner.py +9 -2
  11. app/dda/local_routes.py +31 -8
  12. app/dda/masters_routes.py +31 -2
  13. app/dda/rbac/permissions.py +41 -12
  14. app/dda/rbac/routes.py +4 -12
  15. app/dda/rbac/seed.py +2 -2
  16. app/dda/report_pdf.py +18 -1
  17. app/dda/reports_routes.py +44 -0
  18. app/dda/review_service.py +19 -1
  19. app/dda/tree/area_groups.py +550 -112
  20. app/dda/tree/audit_service.py +2 -0
  21. app/dda/tree/bulk_upload_service.py +414 -0
  22. app/dda/tree/image_service.py +312 -15
  23. app/dda/tree/library_cache.py +14 -11
  24. app/dda/tree/migration.py +5 -11
  25. app/dda/tree/models.py +14 -0
  26. app/dda/tree/routes.py +278 -6
  27. app/dda/tree/sync_service.py +29 -23
  28. app/dda/tree/tree_service.py +23 -0
  29. app/main.py +90 -23
  30. app/models.py +10 -0
  31. requirements.txt +1 -0
  32. scripts/test_area_groups.py +227 -11
  33. static/css/dda-modernist-admin.css +372 -0
  34. static/css/dda-modernist-auth.css +195 -0
  35. static/css/dda-modernist-detect.css +197 -0
  36. static/css/dda-modernist-library.css +230 -0
  37. static/css/dda-modernist-logs.css +269 -0
  38. static/css/dda-modernist-masters-profile.css +252 -0
  39. static/css/dda-modernist-reports.css +286 -0
  40. static/css/dda-modernist-result-modal.css +282 -0
  41. static/css/dda-modernist-sidebar.css +76 -0
  42. static/css/dda.css +630 -16
  43. static/css/style.css +64 -4
  44. static/js/dda/app.js +31 -16
  45. static/js/dda/audit_log.js +8 -1
  46. static/js/dda/compare.js +139 -14
  47. static/js/dda/exception_log.js +6 -3
  48. static/js/dda/home.js +623 -154
  49. static/js/dda/library.js +797 -65
  50. static/js/dda/notifications.js +14 -2
.gitignore CHANGED
@@ -14,9 +14,6 @@ data/library_sources/**/*.tif
14
  data/library_sources/**/*.tiff
15
  data/library_sources/**/*.TIF
16
  data/library_sources/**/*.TIFF
17
- data/library_sources/**/*.png
18
- data/library_sources/**/*.jpg
19
- data/library_sources/**/*.jpeg
20
  !library_sources/**/.gitkeep
21
  .env
22
  data/.secret_key
 
14
  data/library_sources/**/*.tiff
15
  data/library_sources/**/*.TIF
16
  data/library_sources/**/*.TIFF
 
 
 
17
  !library_sources/**/.gitkeep
18
  .env
19
  data/.secret_key
Dockerfile CHANGED
@@ -21,7 +21,7 @@ WORKDIR /app
21
 
22
  # Build-time info + cache-bust:
23
  # Changing APP_BUILD forces Docker to re-run subsequent layers (including pip install).
24
- ARG APP_BUILD=38
25
  ENV MAX_GEOTIFF_MB=15360
26
  ENV APP_BUILD=${APP_BUILD}
27
  ENV GDAL_CONFIG=/usr/bin/gdal-config
 
21
 
22
  # Build-time info + cache-bust:
23
  # Changing APP_BUILD forces Docker to re-run subsequent layers (including pip install).
24
+ ARG APP_BUILD=37
25
  ENV MAX_GEOTIFF_MB=15360
26
  ENV APP_BUILD=${APP_BUILD}
27
  ENV GDAL_CONFIG=/usr/bin/gdal-config
app/auth.py CHANGED
@@ -201,6 +201,14 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -
201
  return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
202
 
203
 
 
 
 
 
 
 
 
 
204
  PASSWORD_RESET_TOKEN_EXPIRE_MINUTES = 30
205
 
206
 
@@ -371,7 +379,14 @@ def get_user_from_token(token: str, db: Session, *, allow_pending_otp: bool = Fa
371
  return None
372
  except JWTError:
373
  return None
374
- return get_user_by_id(db, user_id)
 
 
 
 
 
 
 
375
 
376
 
377
  LOGIN_OTP_EXPIRE_MINUTES = 10
 
201
  return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
202
 
203
 
204
+ def revoke_user_sessions(user: User) -> None:
205
+ """Invalidate every previously-issued session token for `user` (any tab,
206
+ device, or leaked copy) by bumping token_version — get_user_from_token()
207
+ rejects any token whose embedded 'tv' no longer matches. Caller commits.
208
+ """
209
+ user.token_version = int(user.token_version or 0) + 1
210
+
211
+
212
  PASSWORD_RESET_TOKEN_EXPIRE_MINUTES = 30
213
 
214
 
 
379
  return None
380
  except JWTError:
381
  return None
382
+ user = get_user_by_id(db, user_id)
383
+ if user is None:
384
+ return None
385
+ if purpose == "session" and int(payload.get("tv", 0)) != int(user.token_version or 0):
386
+ # Token was issued before a logout / password change bumped
387
+ # token_version — reject it even though it hasn't expired yet.
388
+ return None
389
+ return user
390
 
391
 
392
  LOGIN_OTP_EXPIRE_MINUTES = 10
app/database.py CHANGED
@@ -28,15 +28,28 @@ if DATABASE_URL.startswith("sqlite"):
28
  # timeout = busy wait while another connection holds the write lock
29
  # (progress updates vs the long-running detection worker).
30
  connect_args = {"check_same_thread": False, "timeout": 60.0}
 
 
 
 
 
 
 
31
 
32
  # pool_pre_ping: checks each connection before use and transparently reconnects
33
  # if it's gone stale — matters for a remote DB (MySQL/Postgres) reached over
34
  # the network, where idle connections can be dropped by the server or a
35
  # firewall; irrelevant (and skipped) for local SQLite.
 
 
 
 
 
36
  engine = create_engine(
37
  DATABASE_URL,
38
  connect_args=connect_args,
39
- pool_pre_ping=not DATABASE_URL.startswith("sqlite"),
 
40
  )
41
 
42
 
 
28
  # timeout = busy wait while another connection holds the write lock
29
  # (progress updates vs the long-running detection worker).
30
  connect_args = {"check_same_thread": False, "timeout": 60.0}
31
+ elif DATABASE_URL.startswith("mysql"):
32
+ # connect_timeout: fail a broken/unreachable connection attempt in 10s
33
+ # instead of hanging the request (and the page) indefinitely — matters
34
+ # for a remote MySQL host reached over the network.
35
+ connect_args = {"connect_timeout": 10}
36
+
37
+ _is_sqlite = DATABASE_URL.startswith("sqlite")
38
 
39
  # pool_pre_ping: checks each connection before use and transparently reconnects
40
  # if it's gone stale — matters for a remote DB (MySQL/Postgres) reached over
41
  # the network, where idle connections can be dropped by the server or a
42
  # firewall; irrelevant (and skipped) for local SQLite.
43
+ # pool_recycle: proactively discard and replace any pooled connection older
44
+ # than 280s — shorter than the "wait_timeout" most managed MySQL hosts use to
45
+ # silently drop idle connections server-side, so the pool never hands out a
46
+ # connection the server has already closed (seen in production as
47
+ # "OperationalError 2013: Lost connection to MySQL server during query").
48
  engine = create_engine(
49
  DATABASE_URL,
50
  connect_args=connect_args,
51
+ pool_pre_ping=not _is_sqlite,
52
+ pool_recycle=280 if not _is_sqlite else -1,
53
  )
54
 
55
 
app/dda/auto_detect.py CHANGED
@@ -17,6 +17,7 @@ from datetime import datetime, timedelta, timezone
17
  from pathlib import Path
18
  from typing import Any, Optional
19
 
 
20
  from sqlalchemy.orm import Session
21
 
22
  from ..database import SessionLocal
@@ -257,13 +258,18 @@ def _sync_schedules(db: Session, pairs: list[dict[str, Any]]) -> None:
257
  live_keys = set()
258
  now = _utcnow()
259
  for pair in pairs:
260
- key = pair_key_for(pair["before_path"], pair["after_path"])
 
 
 
 
 
261
  live_keys.add(key)
262
  row = db.query(AutoPairSchedule).filter(AutoPairSchedule.pair_key == key).first()
263
  if row:
264
  row.group_label = pair["label"]
265
- row.before_path = pair["before_path"]
266
- row.after_path = pair["after_path"]
267
  row.interval_days = interval
268
  row.enabled = True
269
  row.updated_at = now
@@ -271,8 +277,8 @@ def _sync_schedules(db: Session, pairs: list[dict[str, Any]]) -> None:
271
  db.add(AutoPairSchedule(
272
  pair_key=key,
273
  group_label=pair["label"],
274
- before_path=pair["before_path"],
275
- after_path=pair["after_path"],
276
  interval_days=interval,
277
  enabled=True,
278
  ))
@@ -285,7 +291,11 @@ def _sync_schedules(db: Session, pairs: list[dict[str, Any]]) -> None:
285
  if row.pair_key not in live_keys:
286
  row.enabled = False
287
  row.updated_at = now
288
- db.commit()
 
 
 
 
289
 
290
 
291
  def _pending_job_for_paths(db: Session, before: str, after: str) -> Optional[DetectionJob]:
 
17
  from pathlib import Path
18
  from typing import Any, Optional
19
 
20
+ from sqlalchemy.exc import IntegrityError
21
  from sqlalchemy.orm import Session
22
 
23
  from ..database import SessionLocal
 
258
  live_keys = set()
259
  now = _utcnow()
260
  for pair in pairs:
261
+ before, after = pair["before_path"], pair["after_path"]
262
+ if not before or not after or before == after:
263
+ continue
264
+ key = pair_key_for(before, after)
265
+ if key in live_keys:
266
+ continue
267
  live_keys.add(key)
268
  row = db.query(AutoPairSchedule).filter(AutoPairSchedule.pair_key == key).first()
269
  if row:
270
  row.group_label = pair["label"]
271
+ row.before_path = before
272
+ row.after_path = after
273
  row.interval_days = interval
274
  row.enabled = True
275
  row.updated_at = now
 
277
  db.add(AutoPairSchedule(
278
  pair_key=key,
279
  group_label=pair["label"],
280
+ before_path=before,
281
+ after_path=after,
282
  interval_days=interval,
283
  enabled=True,
284
  ))
 
291
  if row.pair_key not in live_keys:
292
  row.enabled = False
293
  row.updated_at = now
294
+ try:
295
+ db.commit()
296
+ except IntegrityError:
297
+ db.rollback()
298
+ logger.warning("Auto-pair schedule sync hit a duplicate pair_key; it will retry on the next tick.")
299
 
300
 
301
  def _pending_job_for_paths(db: Session, before: str, after: str) -> Optional[DetectionJob]:
app/dda/bootstrap.py CHANGED
@@ -103,10 +103,37 @@ def init_dda_database():
103
  "ALTER TABLE users ADD COLUMN totp_enabled INTEGER DEFAULT 0",
104
  "ALTER TABLE users ADD COLUMN totp_confirmed_at DATETIME DEFAULT NULL",
105
  "ALTER TABLE users ADD COLUMN phone VARCHAR(20) DEFAULT NULL",
 
106
  "ALTER TABLE detection_runs ADD COLUMN after_full_path VARCHAR(512) DEFAULT ''",
 
 
107
  "ALTER TABLE dda_roles ADD COLUMN is_active BOOLEAN DEFAULT 1",
108
  "ALTER TABLE dda_audit_logs ADD COLUMN role VARCHAR(64) DEFAULT ''",
109
  "ALTER TABLE dda_audit_logs ADD COLUMN module VARCHAR(64) DEFAULT ''",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  ):
111
  try:
112
  conn.execute(sa_text(stmt))
@@ -128,6 +155,19 @@ def init_dda_database():
128
  conn.commit()
129
  except Exception:
130
  conn.rollback()
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  _migrate_detection_jobs_nullable()
132
  except Exception as exc:
133
  logger.warning("DDA schema migration skipped: %s", exc)
 
103
  "ALTER TABLE users ADD COLUMN totp_enabled INTEGER DEFAULT 0",
104
  "ALTER TABLE users ADD COLUMN totp_confirmed_at DATETIME DEFAULT NULL",
105
  "ALTER TABLE users ADD COLUMN phone VARCHAR(20) DEFAULT NULL",
106
+ "ALTER TABLE users ADD COLUMN token_version INTEGER DEFAULT 0",
107
  "ALTER TABLE detection_runs ADD COLUMN after_full_path VARCHAR(512) DEFAULT ''",
108
+ "ALTER TABLE detection_runs ADD COLUMN detection_sensitivity FLOAT DEFAULT NULL",
109
+ "ALTER TABLE detection_runs ADD COLUMN min_region_area INTEGER DEFAULT NULL",
110
  "ALTER TABLE dda_roles ADD COLUMN is_active BOOLEAN DEFAULT 1",
111
  "ALTER TABLE dda_audit_logs ADD COLUMN role VARCHAR(64) DEFAULT ''",
112
  "ALTER TABLE dda_audit_logs ADD COLUMN module VARCHAR(64) DEFAULT ''",
113
+ "ALTER TABLE dda_image_library ADD COLUMN zone_id INTEGER DEFAULT NULL",
114
+ "ALTER TABLE dda_image_library ADD COLUMN village_id INTEGER DEFAULT NULL",
115
+ "ALTER TABLE dda_image_library ADD COLUMN year INTEGER DEFAULT NULL",
116
+ "ALTER TABLE dda_image_library ADD COLUMN source VARCHAR(32) DEFAULT ''",
117
+ "ALTER TABLE dda_exception_logs ADD COLUMN user_role VARCHAR(64) DEFAULT NULL",
118
+ ):
119
+ try:
120
+ conn.execute(sa_text(stmt))
121
+ conn.commit()
122
+ except Exception:
123
+ conn.rollback()
124
+ # Enforce "every image has a zone and village" at the database
125
+ # level, not just in application code — this used to be
126
+ # optional, which is how thousands of images ended up
127
+ # "Unassigned" (bulk upload with no location columns, and the
128
+ # filesystem sync auto-indexing files with none to give).
129
+ # MySQL-only (SQLite can't ALTER a column's nullability); this
130
+ # fails harmlessly and keeps retrying on every startup until any
131
+ # pre-existing NULL rows are cleaned up, at which point it
132
+ # sticks — same best-effort pattern as every other statement
133
+ # above.
134
+ for stmt in (
135
+ "ALTER TABLE dda_image_library MODIFY COLUMN zone_id INTEGER NOT NULL",
136
+ "ALTER TABLE dda_image_library MODIFY COLUMN village_id INTEGER NOT NULL",
137
  ):
138
  try:
139
  conn.execute(sa_text(stmt))
 
155
  conn.commit()
156
  except Exception:
157
  conn.rollback()
158
+ for stmt in (
159
+ "CREATE INDEX IF NOT EXISTS ix_dda_image_library_zone_id "
160
+ "ON dda_image_library (zone_id)",
161
+ "CREATE INDEX IF NOT EXISTS ix_dda_image_library_village_id "
162
+ "ON dda_image_library (village_id)",
163
+ "CREATE INDEX IF NOT EXISTS ix_dda_image_library_year "
164
+ "ON dda_image_library (year)",
165
+ ):
166
+ try:
167
+ conn.execute(sa_text(stmt))
168
+ conn.commit()
169
+ except Exception:
170
+ conn.rollback()
171
  _migrate_detection_jobs_nullable()
172
  except Exception as exc:
173
  logger.warning("DDA schema migration skipped: %s", exc)
app/dda/dashboard_routes.py CHANGED
@@ -8,6 +8,7 @@ different dashboards, exactly like they already see different Reports lists.
8
  """
9
  from __future__ import annotations
10
 
 
11
  from collections import defaultdict
12
  from datetime import datetime, timedelta, timezone
13
  from typing import Optional
@@ -28,6 +29,12 @@ router = APIRouter()
28
  UNASSIGNED = "Unassigned"
29
  DEFAULT_ROLE = "analyst"
30
 
 
 
 
 
 
 
31
  # Matches DEFAULT_ROLES rank order in app/dda/rbac/seed.py — keeps the role
32
  # breakdown in a stable, meaningful order instead of alphabetical/count order.
33
  ROLE_ORDER = ["viewer", "uploader", "analyst", "admin"]
@@ -62,6 +69,17 @@ def _run_thumbs(run: DetectionRun) -> dict:
62
  }
63
 
64
 
 
 
 
 
 
 
 
 
 
 
 
65
  def _run_summary(row: dict) -> dict:
66
  run = row["run"]
67
  return {
@@ -76,11 +94,49 @@ def _run_summary(row: dict) -> dict:
76
  "unclassified": row["unclassified"],
77
  "confirmed": row["confirmed"],
78
  "falsePositive": row["falsePositive"],
 
 
 
79
  "createdAt": _isoformat_ist(run.created_at),
80
  **_run_thumbs(run),
81
  }
82
 
83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  def _accessible_run_filter(db: Session, user: User):
85
  """Same run-visibility rule as GET /api/history in app/main.py: the
86
  user's own runs, plus any run that came from the auto-schedule (visible
@@ -101,51 +157,144 @@ def _accessible_run_filter(db: Session, user: User):
101
  return filt
102
 
103
 
104
- def _load_dataset(db: Session, user: User) -> list[dict]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  """One query for every accessible run + one grouped query for
106
  classification counts; every endpoint below aggregates from this in
107
  Python — mirrors the simple query style already used by
108
  admin_routes.admin_status(). Runs with a blank zone/village are bucketed
109
- under "Unassigned" so totals reconcile.
 
 
 
110
  """
111
- runs = (
112
- db.query(DetectionRun)
113
- .filter(_accessible_run_filter(db, user))
114
- .order_by(DetectionRun.created_at.asc())
115
- .all()
116
- )
 
 
 
117
  counts = bulk_classification_counts(db)
118
  # Same role resolution as dda_auth.get_user_role (raw User.role string,
119
  # falling back to "analyst") but bulk-loaded to avoid a query per run.
120
  roles_by_user = {uid: (role or DEFAULT_ROLE) for uid, role in db.query(User.id, User.role).all()}
 
 
121
  rows = []
122
  for run in runs:
 
 
 
 
 
 
123
  c = counts.get(run.id, {})
124
  classified = c.get("confirmed", 0) + c.get("false_positive", 0) + c.get("submitted", 0)
125
  unclassified = max(0, (run.regions_count or 0) - classified)
126
  rows.append({
127
  "run": run,
128
- "zone": (run.zone or "").strip() or UNASSIGNED,
129
- "village": (run.village or "").strip() or UNASSIGNED,
130
  "role": roles_by_user.get(run.user_id, DEFAULT_ROLE),
131
  "confirmed": c.get("confirmed", 0),
132
  "falsePositive": c.get("false_positive", 0),
133
  "submitted": c.get("submitted", 0),
134
  "unclassified": unclassified,
 
135
  })
136
  return rows
137
 
138
 
139
- @router.get("/dashboard/summary")
140
- def dashboard_summary(db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
141
- _require_dda()
142
- rows = _load_dataset(db, user)
143
-
144
  total_detected = sum((r["run"].regions_count or 0) for r in rows)
145
  pct_values = [r["run"].change_percentage for r in rows if r["run"].change_percentage is not None]
146
  zones = {r["zone"] for r in rows}
147
  villages = {(r["zone"], r["village"]) for r in rows}
148
 
 
 
 
 
149
  return {
150
  "totalRuns": len(rows),
151
  "totalDetectedChanges": total_detected,
@@ -156,43 +305,46 @@ def dashboard_summary(db: Session = Depends(get_db), user: User = Depends(curren
156
  "avgChangePercentage": round(sum(pct_values) / len(pct_values), 2) if pct_values else None,
157
  "totalRegions": len(zones),
158
  "totalVillages": len(villages),
 
 
 
159
  "jobsQueued": db.query(DetectionJob).filter(DetectionJob.status == "queued", DetectionJob.created_by == user.id).count(),
160
  "jobsRunning": db.query(DetectionJob).filter(DetectionJob.status == "running", DetectionJob.created_by == user.id).count(),
161
  "latestRun": _run_summary(rows[-1]) if rows else None,
162
  }
163
 
164
 
165
- @router.get("/dashboard/regions")
166
- def dashboard_regions(db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
167
- _require_dda()
168
- rows = _load_dataset(db, user)
169
  by_zone: dict[str, list] = defaultdict(list)
170
  for r in rows:
171
  by_zone[r["zone"]].append(r)
 
172
 
173
  out = []
174
  for zone, items in by_zone.items():
175
  pct_values = [i["run"].change_percentage for i in items if i["run"].change_percentage is not None]
 
 
176
  out.append({
177
  "zone": zone,
178
  "runsCount": len(items),
179
  "detectedChanges": sum((i["run"].regions_count or 0) for i in items),
180
  "unclassified": sum(i["unclassified"] for i in items),
181
- "confirmed": sum(i["confirmed"] for i in items),
182
- "falsePositive": sum(i["falsePositive"] for i in items),
 
 
183
  "avgChangePercentage": round(sum(pct_values) / len(pct_values), 2) if pct_values else None,
184
  "villagesCount": len({i["village"] for i in items}),
 
 
185
  "lastDetectionAt": _isoformat_ist(max(i["run"].created_at for i in items)),
186
  })
187
  out.sort(key=lambda x: x["runsCount"], reverse=True)
188
- return {"regions": out}
189
 
190
 
191
- @router.get("/dashboard/roles")
192
- def dashboard_roles(db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
193
- """Detection activity grouped by the role of the user who ran it."""
194
- _require_dda()
195
- rows = _load_dataset(db, user)
196
  by_role: dict[str, list] = defaultdict(list)
197
  for r in rows:
198
  by_role[r["role"]].append(r)
@@ -215,50 +367,42 @@ def dashboard_roles(db: Session = Depends(get_db), user: User = Depends(current_
215
  "lastDetectionAt": _isoformat_ist(max(i["run"].created_at for i in items)),
216
  })
217
  out.sort(key=lambda x: (_rank(x["role"]), x["role"]))
218
- return {"roles": out}
219
 
220
 
221
- @router.get("/dashboard/villages")
222
- def dashboard_villages(
223
- zone: Optional[str] = Query(None),
224
- db: Session = Depends(get_db),
225
- user: User = Depends(current_dda_user),
226
- ):
227
- _require_dda()
228
- rows = _load_dataset(db, user)
229
  if zone:
230
  rows = [r for r in rows if r["zone"] == zone]
231
  by_village: dict[tuple, list] = defaultdict(list)
232
  for r in rows:
233
  by_village[(r["zone"], r["village"])].append(r)
 
234
 
235
  out = []
236
  for (z, v), items in by_village.items():
237
  pct_values = [i["run"].change_percentage for i in items if i["run"].change_percentage is not None]
 
 
238
  out.append({
239
  "zone": z,
240
  "village": v,
241
  "runsCount": len(items),
242
  "detectedChanges": sum((i["run"].regions_count or 0) for i in items),
243
  "unclassified": sum(i["unclassified"] for i in items),
244
- "confirmed": sum(i["confirmed"] for i in items),
245
- "falsePositive": sum(i["falsePositive"] for i in items),
 
 
246
  "avgChangePercentage": round(sum(pct_values) / len(pct_values), 2) if pct_values else None,
 
 
247
  "lastDetectionAt": _isoformat_ist(max(i["run"].created_at for i in items)),
248
  })
249
  out.sort(key=lambda x: x["runsCount"], reverse=True)
250
- return {"villages": out}
251
 
252
 
253
- @router.get("/dashboard/trend")
254
- def dashboard_trend(
255
- months: int = Query(6, ge=1, le=24),
256
- db: Session = Depends(get_db),
257
- user: User = Depends(current_dda_user),
258
- ):
259
- _require_dda()
260
- rows = _load_dataset(db, user)
261
-
262
  now = datetime.now(timezone.utc)
263
  keys = []
264
  y, m = now.year, now.month
@@ -281,7 +425,7 @@ def dashboard_trend(
281
  counts[key]["runsCount"] += 1
282
  counts[key]["detectedChanges"] += (r["run"].regions_count or 0)
283
 
284
- buckets = [
285
  {
286
  "period": f"{y}-{m:02d}",
287
  "label": f"{MONTH_NAMES[m]} {y}",
@@ -290,7 +434,87 @@ def dashboard_trend(
290
  }
291
  for (y, m) in keys
292
  ]
293
- return {"buckets": buckets}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
 
295
 
296
  @router.get("/dashboard/location")
@@ -310,15 +534,28 @@ def dashboard_location(
310
  previous = history[-2] if len(history) > 1 else None
311
 
312
  comparison = None
 
313
  if latest and previous:
 
 
 
 
 
 
 
 
 
 
 
314
  comparison = {
315
- "changePercentageDelta": (
316
- round(latest["changePercentage"] - previous["changePercentage"], 2)
317
- if latest["changePercentage"] is not None and previous["changePercentage"] is not None
318
- else None
319
- ),
320
- "regionsCountDelta": latest["regionsCount"] - previous["regionsCount"],
321
  }
 
322
 
323
  return {
324
  "zone": zone,
@@ -327,17 +564,112 @@ def dashboard_location(
327
  "latest": latest,
328
  "previous": previous,
329
  "comparison": comparison,
 
330
  "history": list(reversed(history)),
331
  }
332
 
333
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
334
  @router.get("/dashboard/recent-reports")
335
  def dashboard_recent_reports(
336
  limit: int = Query(8, ge=1, le=50),
 
 
337
  db: Session = Depends(get_db),
338
  user: User = Depends(current_dda_user),
339
  ):
340
  _require_dda()
341
- rows = _load_dataset(db, user)
342
  rows.sort(key=lambda r: r["run"].created_at, reverse=True)
343
  return {"reports": [_run_summary(r) for r in rows[:limit]]}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  """
9
  from __future__ import annotations
10
 
11
+ import json
12
  from collections import defaultdict
13
  from datetime import datetime, timedelta, timezone
14
  from typing import Optional
 
29
  UNASSIGNED = "Unassigned"
30
  DEFAULT_ROLE = "analyst"
31
 
32
+ # Fixed taxonomy every region is already classified into — see
33
+ # app/dda/change_type_map.py::map_to_dda_change_type. Kept here (not
34
+ # imported) purely to pin a stable display order across every chart/table
35
+ # that breaks a count down by change type.
36
+ CHANGE_TYPE_ORDER = ["New Construction", "Demolition", "Extension", "Vegetation Change", "Other"]
37
+
38
  # Matches DEFAULT_ROLES rank order in app/dda/rbac/seed.py — keeps the role
39
  # breakdown in a stable, meaningful order instead of alphabetical/count order.
40
  ROLE_ORDER = ["viewer", "uploader", "analyst", "admin"]
 
69
  }
70
 
71
 
72
+ def _confirmed_rate(confirmed: int, false_positive: int) -> Optional[float]:
73
+ """'Accuracy' for a detection: of the regions a human has actually
74
+ adjudicated as real-or-not, what share were real? None (not 0%) when
75
+ nothing's been reviewed yet, so the UI can show "not yet reviewed"
76
+ instead of a misleading 0%."""
77
+ denom = confirmed + false_positive
78
+ if denom == 0:
79
+ return None
80
+ return round(100 * confirmed / denom, 1)
81
+
82
+
83
  def _run_summary(row: dict) -> dict:
84
  run = row["run"]
85
  return {
 
94
  "unclassified": row["unclassified"],
95
  "confirmed": row["confirmed"],
96
  "falsePositive": row["falsePositive"],
97
+ "submitted": row["submitted"],
98
+ "confirmedRate": _confirmed_rate(row["confirmed"], row["falsePositive"]),
99
+ "changeTypes": row["changeTypes"],
100
  "createdAt": _isoformat_ist(run.created_at),
101
  **_run_thumbs(run),
102
  }
103
 
104
 
105
+ def _image_counts_by_location(db: Session) -> tuple[dict[str, int], dict[tuple[str, str], int]]:
106
+ """Image Library counts grouped by zone name and by (zone, village) name
107
+ — joined through the same DdaZone/DdaVillage masters the dashboard's
108
+ zone/village strings already come from, so the names line up exactly
109
+ with what dashboard_regions()/dashboard_villages() group by."""
110
+ from sqlalchemy import func as sa_func
111
+
112
+ from .models import DdaVillage, DdaZone
113
+ from .tree.models import ImageLibrary
114
+
115
+ by_zone: dict[str, int] = defaultdict(int)
116
+ by_village: dict[tuple[str, str], int] = defaultdict(int)
117
+
118
+ zone_rows = (
119
+ db.query(DdaZone.name, sa_func.count(ImageLibrary.id))
120
+ .join(ImageLibrary, ImageLibrary.zone_id == DdaZone.id)
121
+ .group_by(DdaZone.name)
122
+ .all()
123
+ )
124
+ for name, cnt in zone_rows:
125
+ by_zone[name] = cnt
126
+
127
+ village_rows = (
128
+ db.query(DdaZone.name, DdaVillage.name, sa_func.count(ImageLibrary.id))
129
+ .join(DdaVillage, DdaVillage.zone_id == DdaZone.id)
130
+ .join(ImageLibrary, ImageLibrary.village_id == DdaVillage.id)
131
+ .group_by(DdaZone.name, DdaVillage.name)
132
+ .all()
133
+ )
134
+ for zone_name, village_name, cnt in village_rows:
135
+ by_village[(zone_name, village_name)] = cnt
136
+
137
+ return by_zone, by_village
138
+
139
+
140
  def _accessible_run_filter(db: Session, user: User):
141
  """Same run-visibility rule as GET /api/history in app/main.py: the
142
  user's own runs, plus any run that came from the auto-schedule (visible
 
157
  return filt
158
 
159
 
160
+ def _parse_date_bound(value: Optional[str], *, end_of_day: bool = False) -> Optional[datetime]:
161
+ """Parse a 'YYYY-MM-DD' filter bound (as sent by an <input type=date>) as
162
+ an IST calendar day — every timestamp in this app is shown in IST, so a
163
+ user picking "7 Sep" means the whole IST day, not UTC midnight — then
164
+ convert to UTC since DetectionRun.created_at is stored in UTC."""
165
+ if not value or not value.strip():
166
+ return None
167
+ try:
168
+ d = datetime.strptime(value.strip(), "%Y-%m-%d")
169
+ except ValueError:
170
+ raise HTTPException(status_code=400, detail=f"Invalid date '{value}', expected YYYY-MM-DD")
171
+ if end_of_day:
172
+ d = d + timedelta(days=1, microseconds=-1)
173
+ return d.replace(tzinfo=_IST).astimezone(timezone.utc)
174
+
175
+
176
+ def _live_locations_for(db: Session, run_ids: list[int]) -> dict[int, tuple[str, str]]:
177
+ """Zone/village for runs that still have a blank one, re-derived on every
178
+ call from the run's original before/after image paths (kept on its
179
+ DetectionJob) and those images' *current* zone/village. This is what
180
+ makes assigning a location to an image in the Image Library immediately
181
+ reflected on the dashboard for every past run that used it — no separate
182
+ backfill step, no stale copy to re-sync."""
183
+ if not run_ids:
184
+ return {}
185
+ from .tree.image_service import resolve_run_location
186
+
187
+ jobs = db.query(DetectionJob).filter(DetectionJob.run_id.in_(run_ids)).all()
188
+ out: dict[int, tuple[str, str]] = {}
189
+ for job in jobs:
190
+ try:
191
+ params = json.loads(job.params_json or "{}")
192
+ except json.JSONDecodeError:
193
+ continue
194
+ base_path = params.get("base_path", "")
195
+ comparison_path = params.get("comparison_path", "")
196
+ if not base_path and not comparison_path:
197
+ continue
198
+ out[job.run_id] = resolve_run_location(db, base_path, comparison_path)
199
+ return out
200
+
201
+
202
+ def _run_change_type_counts(run: DetectionRun) -> dict[str, int]:
203
+ """How many of this run's regions fall into each fixed change-type
204
+ category (New Construction/Demolition/Extension/Vegetation Change/
205
+ Other) — already computed per region at detection time (see
206
+ change_type_map.py) and stored in regions_json; this just tallies it."""
207
+ try:
208
+ regions = json.loads(run.regions_json or "[]")
209
+ except json.JSONDecodeError:
210
+ return {}
211
+ counts: dict[str, int] = defaultdict(int)
212
+ for region in regions:
213
+ if region.get("suppressed"):
214
+ continue
215
+ ctype = region.get("ddaChangeType") or "Other"
216
+ counts[ctype] += 1
217
+ return dict(counts)
218
+
219
+
220
+ def _merge_change_type_counts(items: list[dict]) -> dict[str, int]:
221
+ """Sum per-run changeTypes dicts into one, ordered per CHANGE_TYPE_ORDER
222
+ with any unrecognized label appended at the end rather than dropped."""
223
+ total: dict[str, int] = defaultdict(int)
224
+ for i in items:
225
+ for ctype, cnt in i["changeTypes"].items():
226
+ total[ctype] += cnt
227
+ ordered = {k: total[k] for k in CHANGE_TYPE_ORDER if k in total}
228
+ ordered.update({k: v for k, v in total.items() if k not in ordered})
229
+ return ordered
230
+
231
+
232
+ def _load_dataset(
233
+ db: Session,
234
+ user: User,
235
+ *,
236
+ date_from: Optional[str] = None,
237
+ date_to: Optional[str] = None,
238
+ ) -> list[dict]:
239
  """One query for every accessible run + one grouped query for
240
  classification counts; every endpoint below aggregates from this in
241
  Python — mirrors the simple query style already used by
242
  admin_routes.admin_status(). Runs with a blank zone/village are bucketed
243
+ under "Unassigned" so totals reconcile, after first trying to live-derive
244
+ one from the source images (see _live_locations_for). ``date_from``/
245
+ ``date_to`` are optional 'YYYY-MM-DD' bounds on created_at (inclusive on
246
+ both ends).
247
  """
248
+ from_dt = _parse_date_bound(date_from)
249
+ to_dt = _parse_date_bound(date_to, end_of_day=True)
250
+
251
+ query = db.query(DetectionRun).filter(_accessible_run_filter(db, user))
252
+ if from_dt:
253
+ query = query.filter(DetectionRun.created_at >= from_dt)
254
+ if to_dt:
255
+ query = query.filter(DetectionRun.created_at <= to_dt)
256
+ runs = query.order_by(DetectionRun.created_at.asc()).all()
257
  counts = bulk_classification_counts(db)
258
  # Same role resolution as dda_auth.get_user_role (raw User.role string,
259
  # falling back to "analyst") but bulk-loaded to avoid a query per run.
260
  roles_by_user = {uid: (role or DEFAULT_ROLE) for uid, role in db.query(User.id, User.role).all()}
261
+ blank_ids = [r.id for r in runs if not (r.zone or "").strip() or not (r.village or "").strip()]
262
+ live = _live_locations_for(db, blank_ids)
263
  rows = []
264
  for run in runs:
265
+ zone = (run.zone or "").strip()
266
+ village = (run.village or "").strip()
267
+ if not zone or not village:
268
+ auto_zone, auto_village = live.get(run.id, ("", ""))
269
+ zone = zone or auto_zone
270
+ village = village or auto_village
271
  c = counts.get(run.id, {})
272
  classified = c.get("confirmed", 0) + c.get("false_positive", 0) + c.get("submitted", 0)
273
  unclassified = max(0, (run.regions_count or 0) - classified)
274
  rows.append({
275
  "run": run,
276
+ "zone": zone or UNASSIGNED,
277
+ "village": village or UNASSIGNED,
278
  "role": roles_by_user.get(run.user_id, DEFAULT_ROLE),
279
  "confirmed": c.get("confirmed", 0),
280
  "falsePositive": c.get("false_positive", 0),
281
  "submitted": c.get("submitted", 0),
282
  "unclassified": unclassified,
283
+ "changeTypes": _run_change_type_counts(run),
284
  })
285
  return rows
286
 
287
 
288
+ def _summary_from_rows(rows: list[dict], db: Session, user: User) -> dict:
 
 
 
 
289
  total_detected = sum((r["run"].regions_count or 0) for r in rows)
290
  pct_values = [r["run"].change_percentage for r in rows if r["run"].change_percentage is not None]
291
  zones = {r["zone"] for r in rows}
292
  villages = {(r["zone"], r["village"]) for r in rows}
293
 
294
+ from .models import DdaVillage, DdaZone
295
+ zones_total = db.query(DdaZone).count()
296
+ villages_total = db.query(DdaVillage).count()
297
+
298
  return {
299
  "totalRuns": len(rows),
300
  "totalDetectedChanges": total_detected,
 
305
  "avgChangePercentage": round(sum(pct_values) / len(pct_values), 2) if pct_values else None,
306
  "totalRegions": len(zones),
307
  "totalVillages": len(villages),
308
+ "zonesTotal": zones_total,
309
+ "villagesTotal": villages_total,
310
+ "changeTypes": _merge_change_type_counts(rows),
311
  "jobsQueued": db.query(DetectionJob).filter(DetectionJob.status == "queued", DetectionJob.created_by == user.id).count(),
312
  "jobsRunning": db.query(DetectionJob).filter(DetectionJob.status == "running", DetectionJob.created_by == user.id).count(),
313
  "latestRun": _run_summary(rows[-1]) if rows else None,
314
  }
315
 
316
 
317
+ def _regions_from_rows(rows: list[dict], db: Session) -> list[dict]:
 
 
 
318
  by_zone: dict[str, list] = defaultdict(list)
319
  for r in rows:
320
  by_zone[r["zone"]].append(r)
321
+ image_counts_by_zone, _ = _image_counts_by_location(db)
322
 
323
  out = []
324
  for zone, items in by_zone.items():
325
  pct_values = [i["run"].change_percentage for i in items if i["run"].change_percentage is not None]
326
+ confirmed = sum(i["confirmed"] for i in items)
327
+ false_positive = sum(i["falsePositive"] for i in items)
328
  out.append({
329
  "zone": zone,
330
  "runsCount": len(items),
331
  "detectedChanges": sum((i["run"].regions_count or 0) for i in items),
332
  "unclassified": sum(i["unclassified"] for i in items),
333
+ "confirmed": confirmed,
334
+ "falsePositive": false_positive,
335
+ "submitted": sum(i["submitted"] for i in items),
336
+ "confirmedRate": _confirmed_rate(confirmed, false_positive),
337
  "avgChangePercentage": round(sum(pct_values) / len(pct_values), 2) if pct_values else None,
338
  "villagesCount": len({i["village"] for i in items}),
339
+ "imageCount": image_counts_by_zone.get(zone, 0),
340
+ "changeTypes": _merge_change_type_counts(items),
341
  "lastDetectionAt": _isoformat_ist(max(i["run"].created_at for i in items)),
342
  })
343
  out.sort(key=lambda x: x["runsCount"], reverse=True)
344
+ return out
345
 
346
 
347
+ def _roles_from_rows(rows: list[dict]) -> list[dict]:
 
 
 
 
348
  by_role: dict[str, list] = defaultdict(list)
349
  for r in rows:
350
  by_role[r["role"]].append(r)
 
367
  "lastDetectionAt": _isoformat_ist(max(i["run"].created_at for i in items)),
368
  })
369
  out.sort(key=lambda x: (_rank(x["role"]), x["role"]))
370
+ return out
371
 
372
 
373
+ def _villages_from_rows(rows: list[dict], db: Session, zone: Optional[str] = None) -> list[dict]:
 
 
 
 
 
 
 
374
  if zone:
375
  rows = [r for r in rows if r["zone"] == zone]
376
  by_village: dict[tuple, list] = defaultdict(list)
377
  for r in rows:
378
  by_village[(r["zone"], r["village"])].append(r)
379
+ _, image_counts_by_village = _image_counts_by_location(db)
380
 
381
  out = []
382
  for (z, v), items in by_village.items():
383
  pct_values = [i["run"].change_percentage for i in items if i["run"].change_percentage is not None]
384
+ confirmed = sum(i["confirmed"] for i in items)
385
+ false_positive = sum(i["falsePositive"] for i in items)
386
  out.append({
387
  "zone": z,
388
  "village": v,
389
  "runsCount": len(items),
390
  "detectedChanges": sum((i["run"].regions_count or 0) for i in items),
391
  "unclassified": sum(i["unclassified"] for i in items),
392
+ "confirmed": confirmed,
393
+ "falsePositive": false_positive,
394
+ "submitted": sum(i["submitted"] for i in items),
395
+ "confirmedRate": _confirmed_rate(confirmed, false_positive),
396
  "avgChangePercentage": round(sum(pct_values) / len(pct_values), 2) if pct_values else None,
397
+ "imageCount": image_counts_by_village.get((z, v), 0),
398
+ "changeTypes": _merge_change_type_counts(items),
399
  "lastDetectionAt": _isoformat_ist(max(i["run"].created_at for i in items)),
400
  })
401
  out.sort(key=lambda x: x["runsCount"], reverse=True)
402
+ return out
403
 
404
 
405
+ def _trend_from_rows(rows: list[dict], months: int = 6) -> list[dict]:
 
 
 
 
 
 
 
 
406
  now = datetime.now(timezone.utc)
407
  keys = []
408
  y, m = now.year, now.month
 
425
  counts[key]["runsCount"] += 1
426
  counts[key]["detectedChanges"] += (r["run"].regions_count or 0)
427
 
428
+ return [
429
  {
430
  "period": f"{y}-{m:02d}",
431
  "label": f"{MONTH_NAMES[m]} {y}",
 
434
  }
435
  for (y, m) in keys
436
  ]
437
+
438
+
439
+ @router.get("/dashboard/bootstrap")
440
+ def dashboard_bootstrap(
441
+ date_from: Optional[str] = Query(None),
442
+ date_to: Optional[str] = Query(None),
443
+ months: int = Query(6, ge=1, le=24),
444
+ db: Session = Depends(get_db),
445
+ user: User = Depends(current_dda_user),
446
+ ):
447
+ """Single payload for home dashboard — one `_load_dataset` pass."""
448
+ _require_dda()
449
+ rows = _load_dataset(db, user, date_from=date_from, date_to=date_to)
450
+ return {
451
+ "summary": _summary_from_rows(rows, db, user),
452
+ "roles": _roles_from_rows(rows),
453
+ "regions": _regions_from_rows(rows, db),
454
+ "villages": _villages_from_rows(rows, db),
455
+ "trend": {"buckets": _trend_from_rows(rows, months=months)},
456
+ }
457
+
458
+
459
+ @router.get("/dashboard/summary")
460
+ def dashboard_summary(
461
+ date_from: Optional[str] = Query(None),
462
+ date_to: Optional[str] = Query(None),
463
+ db: Session = Depends(get_db),
464
+ user: User = Depends(current_dda_user),
465
+ ):
466
+ _require_dda()
467
+ rows = _load_dataset(db, user, date_from=date_from, date_to=date_to)
468
+ return _summary_from_rows(rows, db, user)
469
+
470
+
471
+ @router.get("/dashboard/regions")
472
+ def dashboard_regions(
473
+ date_from: Optional[str] = Query(None),
474
+ date_to: Optional[str] = Query(None),
475
+ db: Session = Depends(get_db),
476
+ user: User = Depends(current_dda_user),
477
+ ):
478
+ _require_dda()
479
+ rows = _load_dataset(db, user, date_from=date_from, date_to=date_to)
480
+ return {"regions": _regions_from_rows(rows, db)}
481
+
482
+
483
+ @router.get("/dashboard/roles")
484
+ def dashboard_roles(
485
+ date_from: Optional[str] = Query(None),
486
+ date_to: Optional[str] = Query(None),
487
+ db: Session = Depends(get_db),
488
+ user: User = Depends(current_dda_user),
489
+ ):
490
+ """Detection activity grouped by the role of the user who ran it."""
491
+ _require_dda()
492
+ rows = _load_dataset(db, user, date_from=date_from, date_to=date_to)
493
+ return {"roles": _roles_from_rows(rows)}
494
+
495
+
496
+ @router.get("/dashboard/villages")
497
+ def dashboard_villages(
498
+ zone: Optional[str] = Query(None),
499
+ date_from: Optional[str] = Query(None),
500
+ date_to: Optional[str] = Query(None),
501
+ db: Session = Depends(get_db),
502
+ user: User = Depends(current_dda_user),
503
+ ):
504
+ _require_dda()
505
+ rows = _load_dataset(db, user, date_from=date_from, date_to=date_to)
506
+ return {"villages": _villages_from_rows(rows, db, zone=zone)}
507
+
508
+
509
+ @router.get("/dashboard/trend")
510
+ def dashboard_trend(
511
+ months: int = Query(6, ge=1, le=24),
512
+ db: Session = Depends(get_db),
513
+ user: User = Depends(current_dda_user),
514
+ ):
515
+ _require_dda()
516
+ rows = _load_dataset(db, user)
517
+ return {"buckets": _trend_from_rows(rows, months=months)}
518
 
519
 
520
  @router.get("/dashboard/location")
 
534
  previous = history[-2] if len(history) > 1 else None
535
 
536
  comparison = None
537
+ summary = None
538
  if latest and previous:
539
+ change_delta = (
540
+ round(latest["changePercentage"] - previous["changePercentage"], 2)
541
+ if latest["changePercentage"] is not None and previous["changePercentage"] is not None
542
+ else None
543
+ )
544
+ regions_delta = latest["regionsCount"] - previous["regionsCount"]
545
+ rate_delta = (
546
+ round(latest["confirmedRate"] - previous["confirmedRate"], 1)
547
+ if latest["confirmedRate"] is not None and previous["confirmedRate"] is not None
548
+ else None
549
+ )
550
  comparison = {
551
+ "changePercentageDelta": change_delta,
552
+ "regionsCountDelta": regions_delta,
553
+ "confirmedDelta": latest["confirmed"] - previous["confirmed"],
554
+ "falsePositiveDelta": latest["falsePositive"] - previous["falsePositive"],
555
+ "unclassifiedDelta": latest["unclassified"] - previous["unclassified"],
556
+ "confirmedRateDelta": rate_delta,
557
  }
558
+ summary = _build_comparison_summary(latest, previous, comparison)
559
 
560
  return {
561
  "zone": zone,
 
564
  "latest": latest,
565
  "previous": previous,
566
  "comparison": comparison,
567
+ "summary": summary,
568
  "history": list(reversed(history)),
569
  }
570
 
571
 
572
+ def _build_comparison_summary(latest: dict, previous: dict, comparison: dict) -> str:
573
+ """Plain-English recap of how the latest run compares to the previous
574
+ one at this location — change area, region count, and review accuracy
575
+ (confirmed rate) all in one sentence set, so the dashboard doesn't make
576
+ someone read four numbers to know if things got better or worse."""
577
+ parts = []
578
+
579
+ cd = comparison["changePercentageDelta"]
580
+ if cd is not None:
581
+ if abs(cd) < 0.01:
582
+ parts.append("Change area is essentially unchanged from the previous run.")
583
+ else:
584
+ direction = "up" if cd > 0 else "down"
585
+ parts.append(
586
+ f"Change area is {direction} {abs(cd):.2f} percentage points "
587
+ f"({previous['changePercentage']:.2f}% → {latest['changePercentage']:.2f}%)."
588
+ )
589
+
590
+ rd = comparison["regionsCountDelta"]
591
+ if rd != 0:
592
+ parts.append(f"{abs(rd)} {'more' if rd > 0 else 'fewer'} region(s) detected than last time.")
593
+ else:
594
+ parts.append("Same number of regions detected as last time.")
595
+
596
+ rate_delta = comparison["confirmedRateDelta"]
597
+ if rate_delta is not None:
598
+ if abs(rate_delta) < 0.1:
599
+ parts.append(f"Confirmed rate held steady at {latest['confirmedRate']:.1f}%.")
600
+ else:
601
+ direction = "improved" if rate_delta > 0 else "dropped"
602
+ parts.append(
603
+ f"Confirmed rate {direction} {abs(rate_delta):.1f} points "
604
+ f"({previous['confirmedRate']:.1f}% → {latest['confirmedRate']:.1f}%)."
605
+ )
606
+ elif latest["confirmedRate"] is not None:
607
+ parts.append(f"Confirmed rate is {latest['confirmedRate']:.1f}% (previous run not yet reviewed).")
608
+
609
+ fpd = comparison["falsePositiveDelta"]
610
+ if fpd != 0:
611
+ parts.append(f"{abs(fpd)} {'more' if fpd > 0 else 'fewer'} false positive(s) flagged.")
612
+
613
+ return " ".join(parts)
614
+
615
+
616
  @router.get("/dashboard/recent-reports")
617
  def dashboard_recent_reports(
618
  limit: int = Query(8, ge=1, le=50),
619
+ date_from: Optional[str] = Query(None),
620
+ date_to: Optional[str] = Query(None),
621
  db: Session = Depends(get_db),
622
  user: User = Depends(current_dda_user),
623
  ):
624
  _require_dda()
625
+ rows = _load_dataset(db, user, date_from=date_from, date_to=date_to)
626
  rows.sort(key=lambda r: r["run"].created_at, reverse=True)
627
  return {"reports": [_run_summary(r) for r in rows[:limit]]}
628
+
629
+
630
+ @router.get("/dashboard/library-stats")
631
+ def dashboard_library_stats(db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
632
+ """Image Library summary for the dashboard — total images, zone/village
633
+ breakdown, format mix, and how many still have no zone/village assigned.
634
+ Independent of detection history and not scoped per-user: the library
635
+ itself isn't user-scoped anywhere else in the app (matches
636
+ local_routes.py's image listing), so this reflects the whole library as
637
+ it stands right now, for every viewer.
638
+ """
639
+ _require_dda()
640
+ from sqlalchemy import func as sa_func
641
+
642
+ from .tree.models import ImageLibrary
643
+
644
+ total = db.query(ImageLibrary).count()
645
+ by_zone, by_village = _image_counts_by_location(db)
646
+ unassigned = db.query(ImageLibrary).filter(ImageLibrary.zone_id.is_(None)).count()
647
+
648
+ format_rows = (
649
+ db.query(ImageLibrary.format, sa_func.count(ImageLibrary.id))
650
+ .group_by(ImageLibrary.format)
651
+ .all()
652
+ )
653
+ by_format = sorted(
654
+ ({"format": fmt or "Unknown", "count": cnt} for fmt, cnt in format_rows),
655
+ key=lambda x: -x["count"],
656
+ )
657
+ zones_out = sorted(
658
+ ({"zone": z, "imageCount": c} for z, c in by_zone.items()),
659
+ key=lambda x: -x["imageCount"],
660
+ )
661
+ villages_out = sorted(
662
+ ({"zone": z, "village": v, "imageCount": c} for (z, v), c in by_village.items()),
663
+ key=lambda x: -x["imageCount"],
664
+ )
665
+
666
+ return {
667
+ "totalImages": total,
668
+ "assignedImages": total - unassigned,
669
+ "unassignedImages": unassigned,
670
+ "zonesCovered": len(by_zone),
671
+ "villagesCovered": len(by_village),
672
+ "byZone": zones_out,
673
+ "byVillage": villages_out,
674
+ "byFormat": by_format,
675
+ }
app/dda/detect_service.py CHANGED
@@ -27,6 +27,17 @@ THUMB_MAX_SIZE = 200
27
  MAX_POLYGON_VERTICES = 100
28
 
29
 
 
 
 
 
 
 
 
 
 
 
 
30
  def _serialize_polygon(raw) -> Optional[list]:
31
  """Normalize an engine polygon to ``[[x, y], ...]`` ints, or None.
32
 
@@ -225,9 +236,7 @@ def run_detection_and_save(
225
  else:
226
  user = get_or_create_guest_user(db)
227
  db.commit()
228
- detection_sensitivity = max(0.0, min(1.0, float(detection_sensitivity)))
229
- if min_region_area is not None:
230
- min_region_area = int(max(50, min(10000, min_region_area)))
231
 
232
  def _on_engine_progress(engine_pct: int, stage: str) -> None:
233
  # Map engine 0–100% into job 15–78%
@@ -414,6 +423,8 @@ def run_detection_and_save(
414
  after_thumb_path=relative_after_thumb,
415
  after_full_path=relative_after_full,
416
  regions_json=json.dumps(regions_serializable),
 
 
417
  )
418
  db.add(run)
419
  db.commit()
@@ -468,6 +479,8 @@ def run_detection_and_save(
468
  "changedPixels": changed_px,
469
  "unchangedPixels": int(stats["unchanged_pixels"]),
470
  "changePercentage": change_pct,
 
 
471
  "thresholdDebug": stats.get("threshold_debug", {}),
472
  "params": stats.get("params", {}),
473
  "alignmentWarning": stats.get("alignment_warning"),
 
27
  MAX_POLYGON_VERTICES = 100
28
 
29
 
30
+ def clamp_detection_params(detection_sensitivity, min_region_area):
31
+ """Single source of truth for valid ranges, shared by every entry point
32
+ that accepts these as Form params (app/main.py's /api/detect and this
33
+ module's run_detection_and_save, used by /api/dda/detect/from-library) —
34
+ previously each clamped independently and could drift apart."""
35
+ sensitivity = max(0.0, min(1.0, float(detection_sensitivity)))
36
+ if min_region_area is not None:
37
+ min_region_area = int(max(50, min(10000, min_region_area)))
38
+ return sensitivity, min_region_area
39
+
40
+
41
  def _serialize_polygon(raw) -> Optional[list]:
42
  """Normalize an engine polygon to ``[[x, y], ...]`` ints, or None.
43
 
 
236
  else:
237
  user = get_or_create_guest_user(db)
238
  db.commit()
239
+ detection_sensitivity, min_region_area = clamp_detection_params(detection_sensitivity, min_region_area)
 
 
240
 
241
  def _on_engine_progress(engine_pct: int, stage: str) -> None:
242
  # Map engine 0–100% into job 15–78%
 
423
  after_thumb_path=relative_after_thumb,
424
  after_full_path=relative_after_full,
425
  regions_json=json.dumps(regions_serializable),
426
+ detection_sensitivity=detection_sensitivity,
427
+ min_region_area=min_region_area,
428
  )
429
  db.add(run)
430
  db.commit()
 
479
  "changedPixels": changed_px,
480
  "unchangedPixels": int(stats["unchanged_pixels"]),
481
  "changePercentage": change_pct,
482
+ "detectionSensitivity": detection_sensitivity,
483
+ "minRegionArea": min_region_area,
484
  "thresholdDebug": stats.get("threshold_debug", {}),
485
  "params": stats.get("params", {}),
486
  "alignmentWarning": stats.get("alignment_warning"),
app/dda/exception_routes.py CHANGED
@@ -34,6 +34,7 @@ def _entry_dict(entry: ExceptionLog, *, with_traceback: bool = False) -> dict:
34
  "statusCode": entry.status_code,
35
  "handled": bool(entry.handled),
36
  "userEmail": entry.user_email or "",
 
37
  }
38
  if with_traceback:
39
  d["traceback"] = entry.traceback or ""
 
34
  "statusCode": entry.status_code,
35
  "handled": bool(entry.handled),
36
  "userEmail": entry.user_email or "",
37
+ "userRole": getattr(entry, "user_role", None) or "",
38
  }
39
  if with_traceback:
40
  d["traceback"] = entry.traceback or ""
app/dda/job_runner.py CHANGED
@@ -126,14 +126,21 @@ def _run_job_sync(job_id: int) -> None:
126
  "Job %d detection max_size=%d (loaded %s)",
127
  job_id, detect_max, before_pil.size,
128
  )
 
 
 
 
 
 
 
129
  result = run_detection_and_save(
130
  db,
131
  before_pil,
132
  after_pil,
133
  method=method,
134
  title=title,
135
- zone=params.get("zone", ""),
136
- village=params.get("village", ""),
137
  enable_registration=bool(params.get("enable_registration", True)),
138
  enable_normalization=bool(params.get("enable_normalization", True)),
139
  detection_sensitivity=float(params.get("detection_sensitivity", 0.45)),
 
126
  "Job %d detection max_size=%d (loaded %s)",
127
  job_id, detect_max, before_pil.size,
128
  )
129
+ zone = (params.get("zone") or "").strip()
130
+ village = (params.get("village") or "").strip()
131
+ if not zone or not village:
132
+ from .tree.image_service import resolve_run_location
133
+ auto_zone, auto_village = resolve_run_location(db, base_path, comparison_path)
134
+ zone = zone or auto_zone
135
+ village = village or auto_village
136
  result = run_detection_and_save(
137
  db,
138
  before_pil,
139
  after_pil,
140
  method=method,
141
  title=title,
142
+ zone=zone,
143
+ village=village,
144
  enable_registration=bool(params.get("enable_registration", True)),
145
  enable_normalization=bool(params.get("enable_normalization", True)),
146
  detection_sensitivity=float(params.get("detection_sensitivity", 0.45)),
app/dda/local_routes.py CHANGED
@@ -112,7 +112,14 @@ def local_library_config(user: User = Depends(current_dda_user)):
112
  def local_images(
113
  node_id: Optional[int] = Query(None),
114
  q: Optional[str] = Query(None),
115
- limit: Optional[int] = Query(None, ge=1, le=500),
 
 
 
 
 
 
 
116
  offset: int = Query(0, ge=0),
117
  db: Session = Depends(get_db),
118
  user: User = Depends(current_dda_user),
@@ -122,9 +129,16 @@ def local_images(
122
  db,
123
  node_id=node_id,
124
  query=q,
 
 
 
 
 
 
 
125
  include_bounds=False,
126
  slim=True,
127
- limit=limit,
128
  offset=offset,
129
  )
130
 
@@ -132,19 +146,22 @@ def local_images(
132
  @router.get("/local/area-groups")
133
  def local_area_groups(
134
  node_id: Optional[int] = Query(None),
 
135
  db: Session = Depends(get_db),
136
  user: User = Depends(current_dda_user),
137
  ):
138
  """Images covering the same place, with oldest → Before and newest → After."""
139
  _require_dda()
140
- from .tree.area_groups import filter_area_groups_for_node, load_cached_area_groups
141
  from .tree.tree_service import get_node_or_404
142
 
143
- payload = load_cached_area_groups(db)
144
  if node_id:
145
- node = get_node_or_404(db, node_id)
146
- return filter_area_groups_for_node(payload, node)
147
- return payload
 
 
 
148
 
149
 
150
  @router.get("/local/thumb")
@@ -377,7 +394,7 @@ async def detect_from_library(
377
  enable_registration: bool = Form(True),
378
  enable_normalization: bool = Form(True),
379
  detection_sensitivity: float = Form(0.5),
380
- min_region_area: Optional[int] = Form(150),
381
  notify_email: Optional[str] = Form(None),
382
  roi: Optional[str] = Form(None),
383
  shape_mode: str = Form("polygon"),
@@ -449,6 +466,12 @@ async def detect_from_library(
449
  if title == "Untitled run":
450
  title = f"{Path(base_norm).name} vs {Path(comp_norm).name}"
451
 
 
 
 
 
 
 
452
  try:
453
  # Use fullres load cap (up to DETECTION_FULLRES_MAX_SIDE), not the
454
  # legacy 4096 downscaled cap — otherwise max-res config is ignored.
 
112
  def local_images(
113
  node_id: Optional[int] = Query(None),
114
  q: Optional[str] = Query(None),
115
+ year: Optional[int] = Query(None),
116
+ image_type: Optional[str] = Query(None),
117
+ zone_id: Optional[int] = Query(None),
118
+ village_id: Optional[int] = Query(None),
119
+ capture_date_from: Optional[str] = Query(None),
120
+ capture_date_to: Optional[str] = Query(None),
121
+ source: Optional[str] = Query(None),
122
+ limit: Optional[int] = Query(50, ge=1, le=500),
123
  offset: int = Query(0, ge=0),
124
  db: Session = Depends(get_db),
125
  user: User = Depends(current_dda_user),
 
129
  db,
130
  node_id=node_id,
131
  query=q,
132
+ year=year,
133
+ image_type=image_type,
134
+ zone_id=zone_id,
135
+ village_id=village_id,
136
+ capture_date_from=capture_date_from,
137
+ capture_date_to=capture_date_to,
138
+ source=source,
139
  include_bounds=False,
140
  slim=True,
141
+ limit=limit if limit is not None else 50,
142
  offset=offset,
143
  )
144
 
 
146
  @router.get("/local/area-groups")
147
  def local_area_groups(
148
  node_id: Optional[int] = Query(None),
149
+ include_unpaired: int = Query(0, ge=0, le=1),
150
  db: Session = Depends(get_db),
151
  user: User = Depends(current_dda_user),
152
  ):
153
  """Images covering the same place, with oldest → Before and newest → After."""
154
  _require_dda()
155
+ from .tree.area_groups import load_cached_area_groups
156
  from .tree.tree_service import get_node_or_404
157
 
 
158
  if node_id:
159
+ get_node_or_404(db, node_id)
160
+ return load_cached_area_groups(
161
+ db,
162
+ node_id=node_id,
163
+ include_unpaired=bool(include_unpaired),
164
+ )
165
 
166
 
167
  @router.get("/local/thumb")
 
394
  enable_registration: bool = Form(True),
395
  enable_normalization: bool = Form(True),
396
  detection_sensitivity: float = Form(0.5),
397
+ min_region_area: Optional[int] = Form(None),
398
  notify_email: Optional[str] = Form(None),
399
  roi: Optional[str] = Form(None),
400
  shape_mode: str = Form("polygon"),
 
466
  if title == "Untitled run":
467
  title = f"{Path(base_norm).name} vs {Path(comp_norm).name}"
468
 
469
+ if not zone.strip() or not village.strip():
470
+ from .tree.image_service import resolve_run_location
471
+ auto_zone, auto_village = resolve_run_location(db, base_norm, comp_norm)
472
+ zone = zone.strip() or auto_zone
473
+ village = village.strip() or auto_village
474
+
475
  try:
476
  # Use fullres load cap (up to DETECTION_FULLRES_MAX_SIDE), not the
477
  # legacy 4096 downscaled cap — otherwise max-res config is ignored.
app/dda/masters_routes.py CHANGED
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
10
  from ..database import get_db
11
  from ..models import User
12
  from .models import DdaVillage, DdaZone, ImageAsset
 
13
  from .rbac.permissions import require_module_permission
14
  from .seed import seed_delhi_hierarchy
15
 
@@ -67,6 +68,28 @@ def _village_dict(village: DdaVillage, zone: Optional[DdaZone] = None) -> dict:
67
  }
68
 
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  @router.get("/masters/zones")
71
  def list_zones(
72
  db: Session = Depends(get_db),
@@ -132,7 +155,10 @@ def delete_zone(
132
  zone = db.query(DdaZone).filter(DdaZone.id == zone_id).first()
133
  if not zone:
134
  raise HTTPException(status_code=404, detail="Zone not found")
135
- in_use = db.query(ImageAsset).filter(ImageAsset.zone_id == zone_id).count()
 
 
 
136
  if in_use:
137
  raise HTTPException(status_code=409, detail="This zone is used by library images and cannot be deleted.")
138
  zone_name = zone.name
@@ -231,7 +257,10 @@ def delete_village(
231
  village = db.query(DdaVillage).filter(DdaVillage.id == village_id).first()
232
  if not village:
233
  raise HTTPException(status_code=404, detail="Location not found")
234
- in_use = db.query(ImageAsset).filter(ImageAsset.village_id == village_id).count()
 
 
 
235
  if in_use:
236
  raise HTTPException(status_code=409, detail="This location is used by library images and cannot be deleted.")
237
  village_name = village.name
 
10
  from ..database import get_db
11
  from ..models import User
12
  from .models import DdaVillage, DdaZone, ImageAsset
13
+ from .tree.models import ImageLibrary
14
  from .rbac.permissions import require_module_permission
15
  from .seed import seed_delhi_hierarchy
16
 
 
68
  }
69
 
70
 
71
+ @router.get("/masters/options")
72
+ def masters_options(
73
+ db: Session = Depends(get_db),
74
+ user: User = Depends(require_module_permission("library", "view")),
75
+ ):
76
+ """Zone + village lists for pickers (upload form).
77
+
78
+ Separate from /masters/zones because choosing a location while uploading
79
+ only needs to read the masters, not manage them.
80
+ """
81
+ _require_dda()
82
+ seed_delhi_hierarchy(db)
83
+ zones = db.query(DdaZone).order_by(DdaZone.name).all()
84
+ villages = db.query(DdaVillage).order_by(DdaVillage.name).all()
85
+ return {
86
+ "zones": [{"id": z.id, "name": z.name} for z in zones],
87
+ "villages": [
88
+ {"id": v.id, "name": v.name, "zoneId": v.zone_id} for v in villages
89
+ ],
90
+ }
91
+
92
+
93
  @router.get("/masters/zones")
94
  def list_zones(
95
  db: Session = Depends(get_db),
 
155
  zone = db.query(DdaZone).filter(DdaZone.id == zone_id).first()
156
  if not zone:
157
  raise HTTPException(status_code=404, detail="Zone not found")
158
+ in_use = (
159
+ db.query(ImageAsset).filter(ImageAsset.zone_id == zone_id).count()
160
+ + db.query(ImageLibrary).filter(ImageLibrary.zone_id == zone_id).count()
161
+ )
162
  if in_use:
163
  raise HTTPException(status_code=409, detail="This zone is used by library images and cannot be deleted.")
164
  zone_name = zone.name
 
257
  village = db.query(DdaVillage).filter(DdaVillage.id == village_id).first()
258
  if not village:
259
  raise HTTPException(status_code=404, detail="Location not found")
260
+ in_use = (
261
+ db.query(ImageAsset).filter(ImageAsset.village_id == village_id).count()
262
+ + db.query(ImageLibrary).filter(ImageLibrary.village_id == village_id).count()
263
+ )
264
  if in_use:
265
  raise HTTPException(status_code=409, detail="This location is used by library images and cannot be deleted.")
266
  village_name = village.name
app/dda/rbac/permissions.py CHANGED
@@ -2,7 +2,7 @@
2
  routes/pages, and by the navbar to filter which menu items a user sees."""
3
  from __future__ import annotations
4
 
5
- from typing import List, Optional
6
 
7
  from fastapi import Depends, HTTPException
8
  from sqlalchemy.orm import Session
@@ -32,6 +32,32 @@ def get_role_rank(user: User, db: Session) -> Optional[int]:
32
  return role.rank if role else None
33
 
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  def user_can(user: User, db: Session, module_key: str, action: str = "view") -> bool:
36
  """action: 'view' | 'create' | 'edit' | 'delete'."""
37
  role = get_user_role_row(user, db)
@@ -77,28 +103,31 @@ def resolve_user_menu(user: User, db: Session) -> List[MenuItem]:
77
  a group item ("Configuration") which is only shown once it has at least
78
  one visible child — otherwise a role with no admin access would see an
79
  empty, dead group header."""
 
 
 
 
 
 
80
 
81
  def _visible(item: MenuItem) -> bool:
82
  if item.module_id is None:
83
  return True
84
- module = db.query(Module).filter(Module.id == item.module_id).first()
85
- return bool(module and user_can(user, db, module.key, "view"))
86
 
87
- top_level = (
88
  db.query(MenuItem)
89
- .filter(MenuItem.is_active.is_(True), MenuItem.parent_id.is_(None))
90
  .order_by(MenuItem.sort_order)
91
  .all()
92
  )
 
 
 
93
 
94
  visible = []
95
- for item in top_level:
96
- children = (
97
- db.query(MenuItem)
98
- .filter(MenuItem.is_active.is_(True), MenuItem.parent_id == item.id)
99
- .order_by(MenuItem.sort_order)
100
- .all()
101
- )
102
  item.visible_children = [c for c in children if _visible(c)]
103
  is_group = bool(children)
104
  if (item.visible_children if is_group else _visible(item)):
 
2
  routes/pages, and by the navbar to filter which menu items a user sees."""
3
  from __future__ import annotations
4
 
5
+ from typing import Dict, List, Optional
6
 
7
  from fastapi import Depends, HTTPException
8
  from sqlalchemy.orm import Session
 
32
  return role.rank if role else None
33
 
34
 
35
+ def _role_permission_rows(role: Optional[Role], db: Session) -> Dict[int, RolePermission]:
36
+ if not role:
37
+ return {}
38
+ return {
39
+ p.module_id: p
40
+ for p in db.query(RolePermission).filter(RolePermission.role_id == role.id).all()
41
+ }
42
+
43
+
44
+ def user_permissions_map(user: User, db: Session) -> dict:
45
+ """One role + one permissions query for every module (no per-action N+1)."""
46
+ role = get_user_role_row(user, db)
47
+ modules = db.query(Module).all()
48
+ perms = _role_permission_rows(role, db)
49
+ out = {}
50
+ for m in modules:
51
+ p = perms.get(m.id)
52
+ out[m.key] = {
53
+ "view": bool(p and p.can_view),
54
+ "create": bool(p and p.can_create),
55
+ "edit": bool(p and p.can_edit),
56
+ "delete": bool(p and p.can_delete),
57
+ }
58
+ return out
59
+
60
+
61
  def user_can(user: User, db: Session, module_key: str, action: str = "view") -> bool:
62
  """action: 'view' | 'create' | 'edit' | 'delete'."""
63
  role = get_user_role_row(user, db)
 
103
  a group item ("Configuration") which is only shown once it has at least
104
  one visible child — otherwise a role with no admin access would see an
105
  empty, dead group header."""
106
+ role = get_user_role_row(user, db)
107
+ can_view_module_ids = {
108
+ mid
109
+ for mid, p in _role_permission_rows(role, db).items()
110
+ if p.can_view
111
+ }
112
 
113
  def _visible(item: MenuItem) -> bool:
114
  if item.module_id is None:
115
  return True
116
+ return item.module_id in can_view_module_ids
 
117
 
118
+ items = (
119
  db.query(MenuItem)
120
+ .filter(MenuItem.is_active.is_(True))
121
  .order_by(MenuItem.sort_order)
122
  .all()
123
  )
124
+ children_by_parent: dict[Optional[int], list] = {}
125
+ for item in items:
126
+ children_by_parent.setdefault(item.parent_id, []).append(item)
127
 
128
  visible = []
129
+ for item in children_by_parent.get(None, []):
130
+ children = children_by_parent.get(item.id, [])
 
 
 
 
 
131
  item.visible_children = [c for c in children if _visible(c)]
132
  is_group = bool(children)
133
  if (item.visible_children if is_group else _visible(item)):
app/dda/rbac/routes.py CHANGED
@@ -9,12 +9,12 @@ from pydantic import BaseModel, EmailStr, Field
9
  from sqlalchemy.exc import IntegrityError
10
  from sqlalchemy.orm import Session
11
 
12
- from ...auth import get_password_hash, get_user_by_email, get_user_by_phone, normalize_phone
13
  from ...database import get_db
14
  from ...models import User
15
  from ..dda_auth import current_dda_user
16
  from .models import MenuItem, Module, Role, RolePermission
17
- from .permissions import require_module_permission, user_can
18
 
19
  router = APIRouter()
20
 
@@ -91,16 +91,7 @@ def my_permissions(db: Session = Depends(get_db), user: User = Depends(current_d
91
  show/hide/disable in the UI (Upload, Run detection, Delete, etc.) so
92
  the frontend always matches whatever's set in the Roles & Users
93
  permission matrix, without hardcoding any role name."""
94
- modules = db.query(Module).all()
95
- return {
96
- m.key: {
97
- "view": user_can(user, db, m.key, "view"),
98
- "create": user_can(user, db, m.key, "create"),
99
- "edit": user_can(user, db, m.key, "edit"),
100
- "delete": user_can(user, db, m.key, "delete"),
101
- }
102
- for m in modules
103
- }
104
 
105
 
106
  # ---------------------------------------------------------------- Roles ----
@@ -476,6 +467,7 @@ def update_user(
476
  target.role = role.name
477
  if body.password:
478
  target.hashed_password = get_password_hash(body.password)
 
479
  db.commit()
480
  _log_admin_action(db, "update_user", _user, {"user_id": target.id, "email": target.email, "role": role.name})
481
  return _user_dict(target, role.name, role.id)
 
9
  from sqlalchemy.exc import IntegrityError
10
  from sqlalchemy.orm import Session
11
 
12
+ from ...auth import get_password_hash, get_user_by_email, get_user_by_phone, normalize_phone, revoke_user_sessions
13
  from ...database import get_db
14
  from ...models import User
15
  from ..dda_auth import current_dda_user
16
  from .models import MenuItem, Module, Role, RolePermission
17
+ from .permissions import require_module_permission, user_can, user_permissions_map
18
 
19
  router = APIRouter()
20
 
 
91
  show/hide/disable in the UI (Upload, Run detection, Delete, etc.) so
92
  the frontend always matches whatever's set in the Roles & Users
93
  permission matrix, without hardcoding any role name."""
94
+ return user_permissions_map(user, db)
 
 
 
 
 
 
 
 
 
95
 
96
 
97
  # ---------------------------------------------------------------- Roles ----
 
467
  target.role = role.name
468
  if body.password:
469
  target.hashed_password = get_password_hash(body.password)
470
+ revoke_user_sessions(target)
471
  db.commit()
472
  _log_admin_action(db, "update_user", _user, {"user_id": target.id, "email": target.email, "role": role.name})
473
  return _user_dict(target, role.name, role.id)
app/dda/rbac/seed.py CHANGED
@@ -24,7 +24,7 @@ DEFAULT_ROLES = [
24
  # (key, name, description)
25
  DEFAULT_MODULES = [
26
  ("home", "Dashboard", "System dashboard — detection overview, regions, reports"),
27
- ("library", "Master Library", "Browse and upload satellite / drone imagery"),
28
  ("masters", "Masters", "Zone / district and village / location master data"),
29
  ("detect", "Change Detection", "Compare imagery and run detections"),
30
  ("reports", "Reports", "Detection history, exports, and PDF reports"),
@@ -42,7 +42,7 @@ DEFAULT_MODULES = [
42
  DEFAULT_MENU_ITEMS = [
43
  ("Dashboard", "/", "home", 0, None),
44
  ("Masters", "#masters", None, 1, None),
45
- ("Master Library", "/library", "library", 0, "Masters"),
46
  ("Zone / District", "/masters/zones", "masters", 1, "Masters"),
47
  ("Village / Location", "/masters/villages", "masters", 2, "Masters"),
48
  ("Change Detection", "/detect", "detect", 2, None),
 
24
  # (key, name, description)
25
  DEFAULT_MODULES = [
26
  ("home", "Dashboard", "System dashboard — detection overview, regions, reports"),
27
+ ("library", "Image Library", "Browse and upload satellite / drone imagery"),
28
  ("masters", "Masters", "Zone / district and village / location master data"),
29
  ("detect", "Change Detection", "Compare imagery and run detections"),
30
  ("reports", "Reports", "Detection history, exports, and PDF reports"),
 
42
  DEFAULT_MENU_ITEMS = [
43
  ("Dashboard", "/", "home", 0, None),
44
  ("Masters", "#masters", None, 1, None),
45
+ ("Image Library", "/library", "library", 0, "Masters"),
46
  ("Zone / District", "/masters/zones", "masters", 1, "Masters"),
47
  ("Village / Location", "/masters/villages", "masters", 2, "Masters"),
48
  ("Change Detection", "/detect", "detect", 2, None),
app/dda/report_pdf.py CHANGED
@@ -11,6 +11,16 @@ from ..models import DetectionRun
11
  from .detect_service import _isoformat_ist
12
 
13
 
 
 
 
 
 
 
 
 
 
 
14
  def _safe_filename(title: str, run_id: int) -> str:
15
  base = "".join(c if c.isalnum() or c in " -_" else "_" for c in (title or "report"))
16
  base = base.strip().replace(" ", "_")[:60] or "report"
@@ -31,6 +41,9 @@ def build_report_dict(run: DetectionRun, *, include_overlay_b64: bool = False, r
31
  "changedPixels": run.changed_pixels,
32
  "unchangedPixels": run.total_pixels - run.changed_pixels,
33
  "changePercentage": run.change_percentage,
 
 
 
34
  },
35
  "regions": regions,
36
  "regionsCount": run.regions_count,
@@ -80,8 +93,12 @@ def generate_report_pdf(run: DetectionRun, *, regions: Optional[List[dict]] = No
80
  Paragraph(f"<b>Location:</b> {location}", body),
81
  Paragraph(f"<b>Change:</b> {run.change_percentage:.2f}% ({run.changed_pixels:,} / {run.total_pixels:,} px)", body),
82
  Paragraph(f"<b>Regions detected:</b> {run.regions_count}", body),
83
- Spacer(1, 10),
84
  ]
 
 
 
 
 
85
 
86
  overlay_path = run.overlay_path
87
  if overlay_path:
 
11
  from .detect_service import _isoformat_ist
12
 
13
 
14
+ def _detection_max_side() -> Optional[int]:
15
+ """Detection resolution used across the app (see get_load_max_side) — a
16
+ static config value, not per-run, but useful context on a report."""
17
+ try:
18
+ from ..detection_config import get_load_max_side
19
+ return get_load_max_side()
20
+ except Exception:
21
+ return None
22
+
23
+
24
  def _safe_filename(title: str, run_id: int) -> str:
25
  base = "".join(c if c.isalnum() or c in " -_" else "_" for c in (title or "report"))
26
  base = base.strip().replace(" ", "_")[:60] or "report"
 
41
  "changedPixels": run.changed_pixels,
42
  "unchangedPixels": run.total_pixels - run.changed_pixels,
43
  "changePercentage": run.change_percentage,
44
+ "detectionSensitivity": run.detection_sensitivity,
45
+ "minRegionArea": run.min_region_area,
46
+ "detectionMaxSide": _detection_max_side(),
47
  },
48
  "regions": regions,
49
  "regionsCount": run.regions_count,
 
93
  Paragraph(f"<b>Location:</b> {location}", body),
94
  Paragraph(f"<b>Change:</b> {run.change_percentage:.2f}% ({run.changed_pixels:,} / {run.total_pixels:,} px)", body),
95
  Paragraph(f"<b>Regions detected:</b> {run.regions_count}", body),
 
96
  ]
97
+ if run.detection_sensitivity is not None or run.min_region_area is not None:
98
+ sens_txt = f"{run.detection_sensitivity:.2f}" if run.detection_sensitivity is not None else "—"
99
+ area_txt = f"{run.min_region_area:,} px" if run.min_region_area is not None else "—"
100
+ story.append(Paragraph(f"<b>Detection settings:</b> Sensitivity {sens_txt} · Min region area {area_txt}", body))
101
+ story.append(Spacer(1, 10))
102
 
103
  overlay_path = run.overlay_path
104
  if overlay_path:
app/dda/reports_routes.py CHANGED
@@ -175,3 +175,47 @@ def notify_report(run_id: int, body: ReportNotifyBody, db: Session = Depends(get
175
  if not sent:
176
  raise HTTPException(status_code=400, detail=error or "Failed to send report email")
177
  return {"ok": True, "message": f"Report link sent to {body.email.strip()}.", "reportUrl": report_url}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  if not sent:
176
  raise HTTPException(status_code=400, detail=error or "Failed to send report email")
177
  return {"ok": True, "message": f"Report link sent to {body.email.strip()}.", "reportUrl": report_url}
178
+
179
+
180
+ class ReportTitleBody(BaseModel):
181
+ title: str
182
+
183
+
184
+ @router.patch("/reports/{run_id}/title")
185
+ def update_report_title(
186
+ run_id: int,
187
+ body: ReportTitleBody,
188
+ db: Session = Depends(get_db),
189
+ user: User = Depends(require_module_permission("reports", "edit")),
190
+ ):
191
+ """Rename a report. The auto-generated 'before.tif vs after.tif' title
192
+ is rarely the name someone wants on a shared report, so this lets it be
193
+ edited after the fact without touching the underlying run data.
194
+
195
+ The Reports list (reports.js) shows a job's own cached title ahead of
196
+ the run's, for any run that still has a matching DetectionJob row — so
197
+ that job's params_json is updated too, or the list would keep showing
198
+ the old name even though the run and the report page are both current."""
199
+ _require_dda()
200
+ run = _get_user_run(db, run_id, user.id)
201
+ title = body.title.strip()
202
+ if not title:
203
+ raise HTTPException(status_code=400, detail="Title cannot be empty.")
204
+ if len(title) > 255:
205
+ raise HTTPException(status_code=400, detail="Title must be 255 characters or fewer.")
206
+ run.title = title
207
+
208
+ import json as _json
209
+
210
+ from .models import DetectionJob
211
+ job = db.query(DetectionJob).filter(DetectionJob.run_id == run.id).first()
212
+ if job:
213
+ try:
214
+ params = _json.loads(job.params_json or "{}")
215
+ except _json.JSONDecodeError:
216
+ params = {}
217
+ params["title"] = title
218
+ job.params_json = _json.dumps(params)
219
+
220
+ db.commit()
221
+ return {"ok": True, "title": run.title}
app/dda/review_service.py CHANGED
@@ -6,6 +6,7 @@ from datetime import datetime, timezone
6
  from typing import Any, Dict, List, Optional
7
 
8
  from sqlalchemy import func
 
9
  from sqlalchemy.orm import Session
10
 
11
  from ..models import DetectionRun
@@ -62,7 +63,24 @@ def get_or_create_review(db: Session, run_id: int, region_id: int) -> RegionRevi
62
  return row
63
  row = RegionReview(run_id=run_id, region_id=region_id, status="pending")
64
  db.add(row)
65
- db.commit()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  db.refresh(row)
67
  return row
68
 
 
6
  from typing import Any, Dict, List, Optional
7
 
8
  from sqlalchemy import func
9
+ from sqlalchemy.exc import IntegrityError
10
  from sqlalchemy.orm import Session
11
 
12
  from ..models import DetectionRun
 
63
  return row
64
  row = RegionReview(run_id=run_id, region_id=region_id, status="pending")
65
  db.add(row)
66
+ try:
67
+ db.commit()
68
+ except IntegrityError:
69
+ # Two overlapping requests for the same region both found no existing
70
+ # row and raced to create one — the (run_id, region_id) unique index
71
+ # lets exactly one insert win; fall back to the row it created
72
+ # instead of duplicating (previously unconstrained, this silently
73
+ # produced duplicate RegionReview rows and inflated classification
74
+ # counts past regions_count).
75
+ db.rollback()
76
+ row = (
77
+ db.query(RegionReview)
78
+ .filter(RegionReview.run_id == run_id, RegionReview.region_id == region_id)
79
+ .first()
80
+ )
81
+ if row:
82
+ return row
83
+ raise
84
  db.refresh(row)
85
  return row
86
 
app/dda/tree/area_groups.py CHANGED
@@ -1,15 +1,29 @@
1
- """Group library images that cover the same place (oldest → Before, newest → After)."""
 
 
 
 
 
 
 
 
 
 
 
2
  from __future__ import annotations
3
 
 
4
  import math
5
  import re
6
  from collections import defaultdict
7
  from datetime import datetime
8
  from pathlib import Path
9
- from typing import Any, Optional
10
 
11
  from ..geo_regions import parse_bounds
12
 
 
 
13
  YEAR_RE = re.compile(r"^(19|20)\d{2}$")
14
  GRID_RE = re.compile(r"grid\s*(?:no\.?\s*)?(\d+)", re.I)
15
  SHEET_RE = re.compile(r"\b([a-z]\d{2}[a-z]\d[a-z]\d+)\b", re.I)
@@ -22,6 +36,19 @@ NOISE_RE = re.compile(
22
  re.I,
23
  )
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
  def _bbox(bounds: Any) -> Optional[tuple[float, float, float, float]]:
27
  parsed = parse_bounds(bounds)
@@ -47,21 +74,35 @@ def _intersection(a: tuple[float, float, float, float], b: tuple[float, float, f
47
  return (west, south, east, north)
48
 
49
 
50
- def bounds_same_area(a: Any, b: Any) -> bool:
51
- """True when two WGS84 boxes cover essentially the same place (not a tiny crop in a huge ORI)."""
 
 
 
 
 
52
  ba, bb = _bbox(a), _bbox(b)
53
  if not ba or not bb:
54
- return False
55
  inter = _intersection(ba, bb)
56
  if not inter:
57
- return False
58
  aa, ab, ai = _area(ba), _area(bb), _area(inter)
59
  if min(aa, ab) <= 0:
 
 
 
 
 
 
 
 
 
 
 
 
60
  return False
61
- iou = ai / max(aa + ab - ai, 1e-12)
62
- overlap_small = ai / min(aa, ab)
63
- ratio = min(aa, ab) / max(aa, ab)
64
- return iou >= 0.25 or (overlap_small >= 0.55 and ratio >= 0.15)
65
 
66
 
67
  def distinctive_keys(filename: str) -> set[str]:
@@ -153,33 +194,20 @@ def _name_rank(filename: str) -> int:
153
 
154
 
155
  def _sort_images(images: list[dict]) -> list[dict]:
 
 
156
  return sorted(
157
  images,
158
  key=lambda img: (
159
- sort_datetime(img),
160
  _name_rank(img.get("filename") or ""),
 
161
  (img.get("filename") or "").lower(),
162
  img.get("id") or 0,
163
  ),
164
  )
165
 
166
 
167
- class _UnionFind:
168
- def __init__(self, n: int):
169
- self.parent = list(range(n))
170
-
171
- def find(self, i: int) -> int:
172
- while self.parent[i] != i:
173
- self.parent[i] = self.parent[self.parent[i]]
174
- i = self.parent[i]
175
- return i
176
-
177
- def union(self, a: int, b: int) -> None:
178
- ra, rb = self.find(a), self.find(b)
179
- if ra != rb:
180
- self.parent[rb] = ra
181
-
182
-
183
  _HARD_KEY_PREFIXES = ("seq:", "grid:", "sheet:")
184
 
185
 
@@ -190,14 +218,51 @@ def _hard_keys(keys: set[str]) -> set[str]:
190
  def distinct_places(keys_a: set[str], keys_b: set[str]) -> bool:
191
  """True when both images are already identified as different sites.
192
 
193
- Bounds overlap is then ignored so a large ORI cannot swallow neighbouring
194
- pairs (union-find is transitive). Images with no hard key can still join
195
- via overlap or a shared folder.
196
  """
197
  ha, hb = _hard_keys(keys_a), _hard_keys(keys_b)
198
  return bool(ha and hb and ha.isdisjoint(hb))
199
 
200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  def _group_label(images: list[dict], keys: set[str]) -> str:
202
  base = ""
203
  for key in sorted(keys):
@@ -235,98 +300,447 @@ def _group_label(images: list[dict], keys: set[str]) -> str:
235
  return base
236
 
237
 
238
- def _match_reason(images: list[dict]) -> str:
239
- boxes = [_bbox(img.get("bounds")) for img in images]
240
- if sum(1 for b in boxes if b) >= 2:
241
- return "bounds"
242
- return "filename"
243
-
244
-
245
- def _spatial_cells(b: tuple[float, float, float, float], cell: float = 0.05):
246
  west, south, east, north = b
247
  x0 = math.floor(west / cell)
248
  x1 = math.floor((east - 1e-12) / cell)
249
  y0 = math.floor(south / cell)
250
  y1 = math.floor((north - 1e-12) / cell)
 
 
 
 
 
 
251
  for x in range(x0, x1 + 1):
252
  for y in range(y0, y1 + 1):
253
  yield (x, y)
254
 
255
 
256
- def _public_image(img: dict) -> dict:
257
- """Drop heavy fields after pairing so the HTTP payload stays small."""
258
- out = dict(img)
259
- out.pop("bounds", None)
260
- return out
 
 
 
 
261
 
262
 
263
- def build_area_groups(images: list[dict]) -> dict:
264
- """Cluster images that cover the same area; suggest oldest/newest as Before/After."""
265
- items = list(images or [])
266
- n = len(items)
267
- uf = _UnionFind(n)
268
- keys = [distinctive_keys(img.get("filename") or img.get("path") or "") for img in items]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
 
270
  by_key: dict[str, list[int]] = defaultdict(list)
271
- for i, ks in enumerate(keys):
272
- for k in ks:
273
- by_key[k].append(i)
274
- for idxs in by_key.values():
275
- root = idxs[0]
276
- for j in idxs[1:]:
277
- uf.union(root, j)
278
-
279
- cell_map: dict[tuple[int, int], list[int]] = defaultdict(list)
280
- for i, img in enumerate(items):
281
- box = _bbox(img.get("bounds"))
282
- if not box:
 
 
283
  continue
284
- for cell in _spatial_cells(box):
285
- cell_map[cell].append(i)
286
-
287
- seen: set[tuple[int, int]] = set()
288
- for idxs in cell_map.values():
289
- uniq = list(dict.fromkeys(idxs))
290
- for a in range(len(uniq)):
291
- for b in range(a + 1, len(uniq)):
292
- i, j = uniq[a], uniq[b]
293
- pair = (i, j) if i < j else (j, i)
294
- if pair in seen:
295
- continue
296
- seen.add(pair)
297
- if uf.find(i) == uf.find(j):
298
- continue
299
- if distinct_places(keys[i], keys[j]):
300
- continue
301
- if bounds_same_area(items[i].get("bounds"), items[j].get("bounds")):
302
- uf.union(i, j)
303
-
304
- buckets: dict[int, list[int]] = {}
305
- for i in range(n):
306
- buckets.setdefault(uf.find(i), []).append(i)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
  groups = []
309
- unpaired = []
310
- for idxs in buckets.values():
311
- members = _sort_images([items[i] for i in idxs])
312
- if len(members) < 2:
313
- unpaired.extend(members)
314
  continue
315
- shared: set[str] = set()
316
- for i in idxs:
317
- if not shared:
318
- shared = set(keys[i])
319
- else:
320
- shared &= keys[i]
321
- before, after = members[0], members[-1]
322
- public_members = [_public_image(m) for m in members]
323
  groups.append({
324
  "id": f"area-{before.get('id')}-{after.get('id')}",
325
- "label": _group_label(members, shared),
326
- "match": _match_reason(members),
327
- "images": public_members,
328
- "suggestedBefore": _public_image(before),
329
- "suggestedAfter": _public_image(after),
 
 
 
330
  "beforePath": before.get("path"),
331
  "afterPath": after.get("path"),
332
  "beforeDate": sort_datetime(before).date().isoformat() if sort_datetime(before) != datetime.min else None,
@@ -334,31 +748,54 @@ def build_area_groups(images: list[dict]) -> dict:
334
  })
335
 
336
  groups.sort(key=lambda g: (g["label"].lower(), g["id"]))
337
- unpaired = [_public_image(img) for img in _sort_images(unpaired)]
 
 
 
338
  return {
339
  "groups": groups,
340
  "unpaired": unpaired,
 
341
  "totalImages": n,
342
- "pairedImages": n - len(unpaired),
343
  }
344
 
345
 
346
- def load_cached_area_groups(db) -> dict:
347
- """Build same-area groups once per library fingerprint, then reuse in-process."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348
  from .image_service import library_fingerprint, list_all_images
349
  from .library_cache import get_area_groups_cache, set_area_groups_cache
350
 
351
  fp = library_fingerprint(db)
352
- cached = get_area_groups_cache(fp)
 
353
  if cached is not None:
354
- return cached
355
- images = list_all_images(db, include_bounds=True, slim=True)
356
- payload = build_area_groups(images)
357
- set_area_groups_cache(fp, payload)
358
- return payload
359
 
360
 
361
  def filter_area_groups_for_node(payload: dict, node) -> dict:
 
362
  prefix = (getattr(node, "node_path", None) or "").rstrip("/")
363
  node_id = getattr(node, "id", None)
364
 
@@ -374,6 +811,7 @@ def filter_area_groups_for_node(payload: dict, node) -> dict:
374
  return {
375
  "groups": groups,
376
  "unpaired": unpaired,
 
377
  "totalImages": payload.get("totalImages", 0),
378
  "pairedImages": sum(len(g.get("images") or []) for g in groups),
379
  }
 
1
+ """Pair library images that cover the same place.
2
+
3
+ Two images are compared on every piece of metadata the library actually holds:
4
+ administrative location (zone / village), folder path, capture date, filename,
5
+ and geographic overlap. Each signal contributes to a score, and a few of them
6
+ act as hard gates that rule a pairing out entirely.
7
+
8
+ Automatic pairing then picks the best-scoring pairs greedily, so a pair always
9
+ holds exactly two images and no image is used by more than one pair. The full
10
+ same-place cluster is still reported as ``candidates`` so an operator can swap
11
+ in a different image by hand.
12
+ """
13
  from __future__ import annotations
14
 
15
+ import logging
16
  import math
17
  import re
18
  from collections import defaultdict
19
  from datetime import datetime
20
  from pathlib import Path
21
+ from typing import Any, NamedTuple, Optional
22
 
23
  from ..geo_regions import parse_bounds
24
 
25
+ logger = logging.getLogger(__name__)
26
+
27
  YEAR_RE = re.compile(r"^(19|20)\d{2}$")
28
  GRID_RE = re.compile(r"grid\s*(?:no\.?\s*)?(\d+)", re.I)
29
  SHEET_RE = re.compile(r"\b([a-z]\d{2}[a-z]\d[a-z]\d+)\b", re.I)
 
36
  re.I,
37
  )
38
 
39
+ # Candidate generation is blocked so a large library stays near-linear: only
40
+ # images that already share a filename key, a village, or a map cell are ever
41
+ # compared. These caps bound the work inside one oversized block.
42
+ _SPATIAL_CELL = 0.02 # ~2 km at Delhi's latitude
43
+ _SPATIAL_SUBCELL = 0.002 # ~200 m, used to split a crowded cell
44
+ _BLOCK_FULL_LIMIT = 64 # compare every combination up to this block size
45
+ _BLOCK_WINDOW = 12 # past it, only compare near-in-time neighbours
46
+ _MAX_CANDIDATES = 500_000
47
+
48
+ # A pair needs both a "same place" signal (see _score_pair) and enough total
49
+ # agreement to be worth proposing.
50
+ _MIN_PAIR_SCORE = 24
51
+
52
 
53
  def _bbox(bounds: Any) -> Optional[tuple[float, float, float, float]]:
54
  parsed = parse_bounds(bounds)
 
74
  return (west, south, east, north)
75
 
76
 
77
+ def overlap_stats(a: Any, b: Any) -> Optional[dict]:
78
+ """IoU plus how much of the smaller box is covered, or None if disjoint.
79
+
80
+ ``small`` and ``ratio`` matter because a wide orthomosaic legitimately
81
+ contains a smaller tile of the same place: IoU alone would call that a
82
+ mismatch.
83
+ """
84
  ba, bb = _bbox(a), _bbox(b)
85
  if not ba or not bb:
86
+ return None
87
  inter = _intersection(ba, bb)
88
  if not inter:
89
+ return None
90
  aa, ab, ai = _area(ba), _area(bb), _area(inter)
91
  if min(aa, ab) <= 0:
92
+ return None
93
+ return {
94
+ "iou": ai / max(aa + ab - ai, 1e-12),
95
+ "small": ai / min(aa, ab),
96
+ "ratio": min(aa, ab) / max(aa, ab),
97
+ }
98
+
99
+
100
+ def bounds_same_area(a: Any, b: Any) -> bool:
101
+ """True when two WGS84 boxes cover essentially the same place (not a tiny crop in a huge ORI)."""
102
+ stats = overlap_stats(a, b)
103
+ if not stats:
104
  return False
105
+ return stats["iou"] >= 0.25 or (stats["small"] >= 0.55 and stats["ratio"] >= 0.15)
 
 
 
106
 
107
 
108
  def distinctive_keys(filename: str) -> set[str]:
 
194
 
195
 
196
  def _sort_images(images: list[dict]) -> list[dict]:
197
+ """Oldest first. Within one day the before/after in the name decides,
198
+ because a same-day upload order says nothing about which came first."""
199
  return sorted(
200
  images,
201
  key=lambda img: (
202
+ sort_datetime(img).date(),
203
  _name_rank(img.get("filename") or ""),
204
+ sort_datetime(img),
205
  (img.get("filename") or "").lower(),
206
  img.get("id") or 0,
207
  ),
208
  )
209
 
210
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  _HARD_KEY_PREFIXES = ("seq:", "grid:", "sheet:")
212
 
213
 
 
218
  def distinct_places(keys_a: set[str], keys_b: set[str]) -> bool:
219
  """True when both images are already identified as different sites.
220
 
221
+ They may still be the same place under two naming schemes (a grid number
222
+ and a survey sheet id), so this only raises the bar: such a pairing needs
223
+ strong geographic proof rather than being ruled out outright.
224
  """
225
  ha, hb = _hard_keys(keys_a), _hard_keys(keys_b)
226
  return bool(ha and hb and ha.isdisjoint(hb))
227
 
228
 
229
+ def conflicting_keys(keys_a: set[str], keys_b: set[str]) -> bool:
230
+ """True when both names use the same vocabulary but disagree within it.
231
+
232
+ ``before3`` vs ``before7`` are two different before/after sets, and
233
+ ``grid 54`` vs ``grid 60`` are two different grids — no amount of map
234
+ overlap makes those one pair. A grid number against a sheet id is not a
235
+ conflict, because neither vocabulary can contradict the other.
236
+ """
237
+ for prefix in _HARD_KEY_PREFIXES:
238
+ a = {k for k in keys_a if k.startswith(prefix)}
239
+ b = {k for k in keys_b if k.startswith(prefix)}
240
+ if a and b and a.isdisjoint(b):
241
+ return True
242
+ return False
243
+
244
+
245
+ def _key_text(key: str) -> str:
246
+ kind, _, value = key.partition(":")
247
+ if kind == "grid":
248
+ return f"grid {value}"
249
+ if kind == "sheet":
250
+ return f"sheet {value.upper()}"
251
+ if kind == "seq":
252
+ return f"before/after set {value}"
253
+ return "filename"
254
+
255
+
256
+ def _gap_text(days: int) -> str:
257
+ if days < 45:
258
+ return f"{days} day{'' if days == 1 else 's'} apart"
259
+ if days < 730:
260
+ months = max(1, round(days / 30.4))
261
+ return f"{months} month{'' if months == 1 else 's'} apart"
262
+ years = round(days / 365.25, 1)
263
+ return f"{years:g} years apart"
264
+
265
+
266
  def _group_label(images: list[dict], keys: set[str]) -> str:
267
  base = ""
268
  for key in sorted(keys):
 
300
  return base
301
 
302
 
303
+ def _spatial_cells(b: tuple[float, float, float, float], cell: float = _SPATIAL_CELL):
304
+ """Every grid cell the box touches, so any two overlapping boxes meet in one."""
 
 
 
 
 
 
305
  west, south, east, north = b
306
  x0 = math.floor(west / cell)
307
  x1 = math.floor((east - 1e-12) / cell)
308
  y0 = math.floor(south / cell)
309
  y1 = math.floor((north - 1e-12) / cell)
310
+ # A city-wide orthomosaic can span thousands of cells; indexing it in all
311
+ # of them would cost far more than the recall it buys.
312
+ if (x1 - x0 + 1) * (y1 - y0 + 1) > 256:
313
+ yield (x0, y0)
314
+ yield (x1, y1)
315
+ return
316
  for x in range(x0, x1 + 1):
317
  for y in range(y0, y1 + 1):
318
  yield (x, y)
319
 
320
 
321
+ def _clip_to_cell(b: tuple[float, float, float, float], cell: tuple[int, int], size: float):
322
+ """Box trimmed to one coarse cell, so a re-hash inside it stays bounded."""
323
+ west = max(b[0], cell[0] * size)
324
+ south = max(b[1], cell[1] * size)
325
+ east = min(b[2], (cell[0] + 1) * size)
326
+ north = min(b[3], (cell[1] + 1) * size)
327
+ if east <= west or north <= south:
328
+ return None
329
+ return (west, south, east, north)
330
 
331
 
332
+ def _public_image(img: dict, item: Optional["_Item"] = None) -> dict:
333
+ """Keep only fields the pairs UI needs (id/path/dates/location)."""
334
+ zone_id = item.zone if item is not None else img.get("zoneId")
335
+ village_id = item.village if item is not None else img.get("villageId")
336
+ return {
337
+ "id": img.get("id"),
338
+ "path": img.get("path"),
339
+ "filename": img.get("filename") or img.get("imageName"),
340
+ "imageName": img.get("imageName") or img.get("filename"),
341
+ "captureDate": img.get("captureDate"),
342
+ "nodeId": img.get("nodeId"),
343
+ "nodePath": img.get("nodePath") or "",
344
+ "zoneId": zone_id,
345
+ "villageId": village_id,
346
+ "zoneName": (item.zone_name if item is not None else None) or img.get("zoneName") or "",
347
+ "villageName": (item.village_name if item is not None else None) or img.get("villageName") or "",
348
+ }
349
+
350
+
351
+ class ResolvedLocation(NamedTuple):
352
+ zone_id: Optional[int]
353
+ village_id: Optional[int]
354
+ zone_name: str
355
+ village_name: str
356
+ # True only when a person chose this on upload. A location read off the
357
+ # folder tree is a hint, so it may suggest a pairing but never veto one.
358
+ explicit: bool
359
+
360
+
361
+ # Folder names that stand for "no location yet" — matching them against the
362
+ # masters would invent a location the image does not really have.
363
+ _PLACEHOLDER_LOCATIONS = frozenset({
364
+ "unassigned", "_unassigned", "unknown", "uncategorized",
365
+ "legacy", "misc", "other", "root", "images", "temp", "tmp",
366
+ })
367
+
368
+
369
+ class LocationIndex:
370
+ """Resolves an image's zone / village, falling back to its folder names.
371
+
372
+ Uploads carry an explicit choice, but the folder tree has always spelled
373
+ the same thing out ("Central Delhi/Daryaganj"), so images indexed from
374
+ disk still get a location to compare on.
375
+ """
376
+
377
+ def __init__(self, zones=(), villages=()):
378
+ self.zone_by_name: dict[str, int] = {}
379
+ self.zone_names: dict[int, str] = {}
380
+ for zone_id, name in zones:
381
+ clean = (name or "").strip()
382
+ if clean:
383
+ self.zone_by_name[clean.lower()] = zone_id
384
+ self.zone_names[zone_id] = clean
385
+ # One village name can exist in several zones, so keep every option.
386
+ self.villages_by_name: dict[str, list] = defaultdict(list)
387
+ self.village_names: dict[int, str] = {}
388
+ for village_id, name, zone_id in villages:
389
+ clean = (name or "").strip()
390
+ if clean:
391
+ self.villages_by_name[clean.lower()].append((zone_id, village_id))
392
+ self.village_names[village_id] = clean
393
+
394
+ def __bool__(self) -> bool:
395
+ return bool(self.zone_by_name or self.villages_by_name)
396
+
397
+ def resolve(self, img: dict) -> ResolvedLocation:
398
+ zone_id = img.get("zoneId") or None
399
+ village_id = img.get("villageId") or None
400
+ explicit = bool(zone_id or village_id)
401
+ if not (zone_id and village_id) and self:
402
+ segments = [
403
+ s.strip().lower()
404
+ for s in str(img.get("nodePath") or "").replace("\\", "/").split("/")
405
+ if s.strip() and s.strip().lower() not in _PLACEHOLDER_LOCATIONS
406
+ ]
407
+ if not zone_id:
408
+ for segment in segments:
409
+ if segment in self.zone_by_name:
410
+ zone_id = self.zone_by_name[segment]
411
+ break
412
+ if not village_id:
413
+ for segment in segments:
414
+ options = self.villages_by_name.get(segment) or []
415
+ if zone_id:
416
+ options = [o for o in options if o[0] == zone_id] or options
417
+ # An ambiguous name is left unresolved rather than guessed.
418
+ if len(options) == 1:
419
+ zone_id = zone_id or options[0][0]
420
+ village_id = options[0][1]
421
+ break
422
+ return ResolvedLocation(
423
+ zone_id,
424
+ village_id,
425
+ img.get("zoneName") or self.zone_names.get(zone_id, ""),
426
+ img.get("villageName") or self.village_names.get(village_id, ""),
427
+ explicit,
428
+ )
429
+
430
+
431
+ def build_location_index(db) -> LocationIndex:
432
+ from ..models import DdaVillage, DdaZone
433
+
434
+ return LocationIndex(
435
+ db.query(DdaZone.id, DdaZone.name).all(),
436
+ db.query(DdaVillage.id, DdaVillage.name, DdaVillage.zone_id).all(),
437
+ )
438
+
439
+
440
+ class _Item:
441
+ """Everything the scorer needs about one image, computed once."""
442
+
443
+ __slots__ = (
444
+ "idx", "img", "path", "filename", "keys", "hard", "bbox", "when",
445
+ "zone", "village", "zone_name", "village_name", "pinned_location",
446
+ "node_id", "folder", "rank",
447
+ )
448
+
449
+ def __init__(self, idx: int, img: dict, locations: Optional[LocationIndex] = None):
450
+ self.idx = idx
451
+ self.img = img
452
+ self.path = (img.get("path") or "").replace("\\", "/")
453
+ self.filename = img.get("filename") or img.get("imageName") or self.path
454
+ self.keys = distinctive_keys(self.filename or self.path)
455
+ self.hard = _hard_keys(self.keys)
456
+ self.bbox = _bbox(img.get("bounds"))
457
+ self.when = infer_capture_datetime(img)
458
+ place = (locations or LocationIndex()).resolve(img)
459
+ self.zone = place.zone_id
460
+ self.village = place.village_id
461
+ self.zone_name = place.zone_name
462
+ self.village_name = place.village_name
463
+ self.pinned_location = place.explicit
464
+ self.node_id = img.get("nodeId") or None
465
+ folder = (img.get("nodePath") or "").strip("/")
466
+ if not folder:
467
+ folder = self.path.rsplit("/", 1)[0] if "/" in self.path else ""
468
+ self.folder = folder.lower()
469
+ self.rank = _name_rank(self.filename)
470
+
471
+
472
+ def _folder_score(a: _Item, b: _Item) -> tuple[float, str]:
473
+ if a.node_id and b.node_id and a.node_id == b.node_id:
474
+ return 10.0, "same folder"
475
+ if a.folder and a.folder == b.folder:
476
+ return 10.0, "same folder"
477
+ if not a.folder or not b.folder:
478
+ return 0.0, ""
479
+ seg_a, seg_b = a.folder.split("/"), b.folder.split("/")
480
+ shared = 0
481
+ for x, y in zip(seg_a, seg_b):
482
+ if x != y:
483
+ break
484
+ shared += 1
485
+ if shared:
486
+ return min(6.0, 2.0 * shared), "same folder branch"
487
+ return 0.0, ""
488
+
489
+
490
+ def _date_score(a: _Item, b: _Item) -> tuple[float, str]:
491
+ if not a.when or not b.when:
492
+ return 0.0, ""
493
+ gap = abs((a.when - b.when).days)
494
+ if gap == 0:
495
+ # Change detection needs two moments in time; a same-day pairing only
496
+ # survives when the filenames explicitly say before/after.
497
+ return -4.0, "same capture date"
498
+ if gap >= 180:
499
+ return 14.0, _gap_text(gap)
500
+ if gap >= 30:
501
+ return 12.0, _gap_text(gap)
502
+ return 8.0, _gap_text(gap)
503
+
504
+
505
+ def _score_pair(a: _Item, b: _Item) -> Optional[dict]:
506
+ """Score one candidate pairing, or None when the metadata rules it out."""
507
+ if a.path and a.path == b.path:
508
+ return None
509
+ if conflicting_keys(a.hard, b.hard):
510
+ return None
511
+ # Only a location someone actually picked can veto a pairing; one read off
512
+ # the folder tree just describes how the files happen to be filed.
513
+ if a.pinned_location and b.pinned_location:
514
+ if a.zone and b.zone and a.zone != b.zone:
515
+ return None
516
+ if a.village and b.village and a.village != b.village:
517
+ return None
518
+
519
+ stats = overlap_stats(a.bbox, b.bbox) if (a.bbox and b.bbox) else None
520
+ if a.bbox and b.bbox and stats is None:
521
+ # Both are georeferenced and their footprints do not touch.
522
+ return None
523
+ strong_geo = bool(stats) and (stats["small"] >= 0.85 and stats["ratio"] >= 0.30)
524
+ if distinct_places(a.hard, b.hard) and not strong_geo:
525
+ return None
526
+
527
+ score = 0.0
528
+ reasons: list[str] = []
529
+ match = "filename"
530
+
531
+ geo_ok = False
532
+ if stats:
533
+ geo_ok = stats["iou"] >= 0.25 or (stats["small"] >= 0.55 and stats["ratio"] >= 0.15)
534
+ score += 40.0 * min(1.0, stats["iou"])
535
+ if stats["small"] >= 0.8:
536
+ score += 10.0
537
+ if geo_ok:
538
+ match = "bounds"
539
+ reasons.append(f"{round(stats['iou'] * 100)}% map overlap")
540
+
541
+ shared_hard = a.hard & b.hard
542
+ shared_soft = (a.keys & b.keys) - shared_hard
543
+ if shared_hard:
544
+ score += 26.0
545
+ reasons.append(f"matching {_key_text(sorted(shared_hard)[0])}")
546
+ elif shared_soft:
547
+ score += 16.0
548
+ reasons.append("matching filename")
549
+
550
+ same_village = bool(a.village) and a.village == b.village
551
+ if same_village:
552
+ score += 20.0
553
+ reasons.append(f"same location ({a.village_name})" if a.village_name else "same location")
554
+ if match == "filename" and not shared_hard and not shared_soft:
555
+ match = "location"
556
+ elif a.zone and a.zone == b.zone:
557
+ score += 12.0
558
+ reasons.append(f"same zone ({a.zone_name})" if a.zone_name else "same zone")
559
+
560
+ folder_points, folder_text = _folder_score(a, b)
561
+ score += folder_points
562
+ if folder_text:
563
+ reasons.append(folder_text)
564
+
565
+ date_points, date_text = _date_score(a, b)
566
+ score += date_points
567
+ if date_text:
568
+ reasons.append(date_text)
569
+
570
+ if {a.rank, b.rank} == {0, 2}:
571
+ score += 12.0
572
+ reasons.append("named before / after")
573
+
574
+ # Folder, zone and capture date only say "could be related". When both
575
+ # footprints are known the map is authoritative — a shared name key can
576
+ # still vouch for a partial overlap, but a folder or village cannot.
577
+ if a.bbox and b.bbox:
578
+ same_place = geo_ok or bool(shared_hard)
579
+ else:
580
+ same_place = geo_ok or bool(shared_hard) or bool(shared_soft) or same_village
581
+ if not same_place:
582
+ return None
583
+ if score < _MIN_PAIR_SCORE:
584
+ return None
585
+ return {"score": score, "reasons": reasons, "match": match}
586
+
587
+
588
+ def _emit_block(members: list[int], items: list[_Item], add) -> None:
589
+ members = list(dict.fromkeys(members))
590
+ if len(members) < 2:
591
+ return
592
+ if len(members) <= _BLOCK_FULL_LIMIT:
593
+ for a in range(len(members)):
594
+ for b in range(a + 1, len(members)):
595
+ add(members[a], members[b])
596
+ return
597
+ # Too crowded to compare exhaustively: order by capture time and only look
598
+ # at each image's nearest neighbours, since a pair needs two dates anyway.
599
+ members.sort(key=lambda i: (items[i].when or datetime.min, items[i].path))
600
+ for a in range(len(members)):
601
+ for b in range(a + 1, min(len(members), a + _BLOCK_WINDOW)):
602
+ add(members[a], members[b])
603
+
604
+
605
+ def _candidate_pairs(items: list[_Item]) -> set[tuple[int, int]]:
606
+ """Index pairings worth scoring: shared filename key, map cell, or village.
607
+
608
+ Folder and zone are deliberately not blocking keys. Neither can establish
609
+ on its own that two images show the same place, so blocking on them would
610
+ only add candidates the scorer then throws away.
611
+ """
612
+ pairs: set[tuple[int, int]] = set()
613
+ truncated = False
614
+
615
+ def add(i: int, j: int) -> None:
616
+ nonlocal truncated
617
+ if len(pairs) >= _MAX_CANDIDATES:
618
+ truncated = True
619
+ return
620
+ pairs.add((i, j) if i < j else (j, i))
621
 
622
  by_key: dict[str, list[int]] = defaultdict(list)
623
+ for item in items:
624
+ for key in item.keys:
625
+ by_key[key].append(item.idx)
626
+ for members in by_key.values():
627
+ _emit_block(members, items, add)
628
+
629
+ by_cell: dict[tuple[int, int], list[int]] = defaultdict(list)
630
+ for item in items:
631
+ if item.bbox:
632
+ for cell in _spatial_cells(item.bbox):
633
+ by_cell[cell].append(item.idx)
634
+ for cell, members in by_cell.items():
635
+ if len(members) <= _BLOCK_FULL_LIMIT:
636
+ _emit_block(members, items, add)
637
  continue
638
+ # Dense city imagery keeps its geographic precision by re-hashing the
639
+ # crowded cell at a finer resolution instead of giving up on bounds.
640
+ sub: dict[tuple[int, int], list[int]] = defaultdict(list)
641
+ for idx in members:
642
+ clipped = _clip_to_cell(items[idx].bbox, cell, _SPATIAL_CELL)
643
+ if not clipped:
644
+ continue
645
+ for fine in _spatial_cells(clipped, _SPATIAL_SUBCELL):
646
+ sub[fine].append(idx)
647
+ for group in sub.values():
648
+ _emit_block(group, items, add)
649
+
650
+ # Village is the only bridge for an image with no map extent; images that
651
+ # do have one are already indexed by cell, so skip those combinations.
652
+ by_village: dict[Any, list[int]] = defaultdict(list)
653
+ for item in items:
654
+ if item.village:
655
+ by_village[item.village].append(item.idx)
656
+ for members in by_village.values():
657
+ floating = [i for i in members if not items[i].bbox]
658
+ if not floating:
659
+ continue
660
+ _emit_block(floating, items, add)
661
+ located = [i for i in members if items[i].bbox]
662
+ if len(located) > _BLOCK_WINDOW:
663
+ located.sort(key=lambda i: (items[i].when or datetime.min, items[i].path))
664
+ located = located[:_BLOCK_WINDOW]
665
+ for i in floating:
666
+ for j in located:
667
+ add(i, j)
668
+
669
+ if truncated:
670
+ logger.warning(
671
+ "Same-area pairing hit the %d candidate cap; some weak pairings were skipped.",
672
+ _MAX_CANDIDATES,
673
+ )
674
+ return pairs
675
+
676
+
677
+ def build_area_groups(
678
+ images: list[dict],
679
+ locations: Optional[LocationIndex] = None,
680
+ ) -> dict:
681
+ """Pair images that cover the same place: two per pair, never reused."""
682
+ items = [_Item(i, img, locations) for i, img in enumerate(images or [])]
683
+ n = len(items)
684
 
685
+ candidates = _candidate_pairs(items)
686
+ scored = []
687
+ for i, j in candidates:
688
+ result = _score_pair(items[i], items[j])
689
+ if result:
690
+ scored.append((result["score"], i, j, result))
691
+
692
+ # Every image that shares a viable pairing with another belongs to the same
693
+ # cluster; that is what the UI offers as manual alternatives.
694
+ cluster_of: dict[int, int] = {}
695
+ clusters: dict[int, list[int]] = {}
696
+ for _, i, j, _r in scored:
697
+ ci, cj = cluster_of.get(i), cluster_of.get(j)
698
+ if ci is None and cj is None:
699
+ root = min(i, j)
700
+ clusters[root] = [i, j]
701
+ cluster_of[i] = cluster_of[j] = root
702
+ elif ci is None:
703
+ clusters[cj].append(i)
704
+ cluster_of[i] = cj
705
+ elif cj is None:
706
+ clusters[ci].append(j)
707
+ cluster_of[j] = ci
708
+ elif ci != cj:
709
+ clusters[ci].extend(clusters.pop(cj))
710
+ for idx in clusters[ci]:
711
+ cluster_of[idx] = ci
712
+
713
+ # Greedy best-first matching: strongest evidence wins, and an image that is
714
+ # already in a pair is skipped, so no pair repeats an image.
715
+ scored.sort(key=lambda row: (-row[0], items[row[1]].path, items[row[2]].path))
716
+ item_of = {id(it.img): it for it in items}
717
+
718
+ def public(img: dict) -> dict:
719
+ return _public_image(img, item_of.get(id(img)))
720
+
721
+ used: set[int] = set()
722
  groups = []
723
+ for score, i, j, result in scored:
724
+ if i in used or j in used or i == j:
725
+ continue
726
+ if items[i].path and items[i].path == items[j].path:
 
727
  continue
728
+ used.add(i)
729
+ used.add(j)
730
+ before, after = _sort_images([items[i].img, items[j].img])
731
+ shared = items[i].keys & items[j].keys
732
+ cluster = clusters.get(cluster_of.get(i, i), [i, j])
733
+ candidate_imgs = _sort_images([items[k].img for k in dict.fromkeys(cluster)])
 
 
734
  groups.append({
735
  "id": f"area-{before.get('id')}-{after.get('id')}",
736
+ "label": _group_label([before, after], shared),
737
+ "match": result["match"],
738
+ "reasons": result["reasons"],
739
+ "score": round(score, 1),
740
+ "images": [public(before), public(after)],
741
+ "candidates": [public(img) for img in candidate_imgs],
742
+ "suggestedBefore": public(before),
743
+ "suggestedAfter": public(after),
744
  "beforePath": before.get("path"),
745
  "afterPath": after.get("path"),
746
  "beforeDate": sort_datetime(before).date().isoformat() if sort_datetime(before) != datetime.min else None,
 
748
  })
749
 
750
  groups.sort(key=lambda g: (g["label"].lower(), g["id"]))
751
+ unpaired = [
752
+ public(img)
753
+ for img in _sort_images([it.img for it in items if it.idx not in used])
754
+ ]
755
  return {
756
  "groups": groups,
757
  "unpaired": unpaired,
758
+ "unpairedCount": len(unpaired),
759
  "totalImages": n,
760
+ "pairedImages": len(used),
761
  }
762
 
763
 
764
+ def _area_groups_response(payload: dict, *, include_unpaired: bool) -> dict:
765
+ unpaired = payload.get("unpaired") or []
766
+ out = {
767
+ "groups": payload.get("groups") or [],
768
+ "unpairedCount": int(payload.get("unpairedCount", len(unpaired))),
769
+ "totalImages": payload.get("totalImages", 0),
770
+ "pairedImages": payload.get("pairedImages", 0),
771
+ "unpaired": unpaired if include_unpaired else [],
772
+ }
773
+ return out
774
+
775
+
776
+ def load_cached_area_groups(
777
+ db,
778
+ node_id: Optional[int] = None,
779
+ *,
780
+ include_unpaired: bool = False,
781
+ ) -> dict:
782
+ """Build same-area groups for a folder (or whole library), cached by fingerprint+node."""
783
  from .image_service import library_fingerprint, list_all_images
784
  from .library_cache import get_area_groups_cache, set_area_groups_cache
785
 
786
  fp = library_fingerprint(db)
787
+ cache_node = int(node_id or 0)
788
+ cached = get_area_groups_cache(fp, cache_node)
789
  if cached is not None:
790
+ return _area_groups_response(cached, include_unpaired=include_unpaired)
791
+ images = list_all_images(db, node_id=node_id, include_bounds=True, slim=True)
792
+ payload = build_area_groups(images, build_location_index(db))
793
+ set_area_groups_cache(fp, payload, cache_node)
794
+ return _area_groups_response(payload, include_unpaired=include_unpaired)
795
 
796
 
797
  def filter_area_groups_for_node(payload: dict, node) -> dict:
798
+ """Legacy post-filter; prefer scoping via load_cached_area_groups(node_id=...)."""
799
  prefix = (getattr(node, "node_path", None) or "").rstrip("/")
800
  node_id = getattr(node, "id", None)
801
 
 
811
  return {
812
  "groups": groups,
813
  "unpaired": unpaired,
814
+ "unpairedCount": len(unpaired),
815
  "totalImages": payload.get("totalImages", 0),
816
  "pairedImages": sum(len(g.get("images") or []) for g in groups),
817
  }
app/dda/tree/audit_service.py CHANGED
@@ -54,6 +54,7 @@ def log_exception(
54
  status_code: int = 500,
55
  handled: bool = False,
56
  user_email: Optional[str] = None,
 
57
  ) -> None:
58
  entry = ExceptionLog(
59
  exception_type=type(exc).__name__,
@@ -64,6 +65,7 @@ def log_exception(
64
  handled=handled,
65
  traceback="".join(traceback_module.format_exception(type(exc), exc, exc.__traceback__))[:8000],
66
  user_email=user_email,
 
67
  )
68
  db.add(entry)
69
  db.commit()
 
54
  status_code: int = 500,
55
  handled: bool = False,
56
  user_email: Optional[str] = None,
57
+ user_role: Optional[str] = None,
58
  ) -> None:
59
  entry = ExceptionLog(
60
  exception_type=type(exc).__name__,
 
65
  handled=handled,
66
  traceback="".join(traceback_module.format_exception(type(exc), exc, exc.__traceback__))[:8000],
67
  user_email=user_email,
68
+ user_role=user_role,
69
  )
70
  db.add(entry)
71
  db.commit()
app/dda/tree/bulk_upload_service.py ADDED
@@ -0,0 +1,414 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bulk image upload: an Excel manifest + a batch of files, routed through the
2
+ same upload_image() pipeline used by the single-image upload (validation,
3
+ dedup, thumbnailing, geo-metadata) — see image_service.py."""
4
+ from __future__ import annotations
5
+
6
+ import io
7
+ import logging
8
+ import re
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Dict, List, Optional
12
+
13
+ from fastapi import HTTPException, UploadFile
14
+ from openpyxl import Workbook, load_workbook
15
+ from openpyxl.worksheet.datavalidation import DataValidation
16
+ from sqlalchemy.orm import Session
17
+
18
+ from ..config import get_storage_root
19
+ from ..models import DdaVillage, DdaZone
20
+ from .image_service import IMAGE_SOURCES, IMAGE_TYPES, upload_image
21
+ from .models import ImageLibrary
22
+ from .tree_service import get_or_create_node_by_path
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # General Folder replaces Folder Path (still accepted when parsing old sheets).
27
+ TEMPLATE_HEADERS = [
28
+ "File Name",
29
+ "Server Filename",
30
+ "General Folder",
31
+ "Zone",
32
+ "Village",
33
+ "Image Type",
34
+ "Year",
35
+ "Source",
36
+ "Capture Date",
37
+ "Manual Bounds",
38
+ ]
39
+ TEMPLATE_EXAMPLE_ROW = [
40
+ "example_before.tif",
41
+ "central_delhi_2025_before.tif",
42
+ "Central Delhi/Sector 5/2025",
43
+ "Central Delhi",
44
+ "Sector 5",
45
+ "GeoTIFF",
46
+ "2025",
47
+ "satellite",
48
+ "2025-01-15",
49
+ "",
50
+ ]
51
+ _REQUIRED_COLUMNS = ("File Name", "General Folder", "Zone", "Village", "Image Type")
52
+ _SOURCE_CHOICES = sorted(s for s in IMAGE_SOURCES if s)
53
+
54
+
55
+ def _basename(value: str) -> str:
56
+ """Strip quotes and take the last path segment so Explorer 'Copy as path'
57
+ pastes still match browser upload basenames."""
58
+ v = (value or "").strip()
59
+ if len(v) >= 2 and v[0] == v[-1] and v[0] in ('"', "'"):
60
+ v = v[1:-1].strip()
61
+ return Path(v.replace("\\", "/")).name
62
+
63
+
64
+ def _normalize_general_folder(value: str) -> str:
65
+ """Relative slash-separated path under Main folder. Reject absolute paths."""
66
+ v = (value or "").strip()
67
+ if len(v) >= 2 and v[0] == v[-1] and v[0] in ('"', "'"):
68
+ v = v[1:-1].strip()
69
+ v = v.replace("\\", "/").strip()
70
+ if not v:
71
+ raise ValueError("General Folder is required")
72
+ if re.match(r"^[A-Za-z]:[/\\]", v) or v.startswith("//") or v.startswith("/"):
73
+ raise ValueError(
74
+ "General Folder must be relative to the server Main folder "
75
+ "(e.g. Central Delhi/Sector 5/2025), not an absolute path"
76
+ )
77
+ parts = [p.strip() for p in v.split("/") if p.strip() and p.strip() != "."]
78
+ if any(p == ".." for p in parts):
79
+ raise ValueError("General Folder cannot contain '..'")
80
+ if not parts:
81
+ raise ValueError("General Folder is required")
82
+ return "/".join(parts)
83
+
84
+
85
+ def build_bulk_upload_template() -> bytes:
86
+ """Generate the standardized .xlsx template for bulk image upload."""
87
+ main = str(get_storage_root())
88
+ wb = Workbook()
89
+ ws = wb.active
90
+ ws.title = "Images"
91
+ ws.append(TEMPLATE_HEADERS)
92
+ ws.append(TEMPLATE_EXAMPLE_ROW)
93
+ widths = (28, 32, 40, 16, 16, 14, 10, 12, 14, 22)
94
+ for col_idx, width in enumerate(widths, start=1):
95
+ ws.column_dimensions[ws.cell(row=1, column=col_idx).column_letter].width = width
96
+
97
+ dv_type = DataValidation(
98
+ type="list", formula1='"' + ",".join(sorted(IMAGE_TYPES)) + '"', allow_blank=False
99
+ )
100
+ ws.add_data_validation(dv_type)
101
+ dv_type.add("F2:F1000")
102
+
103
+ if _SOURCE_CHOICES:
104
+ dv_src = DataValidation(
105
+ type="list", formula1='"' + ",".join(_SOURCE_CHOICES) + '"', allow_blank=True
106
+ )
107
+ ws.add_data_validation(dv_src)
108
+ dv_src.add("H2:H1000")
109
+
110
+ instructions = wb.create_sheet("Instructions")
111
+ instructions.append(["Column", "Required", "Notes"])
112
+ instructions.append([
113
+ "Main folder (this server)",
114
+ "—",
115
+ f"Images are stored under: {main}. Set STORAGE_ROOT or LOCAL_LIBRARY_ROOT on each environment so relative General Folder paths resolve correctly.",
116
+ ])
117
+ instructions.append([
118
+ "File Name",
119
+ "Yes",
120
+ "Must match one of the files you upload with this sheet (basename). Pasting a full Explorer path is fine — only the filename is used for matching.",
121
+ ])
122
+ instructions.append([
123
+ "Server Filename",
124
+ "No",
125
+ "Name stored on the server. Blank = File Name basename.",
126
+ ])
127
+ instructions.append([
128
+ "General Folder",
129
+ "Yes",
130
+ "Relative path under Main folder / library tree, e.g. Central Delhi/Sector 5/2025. Absolute paths (C:\\… or /…) are rejected. Missing folders are created automatically.",
131
+ ])
132
+ instructions.append(["Zone", "Yes", "Must match an existing Zone name exactly (see Zone / District master)."])
133
+ instructions.append(["Village", "Yes", "Must match an existing Village name within that Zone."])
134
+ instructions.append(["Image Type", "Yes", "One of: " + ", ".join(sorted(IMAGE_TYPES))])
135
+ instructions.append(["Year", "No", "Four-digit year (1900–2100). Blank may be inferred from a year segment in General Folder."])
136
+ instructions.append([
137
+ "Source",
138
+ "No",
139
+ "One of: " + (", ".join(_SOURCE_CHOICES) if _SOURCE_CHOICES else "satellite or drone"),
140
+ ])
141
+ instructions.append(["Capture Date", "No", "YYYY-MM-DD"])
142
+ instructions.append(["Manual Bounds", "No", "West,South,East,North — only if the image has no embedded georeferencing."])
143
+ instructions.column_dimensions["A"].width = 28
144
+ instructions.column_dimensions["B"].width = 10
145
+ instructions.column_dimensions["C"].width = 100
146
+
147
+ buf = io.BytesIO()
148
+ wb.save(buf)
149
+ return buf.getvalue()
150
+
151
+
152
+ @dataclass
153
+ class ManifestRow:
154
+ row_number: int
155
+ file_name: str
156
+ folder_path: str
157
+ zone: str
158
+ village: str
159
+ image_type: str
160
+ capture_date: Optional[str]
161
+ manual_bounds: Optional[str]
162
+ server_filename: Optional[str] = None
163
+ year: Optional[str] = None
164
+ source: Optional[str] = None
165
+
166
+
167
+ @dataclass
168
+ class BulkDefaults:
169
+ """Optional modal defaults applied only where Excel cells are blank."""
170
+ year: Optional[str] = None
171
+ source: Optional[str] = None
172
+ image_type: Optional[str] = None
173
+ zone: Optional[str] = None
174
+ village: Optional[str] = None
175
+ filenames: Dict[str, str] = field(default_factory=dict)
176
+
177
+
178
+ def parse_bulk_manifest(xlsx_bytes: bytes) -> tuple[List[ManifestRow], List[dict]]:
179
+ """Parse the uploaded manifest. Returns (valid_rows, row_errors)."""
180
+ try:
181
+ wb = load_workbook(io.BytesIO(xlsx_bytes), data_only=True, read_only=True)
182
+ except Exception:
183
+ raise HTTPException(status_code=400, detail="Could not read the Excel file. Please use the provided template.")
184
+ ws = wb["Images"] if "Images" in wb.sheetnames else wb.worksheets[0]
185
+
186
+ rows_iter = ws.iter_rows(values_only=True)
187
+ try:
188
+ header = [str(c or "").strip() for c in next(rows_iter)]
189
+ except StopIteration:
190
+ raise HTTPException(status_code=400, detail="The Excel sheet is empty.")
191
+
192
+ idx: Dict[str, Optional[int]] = {}
193
+ for name in TEMPLATE_HEADERS:
194
+ idx[name] = header.index(name) if name in header else None
195
+ if idx.get("General Folder") is None and "Folder Path" in header:
196
+ idx["General Folder"] = header.index("Folder Path")
197
+
198
+ missing = [name for name in _REQUIRED_COLUMNS if idx.get(name) is None]
199
+ if missing:
200
+ raise HTTPException(
201
+ status_code=400,
202
+ detail=f"Missing required column(s): {', '.join(missing)}. Please use the provided template.",
203
+ )
204
+
205
+ valid: List[ManifestRow] = []
206
+ errors: List[dict] = []
207
+ for row_number, raw in enumerate(rows_iter, start=2):
208
+ if raw is None or all(c is None or str(c).strip() == "" for c in raw):
209
+ continue
210
+
211
+ def cell(name: str, _raw=raw) -> str:
212
+ i = idx.get(name)
213
+ if i is None or i >= len(_raw) or _raw[i] is None:
214
+ return ""
215
+ return str(_raw[i]).strip()
216
+
217
+ file_name = _basename(cell("File Name"))
218
+ server_filename = cell("Server Filename") or None
219
+ if server_filename:
220
+ server_filename = _basename(server_filename)
221
+ zone = cell("Zone")
222
+ village = cell("Village")
223
+ image_type = cell("Image Type") or ""
224
+ year = cell("Year") or None
225
+ if year and year.endswith(".0"):
226
+ year = year[:-2]
227
+ source = (cell("Source") or "").strip().lower() or None
228
+ capture_date = cell("Capture Date") or None
229
+ manual_bounds = cell("Manual Bounds") or None
230
+
231
+ try:
232
+ folder_path = _normalize_general_folder(cell("General Folder"))
233
+ except ValueError as exc:
234
+ errors.append({"row": row_number, "file": file_name, "reason": str(exc)})
235
+ continue
236
+
237
+ if not file_name:
238
+ errors.append({"row": row_number, "file": file_name, "reason": "File Name is required"})
239
+ continue
240
+ # Zone/Village/Image Type may be filled from modal defaults later.
241
+ if image_type and image_type not in IMAGE_TYPES:
242
+ errors.append({
243
+ "row": row_number,
244
+ "file": file_name,
245
+ "reason": f"Image Type must be one of: {', '.join(sorted(IMAGE_TYPES))}",
246
+ })
247
+ continue
248
+ if source and source not in IMAGE_SOURCES:
249
+ errors.append({
250
+ "row": row_number,
251
+ "file": file_name,
252
+ "reason": "Source must be satellite or drone",
253
+ })
254
+ continue
255
+ valid.append(ManifestRow(
256
+ row_number=row_number,
257
+ file_name=file_name,
258
+ folder_path=folder_path,
259
+ zone=zone,
260
+ village=village,
261
+ image_type=image_type,
262
+ capture_date=capture_date,
263
+ manual_bounds=manual_bounds,
264
+ server_filename=server_filename,
265
+ year=year,
266
+ source=source,
267
+ ))
268
+ return valid, errors
269
+
270
+
271
+ def _resolve_zone_village_by_name(db: Session, zone_name: str, village_name: str) -> tuple[int, int]:
272
+ zone = db.query(DdaZone).filter(DdaZone.name.ilike(zone_name.strip())).first()
273
+ if not zone:
274
+ raise HTTPException(status_code=400, detail=f"Zone '{zone_name}' not found. Check the Zone / District master.")
275
+ village = (
276
+ db.query(DdaVillage)
277
+ .filter(DdaVillage.zone_id == zone.id, DdaVillage.name.ilike(village_name.strip()))
278
+ .first()
279
+ )
280
+ if not village:
281
+ raise HTTPException(
282
+ status_code=400,
283
+ detail=f"Village '{village_name}' not found under zone '{zone_name}'. Check the Village / Location master.",
284
+ )
285
+ return zone.id, village.id
286
+
287
+
288
+ def zone_village_names(db: Session, zone_id: Optional[int], village_id: Optional[int]) -> tuple[str, str]:
289
+ zone_name, village_name = "", ""
290
+ if zone_id:
291
+ z = db.query(DdaZone).filter(DdaZone.id == zone_id).first()
292
+ zone_name = z.name if z else ""
293
+ if village_id:
294
+ v = db.query(DdaVillage).filter(DdaVillage.id == village_id).first()
295
+ village_name = v.name if v else ""
296
+ return zone_name, village_name
297
+
298
+
299
+ def apply_bulk_defaults(rows: List[ManifestRow], defaults: BulkDefaults) -> None:
300
+ """Fill blank Excel fields from modal defaults (in place)."""
301
+ for row in rows:
302
+ key = row.file_name.strip().lower()
303
+ if not row.server_filename and key in defaults.filenames:
304
+ row.server_filename = defaults.filenames[key]
305
+ if not row.year and defaults.year:
306
+ row.year = defaults.year
307
+ if not row.source and defaults.source:
308
+ row.source = defaults.source
309
+ if not row.image_type and defaults.image_type:
310
+ row.image_type = defaults.image_type
311
+ if not row.zone and defaults.zone:
312
+ row.zone = defaults.zone
313
+ if not row.village and defaults.village:
314
+ row.village = defaults.village
315
+ if not row.image_type:
316
+ row.image_type = "GeoTIFF"
317
+
318
+
319
+ @dataclass
320
+ class BulkUploadResult:
321
+ created: List[dict] = field(default_factory=list)
322
+ duplicates: List[dict] = field(default_factory=list)
323
+ errors: List[dict] = field(default_factory=list)
324
+ unmatched_files: List[dict] = field(default_factory=list)
325
+
326
+
327
+ async def bulk_upload_images(
328
+ db: Session,
329
+ rows: List[ManifestRow],
330
+ files: List[UploadFile],
331
+ *,
332
+ uploaded_by: str,
333
+ defaults: Optional[BulkDefaults] = None,
334
+ ) -> BulkUploadResult:
335
+ result = BulkUploadResult()
336
+ apply_bulk_defaults(rows, defaults or BulkDefaults())
337
+
338
+ files_by_name = {(f.filename or "").strip().lower(): f for f in files}
339
+ matched_names = set()
340
+
341
+ for row in rows:
342
+ key = row.file_name.strip().lower()
343
+ file = files_by_name.get(key)
344
+ if file is None:
345
+ result.errors.append({
346
+ "row": row.row_number,
347
+ "file": row.file_name,
348
+ "reason": "No uploaded file matches this File Name",
349
+ })
350
+ continue
351
+ matched_names.add(key)
352
+ stored_name = row.server_filename or row.file_name
353
+ if not row.zone or not row.village:
354
+ result.errors.append({
355
+ "row": row.row_number,
356
+ "file": row.file_name,
357
+ "reason": "Zone and Village are required (set in Excel or modal defaults)",
358
+ })
359
+ continue
360
+ if row.image_type not in IMAGE_TYPES:
361
+ result.errors.append({
362
+ "row": row.row_number,
363
+ "file": row.file_name,
364
+ "reason": f"Image Type must be one of: {', '.join(sorted(IMAGE_TYPES))}",
365
+ })
366
+ continue
367
+ try:
368
+ zone_id, village_id = _resolve_zone_village_by_name(db, row.zone, row.village)
369
+ node = get_or_create_node_by_path(db, row.folder_path, created_by=uploaded_by)
370
+ already_existed = (
371
+ db.query(ImageLibrary)
372
+ .filter(
373
+ ImageLibrary.node_id == node.id,
374
+ ImageLibrary.image_name == Path(stored_name).name,
375
+ )
376
+ .count() > 0
377
+ )
378
+ img = await upload_image(
379
+ db, node.id, file,
380
+ image_type=row.image_type,
381
+ capture_date=row.capture_date,
382
+ uploaded_by=uploaded_by,
383
+ manual_bounds=row.manual_bounds,
384
+ zone_id=zone_id,
385
+ village_id=village_id,
386
+ year=row.year,
387
+ source=row.source,
388
+ server_filename=stored_name,
389
+ )
390
+ entry = {
391
+ "row": row.row_number,
392
+ "file": row.file_name,
393
+ "serverFilename": stored_name,
394
+ "folder": node.node_path,
395
+ "imageId": img.id,
396
+ }
397
+ (result.duplicates if already_existed else result.created).append(entry)
398
+ except HTTPException as exc:
399
+ db.rollback()
400
+ result.errors.append({"row": row.row_number, "file": row.file_name, "reason": exc.detail})
401
+ except Exception:
402
+ db.rollback()
403
+ logger.exception("Bulk upload failed for row %s (%s)", row.row_number, row.file_name)
404
+ result.errors.append({
405
+ "row": row.row_number,
406
+ "file": row.file_name,
407
+ "reason": "Unexpected error while uploading this file",
408
+ })
409
+
410
+ for name, file in files_by_name.items():
411
+ if name not in matched_names:
412
+ result.unmatched_files.append({"file": file.filename})
413
+
414
+ return result
app/dda/tree/image_service.py CHANGED
@@ -25,8 +25,91 @@ from .tree_service import get_node_or_404
25
 
26
  logger = logging.getLogger(__name__)
27
 
28
- IMAGE_TYPES = {"Satellite", "Drone", "Orthomosaic", "DEM", "GeoTIFF", "Raster", "PNG", "JPEG"}
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
  def _safe_basename(filename: str) -> str:
32
  name = Path(filename or "upload").name
@@ -89,12 +172,84 @@ def _as_iso(value) -> Optional[str]:
89
  return text
90
 
91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  def image_to_dict(
93
  img: ImageLibrary,
94
  node: Optional[TreeNode] = None,
95
  *,
96
  include_bounds: bool = True,
97
  slim: bool = False,
 
 
98
  ) -> dict:
99
  rel = (img.file_path or "").replace("\\", "/")
100
  encoded = quote(rel, safe="/")
@@ -120,7 +275,15 @@ def image_to_dict(
120
  "hasGeoref": bool(img.has_georef),
121
  "width": img.width,
122
  "height": img.height,
 
 
 
 
123
  }
 
 
 
 
124
  if include_bounds:
125
  payload["bounds"] = bounds
126
  if slim:
@@ -130,7 +293,6 @@ def image_to_dict(
130
  "previewUrl": f"/api/dda/local/preview?path={encoded}",
131
  "mapInfoUrl": f"/api/dda/local/map-info?path={encoded}",
132
  "format": img.format,
133
- "source": "tree_library",
134
  })
135
  return payload
136
 
@@ -160,16 +322,21 @@ async def upload_image(
160
  capture_date: Optional[str],
161
  uploaded_by: str,
162
  manual_bounds: Optional[str] = None,
 
 
 
 
 
163
  ) -> ImageLibrary:
164
  node = get_node_or_404(db, node_id)
 
165
  itype = image_type.strip() or "GeoTIFF"
166
- if itype not in IMAGE_TYPES:
167
- raise HTTPException(status_code=400, detail=f"image_type must be one of: {', '.join(sorted(IMAGE_TYPES))}")
168
-
169
- original = _safe_basename(file.filename or "upload")
170
- ext = Path(original).suffix.lower()
171
- if ext not in ALLOWED_EXTENSIONS:
172
- raise HTTPException(status_code=400, detail=f"Allowed extensions: {', '.join(sorted(ALLOWED_EXTENSIONS))}")
173
 
174
  existing = (
175
  db.query(ImageLibrary)
@@ -177,6 +344,25 @@ async def upload_image(
177
  .first()
178
  )
179
  if existing is not None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  logger.info("Skipped duplicate upload %s -> node %s (already exists as image id %s)",
181
  original, node.id, existing.id)
182
  return existing
@@ -218,6 +404,10 @@ async def upload_image(
218
  image_type=itype,
219
  file_path=rel,
220
  capture_date=cap,
 
 
 
 
221
  uploaded_by=uploaded_by or "",
222
  file_size_bytes=size,
223
  thumb_cache_key=hashlib.sha256(rel.encode()).hexdigest()[:32],
@@ -228,7 +418,14 @@ async def upload_image(
228
  format=meta.format,
229
  )
230
  db.add(img)
231
- log_action(db, "upload", node_id=node.id, new_value={"file": rel, "type": itype}, action_by=uploaded_by, module="library")
 
 
 
 
 
 
 
232
  db.commit()
233
  db.refresh(img)
234
  invalidate_library_caches()
@@ -239,7 +436,8 @@ async def upload_image(
239
  def list_images_for_node(db: Session, node_id: int) -> List[dict]:
240
  node = get_node_or_404(db, node_id)
241
  rows = db.query(ImageLibrary).filter(ImageLibrary.node_id == node_id).order_by(ImageLibrary.uploaded_on.desc()).all()
242
- return [image_to_dict(r, node) for r in rows]
 
243
 
244
 
245
  def get_image_by_file_path(db: Session, relative_path: str) -> Optional[ImageLibrary]:
@@ -265,7 +463,19 @@ def _node_and_descendants_clause(node: TreeNode):
265
  return or_(*clauses)
266
 
267
 
268
- def _images_query(db: Session, *, node_id: Optional[int] = None, query: Optional[str] = None):
 
 
 
 
 
 
 
 
 
 
 
 
269
  q = (
270
  db.query(ImageLibrary, TreeNode)
271
  .outerjoin(TreeNode, ImageLibrary.node_id == TreeNode.id)
@@ -283,6 +493,28 @@ def _images_query(db: Session, *, node_id: Optional[int] = None, query: Optional
283
  func.lower(ImageLibrary.image_name).like(like),
284
  func.lower(func.coalesce(TreeNode.node_path, "")).like(like),
285
  ))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
  return q
287
 
288
 
@@ -304,28 +536,93 @@ def list_all_images(
304
  *,
305
  node_id: Optional[int] = None,
306
  query: Optional[str] = None,
 
 
 
 
 
 
 
307
  include_bounds: bool = False,
308
  slim: bool = True,
309
  limit: Optional[int] = None,
310
  offset: int = 0,
311
  ):
312
- q = _images_query(db, node_id=node_id, query=query)
 
 
 
 
 
 
 
 
 
 
 
313
  ordered = q.order_by(ImageLibrary.uploaded_on.desc())
 
314
  if limit is not None:
315
  total = q.count()
316
  rows = ordered.offset(offset).limit(limit).all()
317
  items = [
318
- image_to_dict(img, node, include_bounds=include_bounds, slim=slim)
 
319
  for img, node in rows
320
  ]
321
  return {"items": items, "total": total, "offset": offset, "limit": limit}
322
  rows = ordered.all()
323
  return [
324
- image_to_dict(img, node, include_bounds=include_bounds, slim=slim)
 
325
  for img, node in rows
326
  ]
327
 
328
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
  def get_or_build_thumb(relative_path: str, max_side: int = 256) -> Path:
330
  full = resolve_file(relative_path)
331
  cache = _thumb_cache_path(relative_path)
 
25
 
26
  logger = logging.getLogger(__name__)
27
 
28
+ IMAGE_TYPES = {"GeoTIFF", "PNG", "JPEG"}
29
 
30
+ # Type-first: selected image_type only accepts these extensions.
31
+ IMAGE_TYPE_EXTENSIONS = {
32
+ "GeoTIFF": {".tif", ".tiff"},
33
+ "PNG": {".png"},
34
+ "JPEG": {".jpg", ".jpeg"},
35
+ }
36
+
37
+ IMAGE_SOURCES = {"satellite", "drone", ""}
38
+
39
+
40
+ def extensions_for_image_type(image_type: str) -> set[str]:
41
+ return IMAGE_TYPE_EXTENSIONS.get((image_type or "").strip(), set())
42
+
43
+
44
+ def format_allowed_extensions(exts: set[str]) -> str:
45
+ return ", ".join(sorted(exts))
46
+
47
+
48
+ def validate_image_type_extension(image_type: str, filename: str) -> str:
49
+ """Return lowercased extension or raise 400 if type/extension mismatch."""
50
+ itype = (image_type or "").strip() or "GeoTIFF"
51
+ if itype not in IMAGE_TYPES:
52
+ raise HTTPException(
53
+ status_code=400,
54
+ detail=f"image_type must be one of: {', '.join(sorted(IMAGE_TYPES))}",
55
+ )
56
+ allowed = IMAGE_TYPE_EXTENSIONS[itype]
57
+ ext = Path(filename or "").suffix.lower()
58
+ if not ext or ext not in allowed:
59
+ got = ext or "(none)"
60
+ raise HTTPException(
61
+ status_code=400,
62
+ detail=(
63
+ f"image_type {itype} only allows {format_allowed_extensions(allowed)} "
64
+ f"(got {got} for {Path(filename or 'upload').name})"
65
+ ),
66
+ )
67
+ if ext not in ALLOWED_EXTENSIONS:
68
+ raise HTTPException(
69
+ status_code=400,
70
+ detail=f"Allowed extensions: {', '.join(sorted(ALLOWED_EXTENSIONS))}",
71
+ )
72
+ return ext
73
+
74
+
75
+ def _infer_year_from_node(node: TreeNode) -> Optional[int]:
76
+ for part in (node.node_path or "").split("/"):
77
+ part = part.strip()
78
+ if len(part) == 4 and part.isdigit() and part.startswith(("19", "20")):
79
+ return int(part)
80
+ if node.node_type == "Year":
81
+ name = (node.node_name or "").strip()
82
+ if len(name) == 4 and name.isdigit():
83
+ return int(name)
84
+ return None
85
+
86
+
87
+ def _parse_year(raw: Optional[str]) -> Optional[int]:
88
+ if raw is None:
89
+ return None
90
+ text = str(raw).strip()
91
+ if not text:
92
+ return None
93
+ try:
94
+ year = int(text)
95
+ except ValueError:
96
+ raise HTTPException(status_code=400, detail="year must be a number")
97
+ if year < 1900 or year > 2100:
98
+ raise HTTPException(status_code=400, detail="year out of range")
99
+ return year
100
+
101
+
102
+ def _parse_source(raw: Optional[str], image_type: str) -> str:
103
+ text = (raw or "").strip().lower()
104
+ if text in ("satellite", "drone"):
105
+ return text
106
+ if text:
107
+ raise HTTPException(status_code=400, detail="source must be satellite or drone")
108
+ if image_type == "Satellite":
109
+ return "satellite"
110
+ if image_type == "Drone":
111
+ return "drone"
112
+ return ""
113
 
114
  def _safe_basename(filename: str) -> str:
115
  name = Path(filename or "upload").name
 
172
  return text
173
 
174
 
175
+ def resolve_location(
176
+ db: Session,
177
+ zone_id: Optional[int],
178
+ village_id: Optional[int],
179
+ ) -> tuple[Optional[int], Optional[int]]:
180
+ """Validate a required zone/village choice; a village implies its zone.
181
+
182
+ Both are mandatory — every image must be tagged with exactly one zone
183
+ and one village at upload time. This used to be optional, which is how
184
+ thousands of images ended up "Unassigned" (bulk upload and filesystem
185
+ sync both created rows with no location); the database itself now also
186
+ rejects a NULL zone_id/village_id, so this check exists for a clear,
187
+ early error message rather than an opaque IntegrityError."""
188
+ from ..models import DdaVillage, DdaZone
189
+
190
+ zid = int(zone_id) if zone_id else None
191
+ vid = int(village_id) if village_id else None
192
+ if not vid:
193
+ raise HTTPException(status_code=400, detail="A village is required for every image upload.")
194
+ village = db.query(DdaVillage).filter(DdaVillage.id == vid).first()
195
+ if not village:
196
+ raise HTTPException(status_code=404, detail="Location (village) not found")
197
+ if zid and village.zone_id != zid:
198
+ raise HTTPException(
199
+ status_code=400,
200
+ detail="The selected location does not belong to the selected zone.",
201
+ )
202
+ if not zid and not village.zone_id:
203
+ raise HTTPException(status_code=400, detail="A zone is required for every image upload.")
204
+ return village.zone_id, village.id
205
+
206
+
207
+ def location_names(db: Session) -> tuple[dict[int, str], dict[int, str]]:
208
+ """id → name lookups for zones and villages (both tables are small)."""
209
+ from ..models import DdaVillage, DdaZone
210
+
211
+ zones = {z.id: z.name for z in db.query(DdaZone.id, DdaZone.name).all()}
212
+ villages = {v.id: v.name for v in db.query(DdaVillage.id, DdaVillage.name).all()}
213
+ return zones, villages
214
+
215
+
216
+ def resolve_run_location(db: Session, base_path: str, comparison_path: str) -> tuple[str, str]:
217
+ """Best-effort zone/village NAME for a detection run, derived from the
218
+ library images being compared (their own zone_id/village_id, set at
219
+ upload time) — so a new DetectionRun isn't bucketed under "Unassigned"
220
+ on the dashboard just because the caller didn't pass zone/village
221
+ explicitly. Prefers the after/comparison image's location, falling back
222
+ to the before/base image's. Returns ("", "") if neither image has one."""
223
+ def _norm(p: str) -> str:
224
+ return (p or "").replace("\\", "/").strip().lstrip("/")
225
+
226
+ paths = [p for p in (_norm(comparison_path), _norm(base_path)) if p]
227
+ if not paths:
228
+ return "", ""
229
+ by_path = {
230
+ img.file_path: img
231
+ for img in db.query(ImageLibrary).filter(ImageLibrary.file_path.in_(paths)).all()
232
+ }
233
+ zones, villages = location_names(db)
234
+ for p in paths:
235
+ img = by_path.get(p)
236
+ if not img or not (img.zone_id or img.village_id):
237
+ continue
238
+ zone_name = zones.get(img.zone_id, "") if img.zone_id else ""
239
+ village_name = villages.get(img.village_id, "") if img.village_id else ""
240
+ if zone_name or village_name:
241
+ return zone_name, village_name
242
+ return "", ""
243
+
244
+
245
  def image_to_dict(
246
  img: ImageLibrary,
247
  node: Optional[TreeNode] = None,
248
  *,
249
  include_bounds: bool = True,
250
  slim: bool = False,
251
+ zone_names: Optional[dict] = None,
252
+ village_names: Optional[dict] = None,
253
  ) -> dict:
254
  rel = (img.file_path or "").replace("\\", "/")
255
  encoded = quote(rel, safe="/")
 
275
  "hasGeoref": bool(img.has_georef),
276
  "width": img.width,
277
  "height": img.height,
278
+ "zoneId": img.zone_id,
279
+ "villageId": img.village_id,
280
+ "year": img.year,
281
+ "source": img.source or "",
282
  }
283
+ if zone_names is not None:
284
+ payload["zoneName"] = zone_names.get(img.zone_id, "") if img.zone_id else ""
285
+ if village_names is not None:
286
+ payload["villageName"] = village_names.get(img.village_id, "") if img.village_id else ""
287
  if include_bounds:
288
  payload["bounds"] = bounds
289
  if slim:
 
293
  "previewUrl": f"/api/dda/local/preview?path={encoded}",
294
  "mapInfoUrl": f"/api/dda/local/map-info?path={encoded}",
295
  "format": img.format,
 
296
  })
297
  return payload
298
 
 
322
  capture_date: Optional[str],
323
  uploaded_by: str,
324
  manual_bounds: Optional[str] = None,
325
+ zone_id: Optional[int] = None,
326
+ village_id: Optional[int] = None,
327
+ year: Optional[str] = None,
328
+ source: Optional[str] = None,
329
+ server_filename: Optional[str] = None,
330
  ) -> ImageLibrary:
331
  node = get_node_or_404(db, node_id)
332
+ zone_id, village_id = resolve_location(db, zone_id, village_id)
333
  itype = image_type.strip() or "GeoTIFF"
334
+ original = _safe_basename(server_filename or file.filename or "upload")
335
+ ext = validate_image_type_extension(itype, original)
336
+ year_val = _parse_year(year)
337
+ if year_val is None:
338
+ year_val = _infer_year_from_node(node)
339
+ source_val = _parse_source(source, itype)
 
340
 
341
  existing = (
342
  db.query(ImageLibrary)
 
344
  .first()
345
  )
346
  if existing is not None:
347
+ # Re-uploading the same file is a no-op, but it is still the natural way
348
+ # to fill in a location that was left blank the first time.
349
+ backfilled = False
350
+ if zone_id and not existing.zone_id:
351
+ existing.zone_id = zone_id
352
+ backfilled = True
353
+ if village_id and not existing.village_id:
354
+ existing.village_id = village_id
355
+ backfilled = True
356
+ if year_val and not existing.year:
357
+ existing.year = year_val
358
+ backfilled = True
359
+ if source_val and not existing.source:
360
+ existing.source = source_val
361
+ backfilled = True
362
+ if backfilled:
363
+ db.commit()
364
+ db.refresh(existing)
365
+ invalidate_library_caches()
366
  logger.info("Skipped duplicate upload %s -> node %s (already exists as image id %s)",
367
  original, node.id, existing.id)
368
  return existing
 
404
  image_type=itype,
405
  file_path=rel,
406
  capture_date=cap,
407
+ zone_id=zone_id,
408
+ village_id=village_id,
409
+ year=year_val,
410
+ source=source_val,
411
  uploaded_by=uploaded_by or "",
412
  file_size_bytes=size,
413
  thumb_cache_key=hashlib.sha256(rel.encode()).hexdigest()[:32],
 
418
  format=meta.format,
419
  )
420
  db.add(img)
421
+ log_action(
422
+ db, "upload", node_id=node.id,
423
+ new_value={
424
+ "file": rel, "type": itype, "zoneId": zone_id, "villageId": village_id,
425
+ "year": year_val, "source": source_val,
426
+ },
427
+ action_by=uploaded_by, module="library",
428
+ )
429
  db.commit()
430
  db.refresh(img)
431
  invalidate_library_caches()
 
436
  def list_images_for_node(db: Session, node_id: int) -> List[dict]:
437
  node = get_node_or_404(db, node_id)
438
  rows = db.query(ImageLibrary).filter(ImageLibrary.node_id == node_id).order_by(ImageLibrary.uploaded_on.desc()).all()
439
+ zones, villages = location_names(db)
440
+ return [image_to_dict(r, node, zone_names=zones, village_names=villages) for r in rows]
441
 
442
 
443
  def get_image_by_file_path(db: Session, relative_path: str) -> Optional[ImageLibrary]:
 
463
  return or_(*clauses)
464
 
465
 
466
+ def _images_query(
467
+ db: Session,
468
+ *,
469
+ node_id: Optional[int] = None,
470
+ query: Optional[str] = None,
471
+ year: Optional[int] = None,
472
+ image_type: Optional[str] = None,
473
+ zone_id: Optional[int] = None,
474
+ village_id: Optional[int] = None,
475
+ capture_date_from: Optional[str] = None,
476
+ capture_date_to: Optional[str] = None,
477
+ source: Optional[str] = None,
478
+ ):
479
  q = (
480
  db.query(ImageLibrary, TreeNode)
481
  .outerjoin(TreeNode, ImageLibrary.node_id == TreeNode.id)
 
493
  func.lower(ImageLibrary.image_name).like(like),
494
  func.lower(func.coalesce(TreeNode.node_path, "")).like(like),
495
  ))
496
+ if year is not None:
497
+ q = q.filter(ImageLibrary.year == year)
498
+ if image_type:
499
+ q = q.filter(ImageLibrary.image_type == image_type.strip())
500
+ if zone_id is not None:
501
+ q = q.filter(ImageLibrary.zone_id == zone_id)
502
+ if village_id is not None:
503
+ q = q.filter(ImageLibrary.village_id == village_id)
504
+ if source:
505
+ q = q.filter(func.lower(ImageLibrary.source) == source.strip().lower())
506
+ if capture_date_from:
507
+ try:
508
+ start = date.fromisoformat(capture_date_from.strip())
509
+ except ValueError:
510
+ raise HTTPException(status_code=400, detail="capture_date_from must be YYYY-MM-DD")
511
+ q = q.filter(ImageLibrary.capture_date >= datetime.combine(start, datetime.min.time()))
512
+ if capture_date_to:
513
+ try:
514
+ end = date.fromisoformat(capture_date_to.strip())
515
+ except ValueError:
516
+ raise HTTPException(status_code=400, detail="capture_date_to must be YYYY-MM-DD")
517
+ q = q.filter(ImageLibrary.capture_date <= datetime.combine(end, datetime.max.time()))
518
  return q
519
 
520
 
 
536
  *,
537
  node_id: Optional[int] = None,
538
  query: Optional[str] = None,
539
+ year: Optional[int] = None,
540
+ image_type: Optional[str] = None,
541
+ zone_id: Optional[int] = None,
542
+ village_id: Optional[int] = None,
543
+ capture_date_from: Optional[str] = None,
544
+ capture_date_to: Optional[str] = None,
545
+ source: Optional[str] = None,
546
  include_bounds: bool = False,
547
  slim: bool = True,
548
  limit: Optional[int] = None,
549
  offset: int = 0,
550
  ):
551
+ q = _images_query(
552
+ db,
553
+ node_id=node_id,
554
+ query=query,
555
+ year=year,
556
+ image_type=image_type,
557
+ zone_id=zone_id,
558
+ village_id=village_id,
559
+ capture_date_from=capture_date_from,
560
+ capture_date_to=capture_date_to,
561
+ source=source,
562
+ )
563
  ordered = q.order_by(ImageLibrary.uploaded_on.desc())
564
+ zones, villages = location_names(db)
565
  if limit is not None:
566
  total = q.count()
567
  rows = ordered.offset(offset).limit(limit).all()
568
  items = [
569
+ image_to_dict(img, node, include_bounds=include_bounds, slim=slim,
570
+ zone_names=zones, village_names=villages)
571
  for img, node in rows
572
  ]
573
  return {"items": items, "total": total, "offset": offset, "limit": limit}
574
  rows = ordered.all()
575
  return [
576
+ image_to_dict(img, node, include_bounds=include_bounds, slim=slim,
577
+ zone_names=zones, village_names=villages)
578
  for img, node in rows
579
  ]
580
 
581
 
582
+ def export_library_rows(
583
+ db: Session,
584
+ *,
585
+ node_id: Optional[int] = None,
586
+ query: Optional[str] = None,
587
+ year: Optional[int] = None,
588
+ image_type: Optional[str] = None,
589
+ zone_id: Optional[int] = None,
590
+ village_id: Optional[int] = None,
591
+ capture_date_from: Optional[str] = None,
592
+ capture_date_to: Optional[str] = None,
593
+ source: Optional[str] = None,
594
+ ) -> list[dict]:
595
+ rows = list_all_images(
596
+ db,
597
+ node_id=node_id,
598
+ query=query,
599
+ year=year,
600
+ image_type=image_type,
601
+ zone_id=zone_id,
602
+ village_id=village_id,
603
+ capture_date_from=capture_date_from,
604
+ capture_date_to=capture_date_to,
605
+ source=source,
606
+ include_bounds=False,
607
+ slim=False,
608
+ )
609
+ out = []
610
+ for img in rows:
611
+ out.append({
612
+ "Image ID": img.get("id"),
613
+ "Server filename": img.get("filename") or img.get("imageName") or "",
614
+ "Image type": img.get("imageType") or "",
615
+ "Source": img.get("source") or "",
616
+ "Format": img.get("format") or "",
617
+ "Capture date": (str(img.get("captureDate") or "")[:10]),
618
+ "Year": img.get("year") or "",
619
+ "Zone": img.get("zoneName") or "",
620
+ "Village": img.get("villageName") or "",
621
+ "File size": img.get("fileSizeBytes") or 0,
622
+ "Uploaded on": img.get("uploadedOn") or "",
623
+ })
624
+ return out
625
+
626
  def get_or_build_thumb(relative_path: str, max_side: int = 256) -> Path:
627
  full = resolve_file(relative_path)
628
  cache = _thumb_cache_path(relative_path)
app/dda/tree/library_cache.py CHANGED
@@ -3,23 +3,26 @@ from __future__ import annotations
3
 
4
  from typing import Any, Optional
5
 
 
6
  _area_fp: Optional[tuple] = None
7
- _area_payload: Optional[dict[str, Any]] = None
8
 
9
 
10
  def invalidate_library_caches() -> None:
11
- global _area_fp, _area_payload
12
  _area_fp = None
13
- _area_payload = None
14
 
15
 
16
- def get_area_groups_cache(fingerprint: tuple) -> Optional[dict[str, Any]]:
17
- if _area_fp == fingerprint:
18
- return _area_payload
19
- return None
20
 
21
 
22
- def set_area_groups_cache(fingerprint: tuple, payload: dict[str, Any]) -> None:
23
- global _area_fp, _area_payload
24
- _area_fp = fingerprint
25
- _area_payload = payload
 
 
 
3
 
4
  from typing import Any, Optional
5
 
6
+ # fingerprint → { node_id_or_0: payload }
7
  _area_fp: Optional[tuple] = None
8
+ _area_by_node: dict[int, dict[str, Any]] = {}
9
 
10
 
11
  def invalidate_library_caches() -> None:
12
+ global _area_fp, _area_by_node
13
  _area_fp = None
14
+ _area_by_node = {}
15
 
16
 
17
+ def get_area_groups_cache(fingerprint: tuple, node_id: int = 0) -> Optional[dict[str, Any]]:
18
+ if _area_fp != fingerprint:
19
+ return None
20
+ return _area_by_node.get(int(node_id or 0))
21
 
22
 
23
+ def set_area_groups_cache(fingerprint: tuple, payload: dict[str, Any], node_id: int = 0) -> None:
24
+ global _area_fp, _area_by_node
25
+ if _area_fp != fingerprint:
26
+ _area_by_node = {}
27
+ _area_fp = fingerprint
28
+ _area_by_node[int(node_id or 0)] = payload
app/dda/tree/migration.py CHANGED
@@ -10,7 +10,7 @@ from sqlalchemy.orm import Session
10
 
11
  from ..config import ALLOWED_EXTENSIONS, get_library_roots, get_storage_root
12
  from ..seed_data import DELHI_ZONES
13
- from .models import ImageLibrary, TreeNode
14
  from .path_service import ensure_node_directory, storage_root
15
  from .path_slugs import unique_slug
16
  from .tree_service import create_node
@@ -85,16 +85,10 @@ def migrate_flat_year_folders(db: Session) -> dict:
85
  if path.resolve() != target.resolve():
86
  shutil.move(str(path), str(target))
87
  rel = target.relative_to(root).as_posix()
88
- if not db.query(ImageLibrary).filter(ImageLibrary.file_path == rel).first():
89
- db.add(ImageLibrary(
90
- node_id=year_node.id,
91
- image_name=target.name,
92
- image_type="GeoTIFF",
93
- file_path=rel,
94
- uploaded_by="migration",
95
- file_size_bytes=target.stat().st_size,
96
- ))
97
- indexed += 1
98
  moved += 1
99
  try:
100
  if entry.is_dir() and not any(entry.iterdir()):
 
10
 
11
  from ..config import ALLOWED_EXTENSIONS, get_library_roots, get_storage_root
12
  from ..seed_data import DELHI_ZONES
13
+ from .models import TreeNode
14
  from .path_service import ensure_node_directory, storage_root
15
  from .path_slugs import unique_slug
16
  from .tree_service import create_node
 
85
  if path.resolve() != target.resolve():
86
  shutil.move(str(path), str(target))
87
  rel = target.relative_to(root).as_posix()
88
+ # zone_id/village_id are required (NOT NULL); this legacy path has
89
+ # no location info to give a migrated image, so the file is moved
90
+ # into the tree but left un-indexed — same reasoning as the
91
+ # filesystem sync's own skip (see sync_service.py).
 
 
 
 
 
 
92
  moved += 1
93
  try:
94
  if entry.is_dir() and not any(entry.iterdir()):
app/dda/tree/models.py CHANGED
@@ -43,6 +43,19 @@ class ImageLibrary(Base):
43
  # path. Works unchanged on SQLite/PostgreSQL too.
44
  file_path = Column(String(500), nullable=False, unique=True) # relative to storage root
45
  capture_date = Column(DateTime, nullable=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  uploaded_by = Column(String(100), default="")
47
  uploaded_on = Column(DateTime, default=_utcnow)
48
  # BigInteger, not Integer: this project's GeoTIFFs regularly exceed 2GB
@@ -86,4 +99,5 @@ class ExceptionLog(Base):
86
  handled = Column(Boolean, default=False)
87
  traceback = Column(Text, default="")
88
  user_email = Column(String(100), nullable=True)
 
89
  created_at = Column(DateTime, default=_utcnow, index=True)
 
43
  # path. Works unchanged on SQLite/PostgreSQL too.
44
  file_path = Column(String(500), nullable=False, unique=True) # relative to storage root
45
  capture_date = Column(DateTime, nullable=True)
46
+ # Administrative location picked on upload. Independent of the folder tree:
47
+ # the same zone/village can appear under several folders, and pairing uses
48
+ # it as a strong "same place" signal. Required, not just conventionally —
49
+ # an "Unassigned" image was a recurring real problem (thousands of images
50
+ # landed with no location via bulk upload / filesystem sync and cluttered
51
+ # every zone/village breakdown), so this is enforced NOT NULL at the DB
52
+ # level too (see bootstrap.py's migration list) so no insert path —
53
+ # including ones outside this app's own upload endpoints — can create an
54
+ # unassigned row.
55
+ zone_id = Column(Integer, ForeignKey("dda_zones.id"), nullable=False, index=True)
56
+ village_id = Column(Integer, ForeignKey("dda_villages.id"), nullable=False, index=True)
57
+ year = Column(Integer, nullable=True, index=True)
58
+ source = Column(String(32), default="") # satellite | drone | ""
59
  uploaded_by = Column(String(100), default="")
60
  uploaded_on = Column(DateTime, default=_utcnow)
61
  # BigInteger, not Integer: this project's GeoTIFFs regularly exceed 2GB
 
99
  handled = Column(Boolean, default=False)
100
  traceback = Column(Text, default="")
101
  user_email = Column(String(100), nullable=True)
102
+ user_role = Column(String(64), nullable=True)
103
  created_at = Column(DateTime, default=_utcnow, index=True)
app/dda/tree/routes.py CHANGED
@@ -1,23 +1,37 @@
1
  """FastAPI routes for unlimited-depth tree library."""
2
  from __future__ import annotations
3
 
4
- from typing import Optional
 
 
 
5
 
6
- from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
 
7
  from pydantic import BaseModel, Field
8
  from sqlalchemy.orm import Session
9
 
10
  from ...database import get_db
11
  from ...models import User
12
- from ..config import IS_DDA_MODE
13
  from ..dda_auth import current_dda_user, get_user_role
14
  from ..rbac.permissions import require_module_permission
 
 
 
 
 
 
 
15
  from .image_service import (
 
16
  IMAGE_TYPES,
17
  delete_library_image,
 
18
  image_to_dict,
19
  list_all_images,
20
  list_images_for_node,
 
21
  upload_image,
22
  )
23
  from .tree_service import (
@@ -38,6 +52,17 @@ def _require_dda():
38
  raise HTTPException(status_code=404, detail="DDA mode is not enabled")
39
 
40
 
 
 
 
 
 
 
 
 
 
 
 
41
  class NodeCreateBody(BaseModel):
42
  parent_id: Optional[int] = None
43
  node_name: str = Field(..., min_length=1, max_length=500)
@@ -66,7 +91,11 @@ def tree_me(user: User = Depends(current_dda_user), db: Session = Depends(get_db
66
  @router.get("/tree")
67
  def get_tree(db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
68
  _require_dda()
69
- return {"tree": build_tree(db), "imageTypes": sorted(IMAGE_TYPES)}
 
 
 
 
70
 
71
 
72
  @router.post("/tree/nodes")
@@ -184,11 +213,98 @@ def api_list_node_images(node_id: int, db: Session = Depends(get_db), user: User
184
  def api_list_images(
185
  node_id: Optional[int] = None,
186
  q: Optional[str] = None,
 
 
 
 
 
 
 
 
 
187
  db: Session = Depends(get_db),
188
  user: User = Depends(current_dda_user),
189
  ):
190
  _require_dda()
191
- return list_all_images(db, node_id=node_id, query=q)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
 
193
 
194
  @router.post("/tree/nodes/{node_id}/images/upload")
@@ -198,6 +314,11 @@ async def api_upload_image(
198
  image_type: str = Form("GeoTIFF"),
199
  capture_date: str = Form(""),
200
  manual_bounds: str = Form(""),
 
 
 
 
 
201
  db: Session = Depends(get_db),
202
  user: User = Depends(require_module_permission("library", "create")),
203
  ):
@@ -208,9 +329,160 @@ async def api_upload_image(
208
  capture_date=capture_date or None,
209
  uploaded_by=user.email or str(user.id),
210
  manual_bounds=manual_bounds or None,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  node = get_node_or_404(db, node_id)
213
- return {"status": True, "message": "Image Uploaded Successfully", "image": image_to_dict(img, node)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
 
215
 
216
  @router.delete("/tree/images/{image_id}")
 
1
  """FastAPI routes for unlimited-depth tree library."""
2
  from __future__ import annotations
3
 
4
+ import csv
5
+ import io
6
+ import json
7
+ from typing import List, Optional
8
 
9
+ from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
10
+ from fastapi.responses import Response
11
  from pydantic import BaseModel, Field
12
  from sqlalchemy.orm import Session
13
 
14
  from ...database import get_db
15
  from ...models import User
16
+ from ..config import IS_DDA_MODE, get_storage_root
17
  from ..dda_auth import current_dda_user, get_user_role
18
  from ..rbac.permissions import require_module_permission
19
+ from .bulk_upload_service import (
20
+ BulkDefaults,
21
+ build_bulk_upload_template,
22
+ bulk_upload_images,
23
+ parse_bulk_manifest,
24
+ zone_village_names,
25
+ )
26
  from .image_service import (
27
+ IMAGE_TYPE_EXTENSIONS,
28
  IMAGE_TYPES,
29
  delete_library_image,
30
+ export_library_rows,
31
  image_to_dict,
32
  list_all_images,
33
  list_images_for_node,
34
+ location_names,
35
  upload_image,
36
  )
37
  from .tree_service import (
 
52
  raise HTTPException(status_code=404, detail="DDA mode is not enabled")
53
 
54
 
55
+ def _optional_int(raw: str, field: str) -> Optional[int]:
56
+ """Multipart form fields arrive as strings; '' means 'not selected'."""
57
+ text = (raw or "").strip()
58
+ if not text:
59
+ return None
60
+ try:
61
+ return int(text)
62
+ except ValueError:
63
+ raise HTTPException(status_code=400, detail=f"{field} must be a number")
64
+
65
+
66
  class NodeCreateBody(BaseModel):
67
  parent_id: Optional[int] = None
68
  node_name: str = Field(..., min_length=1, max_length=500)
 
91
  @router.get("/tree")
92
  def get_tree(db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
93
  _require_dda()
94
+ return {
95
+ "tree": build_tree(db),
96
+ "imageTypes": sorted(IMAGE_TYPES),
97
+ "imageTypeExtensions": {k: sorted(v) for k, v in IMAGE_TYPE_EXTENSIONS.items()},
98
+ }
99
 
100
 
101
  @router.post("/tree/nodes")
 
213
  def api_list_images(
214
  node_id: Optional[int] = None,
215
  q: Optional[str] = None,
216
+ year: Optional[int] = None,
217
+ image_type: Optional[str] = None,
218
+ zone_id: Optional[int] = None,
219
+ village_id: Optional[int] = None,
220
+ capture_date_from: Optional[str] = None,
221
+ capture_date_to: Optional[str] = None,
222
+ source: Optional[str] = None,
223
+ limit: Optional[int] = Query(50, ge=1, le=500),
224
+ offset: int = Query(0, ge=0),
225
  db: Session = Depends(get_db),
226
  user: User = Depends(current_dda_user),
227
  ):
228
  _require_dda()
229
+ return list_all_images(
230
+ db,
231
+ node_id=node_id,
232
+ query=q,
233
+ year=year,
234
+ image_type=image_type,
235
+ zone_id=zone_id,
236
+ village_id=village_id,
237
+ capture_date_from=capture_date_from,
238
+ capture_date_to=capture_date_to,
239
+ source=source,
240
+ include_bounds=False,
241
+ slim=True,
242
+ limit=limit if limit is not None else 50,
243
+ offset=offset,
244
+ )
245
+
246
+
247
+ @router.get("/tree/images/export")
248
+ def api_export_images(
249
+ node_id: Optional[int] = None,
250
+ q: Optional[str] = None,
251
+ year: Optional[int] = None,
252
+ image_type: Optional[str] = None,
253
+ zone_id: Optional[int] = None,
254
+ village_id: Optional[int] = None,
255
+ capture_date_from: Optional[str] = None,
256
+ capture_date_to: Optional[str] = None,
257
+ source: Optional[str] = None,
258
+ format: str = Query("csv", pattern="^(csv|xlsx)$"),
259
+ db: Session = Depends(get_db),
260
+ user: User = Depends(require_module_permission("library", "view")),
261
+ ):
262
+ _require_dda()
263
+ rows = export_library_rows(
264
+ db,
265
+ node_id=node_id,
266
+ query=q,
267
+ year=year,
268
+ image_type=image_type,
269
+ zone_id=zone_id,
270
+ village_id=village_id,
271
+ capture_date_from=capture_date_from,
272
+ capture_date_to=capture_date_to,
273
+ source=source,
274
+ )
275
+ headers = [
276
+ "Image ID", "Server filename", "Image type", "Source", "Format",
277
+ "Capture date", "Year", "Zone", "Village", "File size", "Uploaded on",
278
+ ]
279
+ if format == "xlsx":
280
+ from openpyxl import Workbook
281
+
282
+ wb = Workbook()
283
+ ws = wb.active
284
+ ws.title = "Library"
285
+ ws.append(headers)
286
+ for row in rows:
287
+ ws.append([row.get(h, "") for h in headers])
288
+ buf = io.BytesIO()
289
+ wb.save(buf)
290
+ buf.seek(0)
291
+ return Response(
292
+ content=buf.getvalue(),
293
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
294
+ headers={"Content-Disposition": 'attachment; filename="library_export.xlsx"'},
295
+ )
296
+
297
+ buf = io.StringIO()
298
+ writer = csv.DictWriter(buf, fieldnames=headers, extrasaction="ignore")
299
+ writer.writeheader()
300
+ for row in rows:
301
+ writer.writerow(row)
302
+ data = buf.getvalue().encode("utf-8-sig")
303
+ return Response(
304
+ content=data,
305
+ media_type="text/csv; charset=utf-8",
306
+ headers={"Content-Disposition": 'attachment; filename="library_export.csv"'},
307
+ )
308
 
309
 
310
  @router.post("/tree/nodes/{node_id}/images/upload")
 
314
  image_type: str = Form("GeoTIFF"),
315
  capture_date: str = Form(""),
316
  manual_bounds: str = Form(""),
317
+ zone_id: str = Form(""),
318
+ village_id: str = Form(""),
319
+ year: str = Form(""),
320
+ source: str = Form(""),
321
+ server_filename: str = Form(""),
322
  db: Session = Depends(get_db),
323
  user: User = Depends(require_module_permission("library", "create")),
324
  ):
 
329
  capture_date=capture_date or None,
330
  uploaded_by=user.email or str(user.id),
331
  manual_bounds=manual_bounds or None,
332
+ zone_id=_optional_int(zone_id, "zone_id"),
333
+ village_id=_optional_int(village_id, "village_id"),
334
+ year=year or None,
335
+ source=source or None,
336
+ server_filename=server_filename or None,
337
+ )
338
+ node = get_node_or_404(db, node_id)
339
+ zones, villages = location_names(db)
340
+ return {
341
+ "status": True,
342
+ "message": "Image Uploaded Successfully",
343
+ "image": image_to_dict(img, node, zone_names=zones, village_names=villages),
344
+ }
345
+
346
+
347
+ @router.get("/tree/bulk-upload/template")
348
+ def api_bulk_upload_template(
349
+ user: User = Depends(require_module_permission("library", "view")),
350
+ ):
351
+ _require_dda()
352
+ content = build_bulk_upload_template()
353
+ return Response(
354
+ content=content,
355
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
356
+ headers={"Content-Disposition": "attachment; filename=bulk_image_upload_template.xlsx"},
357
+ )
358
+
359
+
360
+ @router.get("/tree/bulk-upload/config")
361
+ def api_bulk_upload_config(
362
+ user: User = Depends(require_module_permission("library", "view")),
363
+ ):
364
+ """Main folder for this environment — Excel General Folder paths are relative to it."""
365
+ _require_dda()
366
+ root = get_storage_root()
367
+ return {
368
+ "mainFolder": str(root),
369
+ "imageTypes": sorted(IMAGE_TYPES),
370
+ "sources": ["satellite", "drone"],
371
+ }
372
+
373
+
374
+ @router.post("/tree/bulk-upload")
375
+ async def api_bulk_upload(
376
+ excel: UploadFile = File(...),
377
+ files: List[UploadFile] = File(...),
378
+ year: str = Form(""),
379
+ source: str = Form(""),
380
+ image_type: str = Form(""),
381
+ zone_id: str = Form(""),
382
+ village_id: str = Form(""),
383
+ filenames: Optional[str] = Form(None),
384
+ db: Session = Depends(get_db),
385
+ user: User = Depends(require_module_permission("library", "create")),
386
+ ):
387
+ _require_dda()
388
+ xlsx_bytes = await excel.read()
389
+ rows, row_errors = parse_bulk_manifest(xlsx_bytes)
390
+
391
+ zid = _optional_int(zone_id, "zone_id")
392
+ vid = _optional_int(village_id, "village_id")
393
+ zone_name, village_name = zone_village_names(db, zid, vid)
394
+
395
+ name_map = {}
396
+ if filenames:
397
+ try:
398
+ raw = json.loads(filenames)
399
+ if isinstance(raw, dict):
400
+ name_map = {str(k).strip().lower(): str(v).strip() for k, v in raw.items() if v}
401
+ except Exception:
402
+ raise HTTPException(status_code=400, detail="filenames must be a JSON object of originalName → serverFilename")
403
+
404
+ defaults = BulkDefaults(
405
+ year=(year or "").strip() or None,
406
+ source=(source or "").strip().lower() or None,
407
+ image_type=(image_type or "").strip() or None,
408
+ zone=zone_name or None,
409
+ village=village_name or None,
410
+ filenames=name_map,
411
  )
412
+ # Always run defaults so empty Image Type gets GeoTIFF.
413
+ result = await bulk_upload_images(
414
+ db, rows, files,
415
+ uploaded_by=user.email or str(user.id),
416
+ defaults=defaults,
417
+ )
418
+ result.errors = row_errors + result.errors
419
+ return {
420
+ "status": True,
421
+ "created": result.created,
422
+ "duplicates": result.duplicates,
423
+ "errors": result.errors,
424
+ "unmatchedFiles": result.unmatched_files,
425
+ "summary": {
426
+ "created": len(result.created),
427
+ "duplicates": len(result.duplicates),
428
+ "errors": len(result.errors),
429
+ "unmatchedFiles": len(result.unmatched_files),
430
+ },
431
+ "mainFolder": str(get_storage_root()),
432
+ }
433
+
434
+
435
+ @router.post("/tree/nodes/{node_id}/images/upload-bulk")
436
+ async def api_upload_images_bulk(
437
+ node_id: int,
438
+ files: List[UploadFile] = File(...),
439
+ image_type: str = Form("GeoTIFF"),
440
+ capture_date: str = Form(""),
441
+ manual_bounds: str = Form(""),
442
+ zone_id: str = Form(""),
443
+ village_id: str = Form(""),
444
+ year: str = Form(""),
445
+ source: str = Form(""),
446
+ filenames: Optional[List[str]] = Form(None),
447
+ db: Session = Depends(get_db),
448
+ user: User = Depends(require_module_permission("library", "create")),
449
+ ):
450
+ """Upload many files in one request; loops the single-file upload path."""
451
+ _require_dda()
452
  node = get_node_or_404(db, node_id)
453
+ zones, villages = location_names(db)
454
+ uploaded = []
455
+ failed = []
456
+ if isinstance(filenames, str):
457
+ name_list = [filenames]
458
+ else:
459
+ name_list = list(filenames or [])
460
+ for i, file in enumerate(files):
461
+ server_name = name_list[i] if i < len(name_list) else (file.filename or "upload")
462
+ try:
463
+ img = await upload_image(
464
+ db, node_id, file,
465
+ image_type=image_type,
466
+ capture_date=capture_date or None,
467
+ uploaded_by=user.email or str(user.id),
468
+ manual_bounds=manual_bounds or None,
469
+ zone_id=_optional_int(zone_id, "zone_id"),
470
+ village_id=_optional_int(village_id, "village_id"),
471
+ year=year or None,
472
+ source=source or None,
473
+ server_filename=server_name,
474
+ )
475
+ uploaded.append(image_to_dict(img, node, zone_names=zones, village_names=villages))
476
+ except HTTPException as exc:
477
+ failed.append({"filename": server_name, "error": str(exc.detail)})
478
+ except Exception as exc:
479
+ failed.append({"filename": server_name, "error": str(exc)})
480
+ return {
481
+ "status": True,
482
+ "message": f"Uploaded {len(uploaded)} image(s)" + (f", {len(failed)} failed" if failed else ""),
483
+ "uploaded": uploaded,
484
+ "failed": failed,
485
+ }
486
 
487
 
488
  @router.delete("/tree/images/{image_id}")
app/dda/tree/sync_service.py CHANGED
@@ -22,6 +22,7 @@ _NODE_TYPES = ("Zone", "Area", "Year", "Folder")
22
  _SKIP_DIRS = frozenset({".git", ".thumbs", "__pycache__", "cache", "thumbs"})
23
  _RESERVED_DIR_NAMES = RESERVED
24
  _SKIP_CACHE: Optional[dict] = None
 
25
 
26
 
27
  def _skip_cache_path() -> Path:
@@ -172,7 +173,7 @@ def _image_type_for(file_path: Path) -> str:
172
  return "JPEG"
173
  if ext == ".png":
174
  return "PNG"
175
- return "Raster"
176
 
177
 
178
  def _index_image_file(
@@ -182,6 +183,9 @@ def _index_image_file(
182
 
183
  Unreadable rasters (corrupt TIFF, etc.) are skipped and counted in
184
  ``stats['imagesSkipped']`` so one bad file cannot fail the whole rescan.
 
 
 
185
  """
186
  existing = db.query(ImageLibrary).filter(ImageLibrary.file_path == rel_file).first()
187
  path_fixed = False
@@ -197,7 +201,7 @@ def _index_image_file(
197
  skip_meta = skip.get(rel_file)
198
  if skip_meta is not None and int(skip_meta.get("size") or 0) == stat.st_size:
199
  if path_fixed:
200
- db.commit()
201
  stats["imagesSkipped"] = stats.get("imagesSkipped", 0) + 1
202
  return False
203
  if existing and existing.file_size_bytes == stat.st_size and (existing.width or 0) > 0:
@@ -209,7 +213,7 @@ def _index_image_file(
209
  changed = True
210
  if changed:
211
  existing.node_id = node.id
212
- db.commit()
213
  stats["imagesUpdated"] += 1
214
  return False
215
  try:
@@ -249,35 +253,29 @@ def _index_image_file(
249
  inferred = _infer_capture_date(file_path, node)
250
  if inferred:
251
  existing.capture_date = inferred
252
- db.commit()
253
  stats["imagesUpdated"] += 1
254
  elif existing.capture_date is None:
255
  inferred = _infer_capture_date(file_path, node)
256
  if inferred:
257
  existing.capture_date = inferred
258
- db.commit()
259
  stats["imagesUpdated"] += 1
260
  return False
261
 
262
- img = ImageLibrary(
263
- node_id=node.id,
264
- image_name=file_path.name,
265
- image_type=_image_type_for(file_path),
266
- file_path=rel_file,
267
- capture_date=_infer_capture_date(file_path, node),
268
- uploaded_by="filesystem-sync",
269
- file_size_bytes=stat.st_size,
270
- thumb_cache_key=hashlib.sha256(rel_file.encode()).hexdigest()[:32],
271
- width=meta.width,
272
- height=meta.height,
273
- has_georef=meta.has_georef,
274
- bounds_json=bounds_to_json(meta.bounds_wgs84) or "",
275
- format=meta.format,
276
  )
277
- db.add(img)
278
- db.commit()
279
- logger.info("Indexed image from disk: %s", rel_file)
280
- return True
281
 
282
 
283
  def _node_path_for_images_folder(rel_path: str) -> Optional[str]:
@@ -312,12 +310,19 @@ def _sync_directory(db: Session, abs_dir: Path, rel_path: str, stats: dict) -> N
312
  logger.warning("Skipping images under %s: %s", rel_path, exc)
313
  stats["foldersSkipped"] = stats.get("foldersSkipped", 0) + 1
314
  continue
 
315
  for f in sorted(child.iterdir()):
316
  if not f.is_file() or f.suffix.lower() not in ALLOWED_EXTENSIONS:
317
  continue
318
  rel_file = f.relative_to(storage_root()).as_posix()
319
  if _index_image_file(db, node, f, rel_file, stats):
320
  stats["imagesIndexed"] += 1
 
 
 
 
 
 
321
  continue
322
 
323
  if child.name.lower() in _RESERVED_DIR_NAMES:
@@ -349,6 +354,7 @@ def sync_from_filesystem(db: Session, *, check_orphans: bool = True) -> dict:
349
  "imagesIndexed": 0,
350
  "imagesUpdated": 0,
351
  "imagesSkipped": 0,
 
352
  "orphansFlagged": 0,
353
  "foldersSkipped": 0,
354
  }
 
22
  _SKIP_DIRS = frozenset({".git", ".thumbs", "__pycache__", "cache", "thumbs"})
23
  _RESERVED_DIR_NAMES = RESERVED
24
  _SKIP_CACHE: Optional[dict] = None
25
+ _COMMIT_BATCH_SIZE = 200
26
 
27
 
28
  def _skip_cache_path() -> Path:
 
173
  return "JPEG"
174
  if ext == ".png":
175
  return "PNG"
176
+ return "GeoTIFF"
177
 
178
 
179
  def _index_image_file(
 
183
 
184
  Unreadable rasters (corrupt TIFF, etc.) are skipped and counted in
185
  ``stats['imagesSkipped']`` so one bad file cannot fail the whole rescan.
186
+
187
+ Does not commit — the caller batches commits across many files (see
188
+ ``_COMMIT_BATCH_SIZE``) so a large sync isn't one fsync per row.
189
  """
190
  existing = db.query(ImageLibrary).filter(ImageLibrary.file_path == rel_file).first()
191
  path_fixed = False
 
201
  skip_meta = skip.get(rel_file)
202
  if skip_meta is not None and int(skip_meta.get("size") or 0) == stat.st_size:
203
  if path_fixed:
204
+ db.flush()
205
  stats["imagesSkipped"] = stats.get("imagesSkipped", 0) + 1
206
  return False
207
  if existing and existing.file_size_bytes == stat.st_size and (existing.width or 0) > 0:
 
213
  changed = True
214
  if changed:
215
  existing.node_id = node.id
216
+ db.flush()
217
  stats["imagesUpdated"] += 1
218
  return False
219
  try:
 
253
  inferred = _infer_capture_date(file_path, node)
254
  if inferred:
255
  existing.capture_date = inferred
256
+ db.flush()
257
  stats["imagesUpdated"] += 1
258
  elif existing.capture_date is None:
259
  inferred = _infer_capture_date(file_path, node)
260
  if inferred:
261
  existing.capture_date = inferred
262
+ db.flush()
263
  stats["imagesUpdated"] += 1
264
  return False
265
 
266
+ # zone_id/village_id are required (NOT NULL) and there's no reliable way
267
+ # to derive them from a bare file on disk — a file found by the sync
268
+ # (rather than uploaded through the app, which does collect a location)
269
+ # is left un-indexed rather than creating an "Unassigned" row. It stays
270
+ # on disk and will be picked up automatically once someone uploads it
271
+ # properly (with a zone/village) through the Image Library UI, which
272
+ # de-dupes by file_path.
273
+ logger.info(
274
+ "Not indexing %s: found on disk but the filesystem sync has no zone/village for it "
275
+ "(upload it through the Image Library to tag a location)", rel_file,
 
 
 
 
276
  )
277
+ stats["imagesNeedLocation"] = stats.get("imagesNeedLocation", 0) + 1
278
+ return False
 
 
279
 
280
 
281
  def _node_path_for_images_folder(rel_path: str) -> Optional[str]:
 
310
  logger.warning("Skipping images under %s: %s", rel_path, exc)
311
  stats["foldersSkipped"] = stats.get("foldersSkipped", 0) + 1
312
  continue
313
+ since_commit = 0
314
  for f in sorted(child.iterdir()):
315
  if not f.is_file() or f.suffix.lower() not in ALLOWED_EXTENSIONS:
316
  continue
317
  rel_file = f.relative_to(storage_root()).as_posix()
318
  if _index_image_file(db, node, f, rel_file, stats):
319
  stats["imagesIndexed"] += 1
320
+ since_commit += 1
321
+ if since_commit >= _COMMIT_BATCH_SIZE:
322
+ db.commit()
323
+ since_commit = 0
324
+ if since_commit:
325
+ db.commit()
326
  continue
327
 
328
  if child.name.lower() in _RESERVED_DIR_NAMES:
 
354
  "imagesIndexed": 0,
355
  "imagesUpdated": 0,
356
  "imagesSkipped": 0,
357
+ "imagesNeedLocation": 0,
358
  "orphansFlagged": 0,
359
  "foldersSkipped": 0,
360
  }
app/dda/tree/tree_service.py CHANGED
@@ -173,6 +173,29 @@ def get_or_create_node(
173
  raise
174
 
175
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  def rename_node(
177
  db: Session,
178
  node_id: int,
 
173
  raise
174
 
175
 
176
+ def get_or_create_node_by_path(
177
+ db: Session,
178
+ path: str,
179
+ *,
180
+ created_by: str,
181
+ node_type: str = "Folder",
182
+ ) -> TreeNode:
183
+ """Walk a "/"-separated path from the root, creating any missing folder
184
+ segment along the way (mirrors the frontend's ensureFolderPath, used for
185
+ webkitdirectory uploads, but server-side — see bulk_upload_service.py)."""
186
+ segments = [s.strip() for s in (path or "").split("/") if s.strip()]
187
+ if not segments:
188
+ raise HTTPException(status_code=400, detail="Folder path is required")
189
+ parent_id: Optional[int] = None
190
+ node: Optional[TreeNode] = None
191
+ for segment in segments:
192
+ node, _created = get_or_create_node(
193
+ db, parent_id=parent_id, node_name=segment, node_type=node_type, created_by=created_by,
194
+ )
195
+ parent_id = node.id
196
+ return node
197
+
198
+
199
  def rename_node(
200
  db: Session,
201
  node_id: int,
app/main.py CHANGED
@@ -16,7 +16,7 @@ except ImportError:
16
  from jose import JWTError, jwt
17
  from sqlalchemy import text as sa_text
18
  from fastapi import FastAPI, Depends, File, Form, HTTPException, Query, Request, UploadFile
19
- from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
20
  from fastapi.staticfiles import StaticFiles
21
  from starlette.exceptions import HTTPException as StarletteHTTPException
22
  from fastapi.exception_handlers import http_exception_handler as starlette_http_exception_handler
@@ -51,6 +51,7 @@ from .auth import (
51
  mask_email,
52
  normalize_phone,
53
  record_login_event,
 
54
  verify_login_otp,
55
  verify_password,
56
  )
@@ -116,21 +117,26 @@ app = FastAPI(title="AI Change Detection", version="2.4.0-dda" if IS_DDA_MODE el
116
  setup_dda(app)
117
 
118
 
119
- def _exception_log_user_email(request: Request) -> str | None:
120
- """Best-effort user lookup for the exception log — never raises."""
121
  try:
122
  from .database import SessionLocal
 
123
  token = request.cookies.get("access_token") or request.cookies.get(COOKIE_NAME)
124
  if not token:
125
- return None
126
  db = SessionLocal()
127
  try:
128
  user = get_user_from_token(token, db)
129
- return user.email if user else None
 
 
 
 
130
  finally:
131
  db.close()
132
  except Exception:
133
- return None
134
 
135
 
136
  @app.exception_handler(StarletteHTTPException)
@@ -142,10 +148,11 @@ async def _dda_http_exception_handler(request: Request, exc: StarletteHTTPExcept
142
  from .dda.tree.audit_service import log_exception
143
  db = SessionLocal()
144
  try:
 
145
  log_exception(
146
  db, exc=exc, path=request.url.path, method=request.method,
147
  status_code=exc.status_code, handled=True,
148
- user_email=_exception_log_user_email(request),
149
  )
150
  finally:
151
  db.close()
@@ -164,10 +171,11 @@ async def _dda_unhandled_exception_handler(request: Request, exc: Exception):
164
  from .dda.tree.audit_service import log_exception
165
  db = SessionLocal()
166
  try:
 
167
  log_exception(
168
  db, exc=exc, path=request.url.path, method=request.method,
169
  status_code=500, handled=False,
170
- user_email=_exception_log_user_email(request),
171
  )
172
  finally:
173
  db.close()
@@ -302,6 +310,18 @@ THUMB_MAX_SIZE = 200 # max width or height for history thumbnails
302
  if STATIC_DIR.exists():
303
  app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
304
 
 
 
 
 
 
 
 
 
 
 
 
 
305
  templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
306
 
307
 
@@ -467,7 +487,7 @@ def login(data: UserLogin, request: Request, db: Session = Depends(get_db)):
467
  user_id=user.id, ip_address=ip, user_agent=ua,
468
  )
469
  expires = REMEMBER_ME_EXPIRE if data.remember_me else SESSION_TOKEN_EXPIRE
470
- token = create_access_token(data={"sub": str(user.id), "purpose": "session"}, expires_delta=expires)
471
  return _auth_response(token, user, remember=data.remember_me)
472
  except HTTPException:
473
  raise
@@ -508,7 +528,8 @@ def request_login_otp(data: OtpRequest, request: Request, db: Session = Depends(
508
 
509
  @app.post("/api/auth/logout")
510
  def logout(request: Request, db: Session = Depends(get_db)):
511
- """Record the logout, then clear the auth cookie so subsequent requests are unauthenticated."""
 
512
  token = request.cookies.get(COOKIE_NAME)
513
  if token:
514
  user = get_user_from_token(token, db)
@@ -517,6 +538,8 @@ def logout(request: Request, db: Session = Depends(get_db)):
517
  db, event_type="logout", attempted_email=user.email, user_id=user.id,
518
  ip_address=get_client_ip(request), user_agent=get_user_agent(request),
519
  )
 
 
520
  response = JSONResponse(content={"ok": True})
521
  response.delete_cookie(
522
  COOKIE_NAME,
@@ -541,6 +564,7 @@ def reset_password(data: PasswordReset, db: Session = Depends(get_db)):
541
  # Intentionally vague to prevent email enumeration
542
  raise HTTPException(status_code=404, detail="No account found with that email")
543
  user.hashed_password = get_password_hash(data.new_password)
 
544
  db.commit()
545
  return {"ok": True, "message": "Password has been reset. You can now sign in."}
546
 
@@ -585,6 +609,7 @@ def reset_password_with_token(data: ResetPasswordWithToken, db: Session = Depend
585
  if not user:
586
  raise HTTPException(status_code=400, detail="This reset link is invalid or has expired.")
587
  user.hashed_password = get_password_hash(data.new_password)
 
588
  db.commit()
589
  return {"ok": True, "message": "Password has been reset. You can now sign in."}
590
 
@@ -648,6 +673,7 @@ def change_password(data: ChangePasswordRequest, user: Optional[User] = Depends(
648
  if len(data.new_password) < 8:
649
  raise HTTPException(status_code=400, detail="New password must be at least 8 characters")
650
  user.hashed_password = get_password_hash(data.new_password)
 
651
  db.commit()
652
  return {"ok": True, "message": "Password changed."}
653
 
@@ -726,7 +752,7 @@ def verify_login_otp_route(data: OtpVerifyRequest, request: Request, db: Session
726
  user_id=user.id, ip_address=ip, user_agent=ua,
727
  )
728
  expires = REMEMBER_ME_EXPIRE if remember else SESSION_TOKEN_EXPIRE
729
- token = create_access_token(data={"sub": str(user.id), "purpose": "session"}, expires_delta=expires)
730
  return _auth_response(token, user, remember=remember)
731
 
732
 
@@ -810,9 +836,8 @@ async def detect(
810
 
811
  before_pil = _read_upload(before, "before")
812
  after_pil = _read_upload(after, "after")
813
- detection_sensitivity = max(0.0, min(1.0, float(detection_sensitivity)))
814
- if min_region_area is not None:
815
- min_region_area = int(max(50, min(10000, min_region_area)))
816
 
817
  from .detection_engine import run_detection
818
  change_mask, result_image, stats, change_regions = run_detection(
@@ -897,6 +922,8 @@ async def detect(
897
  before_thumb_path=relative_before_thumb,
898
  after_thumb_path=relative_after_thumb,
899
  regions_json=json.dumps(regions_serializable),
 
 
900
  )
901
  db.add(run)
902
  db.commit()
@@ -966,7 +993,12 @@ def notify_test(
966
 
967
 
968
  @app.get("/api/overlay/{path:path}")
969
- def serve_overlay(path: str, request: Request, db: Session = Depends(get_db)):
 
 
 
 
 
970
  # Restrict to overlays directory
971
  full = (OVERLAYS_DIR.parent / path).resolve()
972
  base = OVERLAYS_DIR.parent.resolve()
@@ -1014,6 +1046,32 @@ def serve_overlay(path: str, request: Request, db: Session = Depends(get_db)):
1014
  if owner_id_str.isdigit() and int(owner_id_str) != user.id:
1015
  raise HTTPException(404)
1016
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1017
  return FileResponse(full, media_type="image/png")
1018
 
1019
 
@@ -1023,6 +1081,8 @@ def history(
1023
  request: Request,
1024
  db: Session = Depends(get_db),
1025
  limit: int = Query(100, ge=1, le=200),
 
 
1026
  ):
1027
  if IS_DDA_MODE:
1028
  from .dda.dda_auth import get_dda_user
@@ -1042,11 +1102,13 @@ def history(
1042
  filt = DetectionRun.user_id == user.id
1043
  if auto_ids:
1044
  filt = or_(filt, DetectionRun.id.in_(auto_ids))
1045
- runs = db.query(DetectionRun).filter(filt).order_by(DetectionRun.created_at.desc()).limit(limit).all()
1046
  else:
1047
  user = get_or_create_guest_user(db)
1048
- runs = db.query(DetectionRun).filter(DetectionRun.user_id == user.id).order_by(DetectionRun.created_at.desc()).limit(limit).all()
1049
- return [
 
 
1050
  {
1051
  "id": r.id,
1052
  "title": r.title,
@@ -1064,6 +1126,9 @@ def history(
1064
  }
1065
  for r in runs
1066
  ]
 
 
 
1067
 
1068
 
1069
  @app.get("/api/history/{run_id}")
@@ -1204,16 +1269,18 @@ def delete_run(
1204
  # --- Serve SPA ---
1205
  @app.get("/dda/reports/{run_id}", response_class=HTMLResponse)
1206
  def dda_report_page(run_id: int, request: Request, db: Session = Depends(get_db)):
1207
- """Standalone browser report (FR-05)."""
1208
  if not IS_DDA_MODE:
1209
  raise HTTPException(status_code=404, detail="Not found")
1210
  _user, redirect = _dda_session_or_redirect(request, db)
1211
  if redirect:
1212
  return redirect
1213
- report_file = TEMPLATES_DIR / "report_dda.html"
1214
- if not report_file.exists():
1215
- raise HTTPException(status_code=404, detail="Report template missing")
1216
- return FileResponse(report_file)
 
 
1217
 
1218
 
1219
  # no-store on every auth page below: FileResponse otherwise sends
 
16
  from jose import JWTError, jwt
17
  from sqlalchemy import text as sa_text
18
  from fastapi import FastAPI, Depends, File, Form, HTTPException, Query, Request, UploadFile
19
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response
20
  from fastapi.staticfiles import StaticFiles
21
  from starlette.exceptions import HTTPException as StarletteHTTPException
22
  from fastapi.exception_handlers import http_exception_handler as starlette_http_exception_handler
 
51
  mask_email,
52
  normalize_phone,
53
  record_login_event,
54
+ revoke_user_sessions,
55
  verify_login_otp,
56
  verify_password,
57
  )
 
117
  setup_dda(app)
118
 
119
 
120
+ def _exception_log_user(request: Request) -> tuple[str | None, str | None]:
121
+ """Best-effort user email + role for the exception log — never raises."""
122
  try:
123
  from .database import SessionLocal
124
+ from .dda.dda_auth import get_user_role
125
  token = request.cookies.get("access_token") or request.cookies.get(COOKIE_NAME)
126
  if not token:
127
+ return None, None
128
  db = SessionLocal()
129
  try:
130
  user = get_user_from_token(token, db)
131
+ if not user:
132
+ return None, None
133
+ email = user.email if user else None
134
+ role = get_user_role(db, user) if user else None
135
+ return email, role
136
  finally:
137
  db.close()
138
  except Exception:
139
+ return None, None
140
 
141
 
142
  @app.exception_handler(StarletteHTTPException)
 
148
  from .dda.tree.audit_service import log_exception
149
  db = SessionLocal()
150
  try:
151
+ email, role = _exception_log_user(request)
152
  log_exception(
153
  db, exc=exc, path=request.url.path, method=request.method,
154
  status_code=exc.status_code, handled=True,
155
+ user_email=email, user_role=role,
156
  )
157
  finally:
158
  db.close()
 
171
  from .dda.tree.audit_service import log_exception
172
  db = SessionLocal()
173
  try:
174
+ email, role = _exception_log_user(request)
175
  log_exception(
176
  db, exc=exc, path=request.url.path, method=request.method,
177
  status_code=500, handled=False,
178
+ user_email=email, user_role=role,
179
  )
180
  finally:
181
  db.close()
 
310
  if STATIC_DIR.exists():
311
  app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
312
 
313
+
314
+ @app.middleware("http")
315
+ async def _static_cache_control(request: Request, call_next):
316
+ """Long-lived cache for versioned /static assets (?v= busting on script tags)."""
317
+ response = await call_next(request)
318
+ if request.url.path.startswith("/static/"):
319
+ response.headers.setdefault(
320
+ "Cache-Control",
321
+ "public, max-age=31536000, immutable",
322
+ )
323
+ return response
324
+
325
  templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
326
 
327
 
 
487
  user_id=user.id, ip_address=ip, user_agent=ua,
488
  )
489
  expires = REMEMBER_ME_EXPIRE if data.remember_me else SESSION_TOKEN_EXPIRE
490
+ token = create_access_token(data={"sub": str(user.id), "purpose": "session", "tv": int(user.token_version or 0)}, expires_delta=expires)
491
  return _auth_response(token, user, remember=data.remember_me)
492
  except HTTPException:
493
  raise
 
528
 
529
  @app.post("/api/auth/logout")
530
  def logout(request: Request, db: Session = Depends(get_db)):
531
+ """Record the logout, revoke the session token (and any other copy of it —
532
+ another tab, another device, or a leaked token), then clear the cookie."""
533
  token = request.cookies.get(COOKIE_NAME)
534
  if token:
535
  user = get_user_from_token(token, db)
 
538
  db, event_type="logout", attempted_email=user.email, user_id=user.id,
539
  ip_address=get_client_ip(request), user_agent=get_user_agent(request),
540
  )
541
+ revoke_user_sessions(user)
542
+ db.commit()
543
  response = JSONResponse(content={"ok": True})
544
  response.delete_cookie(
545
  COOKIE_NAME,
 
564
  # Intentionally vague to prevent email enumeration
565
  raise HTTPException(status_code=404, detail="No account found with that email")
566
  user.hashed_password = get_password_hash(data.new_password)
567
+ revoke_user_sessions(user)
568
  db.commit()
569
  return {"ok": True, "message": "Password has been reset. You can now sign in."}
570
 
 
609
  if not user:
610
  raise HTTPException(status_code=400, detail="This reset link is invalid or has expired.")
611
  user.hashed_password = get_password_hash(data.new_password)
612
+ revoke_user_sessions(user)
613
  db.commit()
614
  return {"ok": True, "message": "Password has been reset. You can now sign in."}
615
 
 
673
  if len(data.new_password) < 8:
674
  raise HTTPException(status_code=400, detail="New password must be at least 8 characters")
675
  user.hashed_password = get_password_hash(data.new_password)
676
+ revoke_user_sessions(user)
677
  db.commit()
678
  return {"ok": True, "message": "Password changed."}
679
 
 
752
  user_id=user.id, ip_address=ip, user_agent=ua,
753
  )
754
  expires = REMEMBER_ME_EXPIRE if remember else SESSION_TOKEN_EXPIRE
755
+ token = create_access_token(data={"sub": str(user.id), "purpose": "session", "tv": int(user.token_version or 0)}, expires_delta=expires)
756
  return _auth_response(token, user, remember=remember)
757
 
758
 
 
836
 
837
  before_pil = _read_upload(before, "before")
838
  after_pil = _read_upload(after, "after")
839
+ from .dda.detect_service import clamp_detection_params
840
+ detection_sensitivity, min_region_area = clamp_detection_params(detection_sensitivity, min_region_area)
 
841
 
842
  from .detection_engine import run_detection
843
  change_mask, result_image, stats, change_regions = run_detection(
 
922
  before_thumb_path=relative_before_thumb,
923
  after_thumb_path=relative_after_thumb,
924
  regions_json=json.dumps(regions_serializable),
925
+ detection_sensitivity=detection_sensitivity,
926
+ min_region_area=min_region_area,
927
  )
928
  db.add(run)
929
  db.commit()
 
993
 
994
 
995
  @app.get("/api/overlay/{path:path}")
996
+ def serve_overlay(
997
+ path: str,
998
+ request: Request,
999
+ db: Session = Depends(get_db),
1000
+ max_side: int | None = Query(None, ge=64, le=4096),
1001
+ ):
1002
  # Restrict to overlays directory
1003
  full = (OVERLAYS_DIR.parent / path).resolve()
1004
  base = OVERLAYS_DIR.parent.resolve()
 
1046
  if owner_id_str.isdigit() and int(owner_id_str) != user.id:
1047
  raise HTTPException(404)
1048
 
1049
+ # Browser viewers choke on ~50MB full overlays; downscale + cache when requested.
1050
+ if max_side:
1051
+ cache = full.with_name(f"{full.stem}_ms{max_side}{full.suffix}")
1052
+ try:
1053
+ if cache.exists() and cache.stat().st_mtime >= full.stat().st_mtime:
1054
+ return FileResponse(cache, media_type="image/png")
1055
+ from PIL import Image
1056
+ with Image.open(full) as im:
1057
+ if im.mode not in ("RGB", "RGBA"):
1058
+ im = im.convert("RGBA")
1059
+ w, h = im.size
1060
+ longest = max(w, h)
1061
+ if longest > max_side:
1062
+ scale = max_side / float(longest)
1063
+ im = im.resize((max(1, int(w * scale)), max(1, int(h * scale))), Image.Resampling.LANCZOS)
1064
+ buf = io.BytesIO()
1065
+ im.save(buf, format="PNG", optimize=True)
1066
+ payload = buf.getvalue()
1067
+ try:
1068
+ cache.write_bytes(payload)
1069
+ except Exception:
1070
+ pass
1071
+ return Response(content=payload, media_type="image/png")
1072
+ except Exception:
1073
+ pass
1074
+
1075
  return FileResponse(full, media_type="image/png")
1076
 
1077
 
 
1081
  request: Request,
1082
  db: Session = Depends(get_db),
1083
  limit: int = Query(100, ge=1, le=200),
1084
+ offset: int = Query(0, ge=0),
1085
+ paged: int = Query(0, ge=0, le=1),
1086
  ):
1087
  if IS_DDA_MODE:
1088
  from .dda.dda_auth import get_dda_user
 
1102
  filt = DetectionRun.user_id == user.id
1103
  if auto_ids:
1104
  filt = or_(filt, DetectionRun.id.in_(auto_ids))
1105
+ query = db.query(DetectionRun).filter(filt)
1106
  else:
1107
  user = get_or_create_guest_user(db)
1108
+ query = db.query(DetectionRun).filter(DetectionRun.user_id == user.id)
1109
+ total = query.count() if paged else None
1110
+ runs = query.order_by(DetectionRun.created_at.desc()).offset(offset).limit(limit).all()
1111
+ items = [
1112
  {
1113
  "id": r.id,
1114
  "title": r.title,
 
1126
  }
1127
  for r in runs
1128
  ]
1129
+ if paged:
1130
+ return {"items": items, "total": int(total or 0), "offset": offset, "limit": limit}
1131
+ return items
1132
 
1133
 
1134
  @app.get("/api/history/{run_id}")
 
1269
  # --- Serve SPA ---
1270
  @app.get("/dda/reports/{run_id}", response_class=HTMLResponse)
1271
  def dda_report_page(run_id: int, request: Request, db: Session = Depends(get_db)):
1272
+ """Standalone browser report (FR-05) with interactive viewer."""
1273
  if not IS_DDA_MODE:
1274
  raise HTTPException(status_code=404, detail="Not found")
1275
  _user, redirect = _dda_session_or_redirect(request, db)
1276
  if redirect:
1277
  return redirect
1278
+ return templates.TemplateResponse(
1279
+ request,
1280
+ "report_dda.html",
1281
+ {"run_id": run_id, "active_page": "reports", "menu_items": []},
1282
+ headers={"Cache-Control": "no-store"},
1283
+ )
1284
 
1285
 
1286
  # no-store on every auth page below: FileResponse otherwise sends
app/models.py CHANGED
@@ -29,6 +29,11 @@ class User(Base):
29
  # accounts can bind a number on first successful OTP verify.
30
  phone = Column(String(20), unique=True, nullable=True, index=True)
31
  created_at = Column(DateTime, default=_utcnow)
 
 
 
 
 
32
 
33
  detections = relationship("DetectionRun", back_populates="user", order_by="desc(DetectionRun.created_at)")
34
  role_obj = relationship("Role", foreign_keys=[role_id])
@@ -53,6 +58,11 @@ class DetectionRun(Base):
53
  zone = Column(String(128), default="")
54
  village = Column(String(128), default="")
55
  regions_json = Column(Text, default="[]")
 
 
 
 
 
56
  created_at = Column(DateTime, default=_utcnow)
57
 
58
  user = relationship("User", back_populates="detections")
 
29
  # accounts can bind a number on first successful OTP verify.
30
  phone = Column(String(20), unique=True, nullable=True, index=True)
31
  created_at = Column(DateTime, default=_utcnow)
32
+ # Bumped on logout and on any password change/reset so previously-issued
33
+ # JWTs (a copy in another tab, or a leaked token) stop authenticating
34
+ # immediately instead of staying valid until natural expiry — see
35
+ # create_access_token()/get_user_from_token() in auth.py.
36
+ token_version = Column(Integer, default=0, nullable=False)
37
 
38
  detections = relationship("DetectionRun", back_populates="user", order_by="desc(DetectionRun.created_at)")
39
  role_obj = relationship("Role", foreign_keys=[role_id])
 
58
  zone = Column(String(128), default="")
59
  village = Column(String(128), default="")
60
  regions_json = Column(Text, default="[]")
61
+ # The Sensitivity / Minimum Region Area values actually used for this run
62
+ # (post-clamp — see detect_service.clamp_detection_params) — nullable so
63
+ # runs created before this column existed just show as blank, not 0.
64
+ detection_sensitivity = Column(Float, nullable=True)
65
+ min_region_area = Column(Integer, nullable=True)
66
  created_at = Column(DateTime, default=_utcnow)
67
 
68
  user = relationship("User", back_populates="detections")
requirements.txt CHANGED
@@ -24,3 +24,4 @@ reportlab>=4.0.0
24
  email-validator>=2.0.0
25
  pyotp>=2.9.0
26
  qrcode>=7.4.0
 
 
24
  email-validator>=2.0.0
25
  pyotp>=2.9.0
26
  qrcode>=7.4.0
27
+ openpyxl>=3.1.0
scripts/test_area_groups.py CHANGED
@@ -1,4 +1,4 @@
1
- """Same-area pairing: hard keys must not be merged by overlapping bounds."""
2
  import sys
3
  from datetime import datetime
4
  from pathlib import Path
@@ -6,11 +6,25 @@ from pathlib import Path
6
  sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
7
 
8
  from app.dda.tree.area_groups import (
 
9
  build_area_groups,
 
10
  distinct_places,
11
  parse_datetime,
12
  )
13
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
  def test_mysql_datetime_and_tiff_strings_parse():
16
  assert parse_datetime("2025-03-01 14:30:00") == datetime(2025, 3, 1, 14, 30, 0)
@@ -19,12 +33,11 @@ def test_mysql_datetime_and_tiff_strings_parse():
19
 
20
 
21
  def test_seq_pairs_stay_separate_despite_overlap():
22
- bounds = {"west": 77.0, "south": 28.0, "east": 77.2, "north": 28.2}
23
  images = [
24
- {"id": 1, "filename": "before3.tif", "path": "a/before3.tif", "captureDate": "2025-07-24", "bounds": bounds},
25
- {"id": 2, "filename": "after3.tif", "path": "a/after3.tif", "captureDate": "2026-07-31", "bounds": bounds},
26
- {"id": 3, "filename": "before7.tif", "path": "a/before7.tif", "captureDate": "2026-07-31", "bounds": bounds},
27
- {"id": 4, "filename": "after7.tif", "path": "a/after7.tif", "captureDate": "2026-07-31", "bounds": bounds},
28
  ]
29
  payload = build_area_groups(images)
30
  labels = sorted(g["label"] for g in payload["groups"])
@@ -58,9 +71,212 @@ def test_distinct_places_grid_vs_sheet():
58
  assert distinct_places({"seq:1"}, set()) is False
59
 
60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  if __name__ == "__main__":
62
- test_mysql_datetime_and_tiff_strings_parse()
63
- test_seq_pairs_stay_separate_despite_overlap()
64
- test_filename_iso_date_orders_before_after()
65
- test_distinct_places_grid_vs_sheet()
66
- print("ok")
 
1
+ """Same-area pairing: metadata comparison, exactly two images, no reuse."""
2
  import sys
3
  from datetime import datetime
4
  from pathlib import Path
 
6
  sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
7
 
8
  from app.dda.tree.area_groups import (
9
+ LocationIndex,
10
  build_area_groups,
11
+ conflicting_keys,
12
  distinct_places,
13
  parse_datetime,
14
  )
15
 
16
+ DELHI = LocationIndex(
17
+ zones=[(1, "Central Delhi"), (2, "North Delhi")],
18
+ villages=[(5, "Daryaganj", 1), (6, "Paharganj", 1), (7, "Model Town", 1), (8, "Model Town", 2)],
19
+ )
20
+
21
+ BOUNDS = {"west": 77.0, "south": 28.0, "east": 77.2, "north": 28.2}
22
+ ELSEWHERE = {"west": 78.0, "south": 29.0, "east": 78.2, "north": 29.2}
23
+
24
+
25
+ def _paths(group):
26
+ return [img["path"] for img in group["images"]]
27
+
28
 
29
  def test_mysql_datetime_and_tiff_strings_parse():
30
  assert parse_datetime("2025-03-01 14:30:00") == datetime(2025, 3, 1, 14, 30, 0)
 
33
 
34
 
35
  def test_seq_pairs_stay_separate_despite_overlap():
 
36
  images = [
37
+ {"id": 1, "filename": "before3.tif", "path": "a/before3.tif", "captureDate": "2025-07-24", "bounds": BOUNDS},
38
+ {"id": 2, "filename": "after3.tif", "path": "a/after3.tif", "captureDate": "2026-07-31", "bounds": BOUNDS},
39
+ {"id": 3, "filename": "before7.tif", "path": "a/before7.tif", "captureDate": "2026-07-31", "bounds": BOUNDS},
40
+ {"id": 4, "filename": "after7.tif", "path": "a/after7.tif", "captureDate": "2026-07-31", "bounds": BOUNDS},
41
  ]
42
  payload = build_area_groups(images)
43
  labels = sorted(g["label"] for g in payload["groups"])
 
71
  assert distinct_places({"seq:1"}, set()) is False
72
 
73
 
74
+ def test_conflicting_keys_only_within_one_vocabulary():
75
+ # Same vocabulary, different value: never the same place.
76
+ assert conflicting_keys({"seq:3"}, {"seq:7"}) is True
77
+ assert conflicting_keys({"grid:54"}, {"grid:60"}) is True
78
+ # Different vocabularies cannot contradict each other.
79
+ assert conflicting_keys({"grid:54"}, {"sheet:h43x2e1"}) is False
80
+ assert conflicting_keys({"grid:54"}, set()) is False
81
+
82
+
83
+ def test_strong_overlap_beats_mismatched_naming_schemes():
84
+ """A grid number and a survey sheet id can name the same place."""
85
+ images = [
86
+ {"id": 1, "filename": "Grid_54.tif", "path": "a/Grid_54.tif", "captureDate": "2025-06-11", "bounds": BOUNDS},
87
+ {"id": 2, "filename": "H43X2E1.tif", "path": "a/H43X2E1.tif", "captureDate": "2025-06-27", "bounds": BOUNDS},
88
+ ]
89
+ payload = build_area_groups(images)
90
+ assert len(payload["groups"]) == 1
91
+ assert payload["groups"][0]["match"] == "bounds"
92
+ assert payload["unpaired"] == []
93
+
94
+
95
+ def test_georeferenced_images_far_apart_never_pair():
96
+ images = [
97
+ {"id": 1, "filename": "grid_9_before.tif", "path": "a/grid_9_before.tif", "captureDate": "2025-01-01", "bounds": BOUNDS},
98
+ {"id": 2, "filename": "grid_9_after.tif", "path": "a/grid_9_after.tif", "captureDate": "2026-01-01", "bounds": ELSEWHERE},
99
+ ]
100
+ payload = build_area_groups(images)
101
+ assert payload["groups"] == []
102
+ assert len(payload["unpaired"]) == 2
103
+
104
+
105
+ def test_shared_location_cannot_rescue_a_weak_overlap():
106
+ """When both footprints are known, the map decides, not the folder."""
107
+ barely = {"west": 77.19, "south": 28.19, "east": 77.40, "north": 28.40}
108
+ images = [
109
+ {"id": 1, "filename": "B2_ORI.tif", "path": "a/B2_ORI.tif", "nodeId": 1,
110
+ "nodePath": "Central Delhi/Daryaganj", "captureDate": "2025-01-01", "bounds": BOUNDS},
111
+ {"id": 2, "filename": "B3_ORI_CLIP.tif", "path": "a/B3_ORI_CLIP.tif", "nodeId": 1,
112
+ "nodePath": "Central Delhi/Daryaganj", "captureDate": "2026-01-01", "bounds": barely},
113
+ ]
114
+ assert build_area_groups(images, DELHI)["groups"] == []
115
+
116
+
117
+ def test_folder_location_cannot_veto_a_clear_overlap():
118
+ """A file simply parked under 'Unassigned' still pairs with its own place."""
119
+ images = [
120
+ {"id": 1, "filename": "H43X2E2.tif", "path": "a/H43X2E2.tif",
121
+ "nodePath": "Central Delhi/Daryaganj", "captureDate": "2025-01-01", "bounds": BOUNDS},
122
+ {"id": 2, "filename": "H43X2E2_ORI.tif", "path": "b/H43X2E2_ORI.tif",
123
+ "nodePath": "Unassigned/Legacy/2025", "captureDate": "2026-01-01", "bounds": BOUNDS},
124
+ ]
125
+ assert len(build_area_groups(images, DELHI)["groups"]) == 1
126
+
127
+
128
+ def test_different_villages_never_pair():
129
+ images = [
130
+ {"id": 1, "filename": "site.tif", "path": "a/site.tif", "captureDate": "2025-01-01",
131
+ "bounds": BOUNDS, "zoneId": 1, "villageId": 5, "villageName": "Karol Bagh"},
132
+ {"id": 2, "filename": "site_new.tif", "path": "a/site_new.tif", "captureDate": "2026-01-01",
133
+ "bounds": BOUNDS, "zoneId": 1, "villageId": 6, "villageName": "Paharganj"},
134
+ ]
135
+ payload = build_area_groups(images)
136
+ assert payload["groups"] == []
137
+
138
+
139
+ def test_village_pairs_images_that_have_no_map_extent():
140
+ images = [
141
+ {"id": 1, "filename": "scan_a.png", "path": "a/scan_a.png", "captureDate": "2025-01-01",
142
+ "bounds": None, "zoneId": 1, "villageId": 5, "villageName": "Karol Bagh", "zoneName": "Central Delhi"},
143
+ {"id": 2, "filename": "scan_b.png", "path": "a/scan_b.png", "captureDate": "2026-01-01",
144
+ "bounds": None, "zoneId": 1, "villageId": 5, "villageName": "Karol Bagh", "zoneName": "Central Delhi"},
145
+ ]
146
+ payload = build_area_groups(images)
147
+ assert len(payload["groups"]) == 1
148
+ g = payload["groups"][0]
149
+ assert g["match"] == "location"
150
+ assert any("Karol Bagh" in reason for reason in g["reasons"])
151
+
152
+
153
+ def test_unrelated_images_in_one_folder_do_not_pair():
154
+ """Folder and capture date alone are not evidence of the same place."""
155
+ images = [
156
+ {"id": 1, "filename": "alpha.png", "path": "a/alpha.png", "nodeId": 1,
157
+ "nodePath": "Delhi", "captureDate": "2025-01-01", "bounds": None},
158
+ {"id": 2, "filename": "omega.png", "path": "a/omega.png", "nodeId": 1,
159
+ "nodePath": "Delhi", "captureDate": "2026-01-01", "bounds": None},
160
+ ]
161
+ payload = build_area_groups(images)
162
+ assert payload["groups"] == []
163
+ assert len(payload["unpaired"]) == 2
164
+
165
+
166
+ def test_pairs_hold_two_images_and_never_reuse_one():
167
+ """Five dates of one place become two pairs plus one leftover."""
168
+ images = [
169
+ {"id": i, "filename": f"Grid_54_{2021 + i}.tif", "path": f"a/Grid_54_{2021 + i}.tif",
170
+ "nodeId": 1, "nodePath": "Delhi", "captureDate": f"{2021 + i}-04-01", "bounds": BOUNDS}
171
+ for i in range(5)
172
+ ]
173
+ payload = build_area_groups(images)
174
+ assert len(payload["groups"]) == 2
175
+ assert payload["pairedImages"] == 4
176
+ assert len(payload["unpaired"]) == 1
177
+
178
+ seen = set()
179
+ for group in payload["groups"]:
180
+ assert len(group["images"]) == 2
181
+ for path in _paths(group):
182
+ assert path not in seen, f"{path} was used by two pairs"
183
+ seen.add(path)
184
+ ids = [g["id"] for g in payload["groups"]]
185
+ assert len(ids) == len(set(ids))
186
+ # The whole cluster stays available for a manual swap.
187
+ assert len(payload["groups"][0]["candidates"]) == 5
188
+
189
+
190
+ def test_folder_names_supply_the_location():
191
+ """Images indexed from disk get their zone/village from the folder tree."""
192
+ images = [
193
+ {"id": 1, "filename": "scan_a.png", "path": "a/scan_a.png", "captureDate": "2025-01-01",
194
+ "bounds": None, "nodePath": "Central Delhi/Daryaganj"},
195
+ {"id": 2, "filename": "scan_b.png", "path": "b/scan_b.png", "captureDate": "2026-01-01",
196
+ "bounds": None, "nodePath": "Central Delhi/Daryaganj"},
197
+ ]
198
+ payload = build_area_groups(images, DELHI)
199
+ assert len(payload["groups"]) == 1
200
+ g = payload["groups"][0]
201
+ assert any("Daryaganj" in reason for reason in g["reasons"])
202
+ assert g["images"][0]["villageName"] == "Daryaganj"
203
+ # Without the masters there is no location to compare, so nothing pairs.
204
+ assert build_area_groups(images)["groups"] == []
205
+
206
+
207
+ def test_folder_location_never_crosses_zones():
208
+ images = [
209
+ {"id": 1, "filename": "scan_a.png", "path": "a/scan_a.png", "captureDate": "2025-01-01",
210
+ "bounds": None, "nodePath": "Central Delhi/Daryaganj"},
211
+ {"id": 2, "filename": "scan_b.png", "path": "b/scan_b.png", "captureDate": "2026-01-01",
212
+ "bounds": None, "nodePath": "North Delhi/Model Town"},
213
+ ]
214
+ assert build_area_groups(images, DELHI)["groups"] == []
215
+
216
+
217
+ def test_ambiguous_village_name_is_left_unresolved():
218
+ """'Model Town' exists in two zones, so the folder alone proves nothing."""
219
+ images = [
220
+ {"id": 1, "filename": "scan_a.png", "path": "a/scan_a.png", "captureDate": "2025-01-01",
221
+ "bounds": None, "nodePath": "Model Town"},
222
+ {"id": 2, "filename": "scan_b.png", "path": "b/scan_b.png", "captureDate": "2026-01-01",
223
+ "bounds": None, "nodePath": "Model Town"},
224
+ ]
225
+ assert build_area_groups(images, DELHI)["groups"] == []
226
+
227
+
228
+ def test_named_before_after_wins_over_same_day_timestamps():
229
+ """An 'after' uploaded a minute earlier must not become the Before image."""
230
+ images = [
231
+ {"id": 1, "filename": "after5.tif", "path": "a/after5.tif",
232
+ "captureDate": "2026-07-31T09:00:00", "bounds": BOUNDS},
233
+ {"id": 2, "filename": "before5.tif", "path": "a/before5.tif",
234
+ "captureDate": "2026-07-31T10:00:00", "bounds": BOUNDS},
235
+ ]
236
+ g = build_area_groups(images)["groups"][0]
237
+ assert g["suggestedBefore"]["filename"] == "before5.tif"
238
+ assert g["suggestedAfter"]["filename"] == "after5.tif"
239
+
240
+
241
+ def test_reasons_name_the_metadata_that_matched():
242
+ images = [
243
+ {"id": 1, "filename": "Grid_54.tif", "path": "Delhi/Grid_54.tif", "nodeId": 1,
244
+ "nodePath": "Delhi", "captureDate": "2024-01-01", "bounds": BOUNDS,
245
+ "zoneId": 1, "villageId": 5, "villageName": "Karol Bagh"},
246
+ {"id": 2, "filename": "Grid_54_new.tif", "path": "Delhi/Grid_54_new.tif", "nodeId": 1,
247
+ "nodePath": "Delhi", "captureDate": "2026-01-01", "bounds": BOUNDS,
248
+ "zoneId": 1, "villageId": 5, "villageName": "Karol Bagh"},
249
+ ]
250
+ reasons = build_area_groups(images)["groups"][0]["reasons"]
251
+ joined = " | ".join(reasons)
252
+ assert "map overlap" in joined
253
+ assert "grid 54" in joined
254
+ assert "Karol Bagh" in joined
255
+ assert "same folder" in joined
256
+ assert "apart" in joined
257
+
258
+
259
+ def test_three_images_become_one_pair_and_one_unpaired():
260
+ """Automatic pairing never emits a 3-image group."""
261
+ images = [
262
+ {"id": 1, "filename": "site_2024.png", "path": "a/site_2024.png",
263
+ "captureDate": "2024-01-01", "bounds": None, "zoneId": 1, "villageId": 5},
264
+ {"id": 2, "filename": "site_2025.png", "path": "a/site_2025.png",
265
+ "captureDate": "2025-01-01", "bounds": None, "zoneId": 1, "villageId": 5},
266
+ {"id": 3, "filename": "site_2026.png", "path": "a/site_2026.png",
267
+ "captureDate": "2026-01-01", "bounds": None, "zoneId": 1, "villageId": 5},
268
+ ]
269
+ payload = build_area_groups(images)
270
+ assert len(payload["groups"]) == 1
271
+ assert len(payload["groups"][0]["images"]) == 2
272
+ assert payload["pairedImages"] == 2
273
+ assert len(payload["unpaired"]) == 1
274
+ assert len(set(_paths(payload["groups"][0]))) == 2
275
+
276
+
277
  if __name__ == "__main__":
278
+ for name, fn in sorted(list(globals().items())):
279
+ if name.startswith("test_") and callable(fn):
280
+ fn()
281
+ print(f"ok {name}")
282
+ print("all ok")
static/css/dda-modernist-admin.css ADDED
@@ -0,0 +1,372 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ Modernist admin theme (Claude Design redesign) — applied to the
3
+ three RBAC configuration screens: Admin Menu, Admin Modules,
4
+ Roles & Users. Same visual language as the #tab-home dashboard
5
+ reskin in dda.css: flat surfaces, hairline dividers, sharp
6
+ corners, one accent color, Archivo for headings. Reuses the
7
+ shared --dm-* tokens declared at :root in dda.css — nothing is
8
+ redeclared here.
9
+
10
+ Scoping: every rule is qualified by one of the three page
11
+ container ids (#tab-admin-menu / #tab-admin-modules /
12
+ #tab-admin-roles-users) or, for the modal dialogs that live
13
+ outside those containers in the DOM, by the modal's own id
14
+ (each id below is unique to one of these three pages). Nothing
15
+ here touches the bare/global .card, .btn-primary, .table rules.
16
+ ============================================================ */
17
+
18
+ /* ---------------------------------------------------------------
19
+ Page heading
20
+ --------------------------------------------------------------- */
21
+ #tab-admin-menu .dda-hero h1,
22
+ #tab-admin-modules .dda-hero h1,
23
+ #tab-admin-roles-users .dda-hero h1 {
24
+ font-family: var(--dm-font);
25
+ font-weight: 800;
26
+ letter-spacing: -0.01em;
27
+ color: var(--dm-ink);
28
+ }
29
+
30
+ /* ---------------------------------------------------------------
31
+ Flat surfaces — cards and modal panels lose the soft shadow /
32
+ rounded corners in favor of a hairline border, same treatment
33
+ as the dashboard cards.
34
+ --------------------------------------------------------------- */
35
+ #tab-admin-menu .card,
36
+ #tab-admin-modules .card,
37
+ #tab-admin-roles-users .card,
38
+ #menu-item-modal .dda-modal-panel,
39
+ #module-modal .dda-modal-panel,
40
+ #module-view-modal .dda-modal-panel,
41
+ #role-modal .dda-modal-panel,
42
+ #perm-modal .dda-modal-panel,
43
+ #user-modal .dda-modal-panel {
44
+ background: var(--dm-surface);
45
+ border: 1px solid var(--dm-line);
46
+ border-radius: 0;
47
+ box-shadow: none;
48
+ }
49
+
50
+ #tab-admin-menu .card-header,
51
+ #tab-admin-modules .card-header,
52
+ #tab-admin-roles-users .card-header,
53
+ #menu-item-modal .card-header,
54
+ #module-modal .card-header,
55
+ #module-view-modal .card-header,
56
+ #role-modal .card-header,
57
+ #perm-modal .card-header,
58
+ #user-modal .card-header {
59
+ border-bottom: 1px solid var(--dm-line);
60
+ }
61
+
62
+ /* ---------------------------------------------------------------
63
+ Buttons — flat accent instead of the app-wide purple gradient,
64
+ sharp corners, Archivo weight. Scoped to these pages/modals only.
65
+ --------------------------------------------------------------- */
66
+ #tab-admin-menu .btn,
67
+ #tab-admin-modules .btn,
68
+ #tab-admin-roles-users .btn,
69
+ #menu-item-modal .btn,
70
+ #module-modal .btn,
71
+ #module-view-modal .btn,
72
+ #role-modal .btn,
73
+ #perm-modal .btn,
74
+ #user-modal .btn {
75
+ border-radius: 0;
76
+ font-family: var(--dm-font);
77
+ font-weight: 700;
78
+ }
79
+ #tab-admin-menu .btn-primary,
80
+ #tab-admin-modules .btn-primary,
81
+ #tab-admin-roles-users .btn-primary,
82
+ #menu-item-modal .btn-primary,
83
+ #module-modal .btn-primary,
84
+ #role-modal .btn-primary,
85
+ #perm-modal .btn-primary,
86
+ #user-modal .btn-primary {
87
+ background: var(--dm-accent);
88
+ }
89
+ #tab-admin-menu .btn-primary:hover,
90
+ #tab-admin-modules .btn-primary:hover,
91
+ #tab-admin-roles-users .btn-primary:hover,
92
+ #menu-item-modal .btn-primary:hover,
93
+ #module-modal .btn-primary:hover,
94
+ #role-modal .btn-primary:hover,
95
+ #perm-modal .btn-primary:hover,
96
+ #user-modal .btn-primary:hover {
97
+ background: var(--dm-accent-hover);
98
+ filter: none;
99
+ box-shadow: none;
100
+ }
101
+ #tab-admin-menu .btn-secondary,
102
+ #tab-admin-modules .btn-secondary,
103
+ #tab-admin-roles-users .btn-secondary,
104
+ #menu-item-modal .btn-secondary,
105
+ #module-modal .btn-secondary,
106
+ #module-view-modal .btn-secondary,
107
+ #role-modal .btn-secondary,
108
+ #perm-modal .btn-secondary,
109
+ #user-modal .btn-secondary {
110
+ background: transparent;
111
+ border-color: var(--dm-line);
112
+ color: var(--dm-ink);
113
+ }
114
+ #tab-admin-menu .btn-secondary:hover,
115
+ #tab-admin-modules .btn-secondary:hover,
116
+ #tab-admin-roles-users .btn-secondary:hover,
117
+ #menu-item-modal .btn-secondary:hover,
118
+ #module-modal .btn-secondary:hover,
119
+ #module-view-modal .btn-secondary:hover,
120
+ #role-modal .btn-secondary:hover,
121
+ #perm-modal .btn-secondary:hover,
122
+ #user-modal .btn-secondary:hover {
123
+ background: var(--dm-bg);
124
+ border-color: var(--dm-line);
125
+ }
126
+ #tab-admin-menu .btn-danger,
127
+ #tab-admin-modules .btn-danger,
128
+ #tab-admin-roles-users .btn-danger {
129
+ border-radius: 0;
130
+ }
131
+ #tab-admin-menu .btn-ghost,
132
+ #tab-admin-modules .btn-ghost,
133
+ #tab-admin-roles-users .btn-ghost {
134
+ color: var(--dm-accent);
135
+ }
136
+ #tab-admin-menu .btn-ghost:hover,
137
+ #tab-admin-modules .btn-ghost:hover,
138
+ #tab-admin-roles-users .btn-ghost:hover {
139
+ background: var(--dm-accent-100);
140
+ color: var(--dm-accent-hover);
141
+ }
142
+
143
+ /* ---------------------------------------------------------------
144
+ Status / system badges — sharpen the pill corners and switch to
145
+ the accent palette instead of green/red.
146
+ --------------------------------------------------------------- */
147
+ #tab-admin-menu .dda-status-badge,
148
+ #tab-admin-modules .dda-status-badge,
149
+ #tab-admin-roles-users .dda-status-badge {
150
+ border-radius: 2px;
151
+ }
152
+ #tab-admin-menu .dda-status-badge.is-active,
153
+ #tab-admin-modules .dda-status-badge.is-active,
154
+ #tab-admin-roles-users .dda-status-badge.is-active {
155
+ background: var(--dm-accent-100);
156
+ color: var(--dm-accent-hover);
157
+ }
158
+ #tab-admin-menu .dda-status-badge.is-inactive,
159
+ #tab-admin-modules .dda-status-badge.is-inactive,
160
+ #tab-admin-roles-users .dda-status-badge.is-inactive {
161
+ background: var(--dm-line);
162
+ color: var(--dm-ink-muted);
163
+ }
164
+ #tab-admin-roles-users .dda-badge-system {
165
+ border-radius: 2px;
166
+ border-color: var(--dm-line);
167
+ color: var(--dm-ink-muted);
168
+ }
169
+
170
+ /* ---------------------------------------------------------------
171
+ Search inputs / select toolbars — flat hairline fields.
172
+ --------------------------------------------------------------- */
173
+ #tab-admin-modules .dda-search,
174
+ #tab-admin-roles-users .dda-search,
175
+ #tab-admin-modules .dda-table-toolbar select,
176
+ #tab-admin-roles-users .dda-table-toolbar select {
177
+ border-radius: 0;
178
+ border: 1px solid var(--dm-line);
179
+ background: var(--dm-surface);
180
+ color: var(--dm-ink);
181
+ }
182
+ #tab-admin-modules .dda-table-toolbar,
183
+ #tab-admin-roles-users .dda-table-toolbar {
184
+ color: var(--dm-ink-muted);
185
+ }
186
+
187
+ /* ---------------------------------------------------------------
188
+ Tables — uppercase hairline headers, same technique as the
189
+ dashboard's .dda-reports-table override (reused verbatim here
190
+ since Modules/Users render into the same table class).
191
+ --------------------------------------------------------------- */
192
+ #tab-admin-modules .dda-reports-table th,
193
+ #tab-admin-roles-users .dda-reports-table th {
194
+ font-size: 0.68rem;
195
+ letter-spacing: 0.06em;
196
+ text-transform: uppercase;
197
+ color: var(--dm-ink-muted);
198
+ border: none;
199
+ border-bottom: 2px solid var(--dm-line);
200
+ background: transparent;
201
+ }
202
+ #tab-admin-modules .dda-reports-table td,
203
+ #tab-admin-roles-users .dda-reports-table td {
204
+ border: none;
205
+ border-bottom: 1px solid var(--dm-line);
206
+ }
207
+ #tab-admin-modules .dda-reports-table tbody tr:hover,
208
+ #tab-admin-roles-users .dda-reports-table tbody tr:hover {
209
+ background: var(--dm-bg);
210
+ }
211
+
212
+ #perm-modal .dda-perm-matrix th {
213
+ font-size: 0.68rem;
214
+ letter-spacing: 0.06em;
215
+ text-transform: uppercase;
216
+ color: var(--dm-ink-muted);
217
+ border: none;
218
+ border-bottom: 2px solid var(--dm-line);
219
+ background: transparent;
220
+ }
221
+ #perm-modal .dda-perm-matrix td {
222
+ border: none;
223
+ border-bottom: 1px solid var(--dm-line);
224
+ }
225
+ #perm-modal .dda-perm-matrix input[type="checkbox"],
226
+ #role-modal input[type="checkbox"],
227
+ #menu-item-modal input[type="checkbox"],
228
+ #user-modal input[type="checkbox"] {
229
+ accent-color: var(--dm-accent);
230
+ }
231
+
232
+ /* ---------------------------------------------------------------
233
+ Pagination — flat, sharp corners, accent active state instead
234
+ of the app-wide gradient pill.
235
+ --------------------------------------------------------------- */
236
+ #tab-admin-modules .pagination button,
237
+ #tab-admin-roles-users .pagination button {
238
+ border-radius: 0;
239
+ border-color: var(--dm-line);
240
+ }
241
+ #tab-admin-modules .pagination button:hover:not(:disabled),
242
+ #tab-admin-roles-users .pagination button:hover:not(:disabled) {
243
+ background: var(--dm-accent-100);
244
+ border-color: var(--dm-accent);
245
+ color: var(--dm-ink);
246
+ }
247
+ #tab-admin-modules .pagination button.active,
248
+ #tab-admin-roles-users .pagination button.active {
249
+ background: var(--dm-accent);
250
+ color: #fff;
251
+ border-color: var(--dm-accent);
252
+ }
253
+
254
+ /* ---------------------------------------------------------------
255
+ Admin Menu — menu item list rendered as a fused hairline list
256
+ (same "1px gap on a line-colored background" technique the
257
+ dashboard KPI grid uses) instead of individually bordered,
258
+ rounded, shadowed rows.
259
+ --------------------------------------------------------------- */
260
+ #tab-admin-menu .dda-admin-2col {
261
+ grid-template-columns: 1.4fr 1fr;
262
+ }
263
+ #tab-admin-menu .dda-menu-list {
264
+ gap: 1px;
265
+ background: var(--dm-line);
266
+ border: 1px solid var(--dm-line);
267
+ }
268
+ #tab-admin-menu .dda-menu-item-row {
269
+ border: none;
270
+ border-radius: 0;
271
+ background: var(--dm-surface);
272
+ }
273
+ #tab-admin-menu .dda-menu-item-row.is-child {
274
+ background: var(--dm-bg);
275
+ }
276
+ #tab-admin-menu .dda-menu-item-order button {
277
+ border-radius: 0;
278
+ border-color: var(--dm-line);
279
+ background: var(--dm-surface);
280
+ }
281
+ #tab-admin-menu .dda-menu-item-order button:hover:not(:disabled) {
282
+ color: var(--dm-accent);
283
+ border-color: var(--dm-accent);
284
+ }
285
+ #tab-admin-menu .dda-menu-item-meta code {
286
+ background: var(--dm-bg);
287
+ border-radius: 0;
288
+ }
289
+
290
+ /* Instructions panel */
291
+ #tab-admin-menu .dda-admin-side-panel code {
292
+ background: var(--dm-bg);
293
+ border-radius: 0;
294
+ color: var(--dm-accent);
295
+ }
296
+
297
+ /* Module Keys — rendered as a row of flat chips instead of a
298
+ stacked list, matching the mockup's tag row. */
299
+ #tab-admin-menu .dda-module-keys {
300
+ flex-direction: row;
301
+ flex-wrap: wrap;
302
+ gap: 0.4rem;
303
+ }
304
+ #tab-admin-menu .dda-module-keys code {
305
+ background: var(--dm-bg);
306
+ border: 1px solid var(--dm-line);
307
+ border-radius: 0;
308
+ color: var(--dm-ink);
309
+ align-self: auto;
310
+ }
311
+
312
+ /* ---------------------------------------------------------------
313
+ Roles & Users — role hierarchy as a fused hairline list, wider
314
+ ratio for the users column to match the mockup's proportions.
315
+ --------------------------------------------------------------- */
316
+ #tab-admin-roles-users .dda-admin-2col-roles {
317
+ grid-template-columns: 1fr 1.4fr;
318
+ }
319
+ #tab-admin-roles-users .dda-role-list {
320
+ gap: 1px;
321
+ background: var(--dm-line);
322
+ border: 1px solid var(--dm-line);
323
+ }
324
+ #tab-admin-roles-users .dda-role-card {
325
+ border: none;
326
+ border-radius: 0;
327
+ background: var(--dm-surface);
328
+ }
329
+ #tab-admin-roles-users .dda-role-card.is-inactive {
330
+ background: var(--dm-bg);
331
+ }
332
+
333
+ /* ---------------------------------------------------------------
334
+ Form fields inside these pages' modals — flat hairline inputs
335
+ instead of the app-wide rounded/shadowed style.
336
+ --------------------------------------------------------------- */
337
+ #menu-item-modal input[type="text"],
338
+ #menu-item-modal select,
339
+ #module-modal input[type="text"],
340
+ #module-modal select,
341
+ #role-modal input[type="text"],
342
+ #role-modal input[type="number"],
343
+ #user-modal input[type="text"],
344
+ #user-modal input[type="email"],
345
+ #user-modal input[type="password"],
346
+ #user-modal input[type="tel"],
347
+ #user-modal select {
348
+ border-radius: 0;
349
+ border-color: var(--dm-line);
350
+ }
351
+ #menu-item-modal input[type="text"]:focus,
352
+ #menu-item-modal select:focus,
353
+ #module-modal input[type="text"]:focus,
354
+ #module-modal select:focus,
355
+ #role-modal input[type="text"]:focus,
356
+ #role-modal input[type="number"]:focus,
357
+ #user-modal input[type="text"]:focus,
358
+ #user-modal input[type="email"]:focus,
359
+ #user-modal input[type="password"]:focus,
360
+ #user-modal input[type="tel"]:focus,
361
+ #user-modal select:focus {
362
+ border-color: var(--dm-accent);
363
+ box-shadow: 0 0 0 3px var(--dm-accent-100);
364
+ }
365
+ #user-modal .dda-phone-input {
366
+ border-radius: 0;
367
+ border-color: var(--dm-line);
368
+ }
369
+ #user-modal .dda-phone-input:focus-within {
370
+ border-color: var(--dm-accent);
371
+ box-shadow: 0 0 0 3px var(--dm-accent-100);
372
+ }
static/css/dda-modernist-auth.css ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ Modernist auth pages theme (from the Claude Design redesign).
3
+ Scoped entirely to #page-login / #page-forgot-password /
4
+ #page-reset-password so no other page is affected. Reuses the
5
+ shared --dm-* tokens declared once at :root in dda.css: flat
6
+ surfaces, hairline dividers, sharp corners, one accent color,
7
+ Archivo for headings. Pure visual/layout restyle — no markup
8
+ behavior, ids or JS hooks are changed by this file.
9
+ ============================================================ */
10
+
11
+ #page-login,
12
+ #page-forgot-password,
13
+ #page-reset-password {
14
+ background: var(--dm-bg);
15
+ }
16
+
17
+ /* --- Shell: form panel centered in the full viewport (no side hero) --- */
18
+ #page-login .dm-auth-shell,
19
+ #page-forgot-password .dm-auth-shell,
20
+ #page-reset-password .dm-auth-shell {
21
+ min-height: 100vh;
22
+ }
23
+
24
+ /* --- Form panel --- */
25
+ #page-login .auth-page,
26
+ #page-forgot-password .auth-page,
27
+ #page-reset-password .auth-page {
28
+ min-height: 100vh;
29
+ display: flex;
30
+ align-items: center;
31
+ justify-content: center;
32
+ padding: 2rem;
33
+ background: var(--dm-bg);
34
+ }
35
+
36
+ /* Brand mark inside the card — the only branding on the page now that
37
+ there's no side hero panel. */
38
+ #page-login .auth-brand,
39
+ #page-forgot-password .auth-brand,
40
+ #page-reset-password .auth-brand {
41
+ display: flex;
42
+ color: var(--dm-ink);
43
+ }
44
+
45
+ /* --- Flat card surface, sharp corners, no shadow --- */
46
+ #page-login .card,
47
+ #page-forgot-password .card,
48
+ #page-reset-password .card {
49
+ background: var(--dm-surface);
50
+ border: 1px solid var(--dm-line);
51
+ border-radius: 0;
52
+ box-shadow: none;
53
+ padding: 2rem;
54
+ }
55
+ #page-login .card-header,
56
+ #page-forgot-password .card-header,
57
+ #page-reset-password .card-header {
58
+ border-bottom-color: var(--dm-line);
59
+ }
60
+ #page-login .card-header h3,
61
+ #page-forgot-password .card-header h3,
62
+ #page-reset-password .card-header h3 {
63
+ font-family: var(--dm-font);
64
+ font-weight: 800;
65
+ font-size: 1.4rem;
66
+ letter-spacing: -0.01em;
67
+ color: var(--dm-ink);
68
+ }
69
+
70
+ #page-login .auth-sub,
71
+ #page-forgot-password .auth-sub,
72
+ #page-reset-password .auth-sub {
73
+ color: var(--dm-ink-muted);
74
+ }
75
+
76
+ /* --- Inputs: sharp corners, hairline border --- */
77
+ #page-login input[type="email"],
78
+ #page-login input[type="password"],
79
+ #page-login input[type="text"],
80
+ #page-login input[type="tel"],
81
+ #page-login select,
82
+ #page-forgot-password input[type="email"],
83
+ #page-reset-password input[type="password"] {
84
+ background: var(--dm-bg);
85
+ border: 1px solid var(--dm-line);
86
+ border-radius: 0;
87
+ color: var(--dm-ink);
88
+ }
89
+ #page-login input:focus,
90
+ #page-login select:focus,
91
+ #page-forgot-password input:focus,
92
+ #page-reset-password input:focus {
93
+ border-color: var(--dm-accent);
94
+ box-shadow: 0 0 0 3px var(--dm-accent-100);
95
+ }
96
+ #page-login .form-group label,
97
+ #page-forgot-password .form-group label,
98
+ #page-reset-password .form-group label {
99
+ color: var(--dm-ink-muted);
100
+ }
101
+ #page-login .field-hint,
102
+ #page-reset-password .field-hint {
103
+ color: var(--dm-ink-dim);
104
+ }
105
+
106
+ /* --- Phone input (country code + local number) --- */
107
+ #page-login .dda-phone-input {
108
+ background: var(--dm-bg);
109
+ border: 1px solid var(--dm-line);
110
+ border-radius: 0;
111
+ }
112
+ #page-login .dda-phone-input:focus-within {
113
+ border-color: var(--dm-accent);
114
+ box-shadow: 0 0 0 3px var(--dm-accent-100);
115
+ }
116
+ #page-login .dda-phone-input .dda-country-select {
117
+ border-right-color: var(--dm-line);
118
+ }
119
+
120
+ /* --- Sign-in method tabs (Password / Email OTP) --- */
121
+ #page-login .dda-auth-tabs {
122
+ background: var(--dm-bg);
123
+ border: 1px solid var(--dm-line);
124
+ border-radius: 0;
125
+ }
126
+ #page-login .dda-auth-tab.is-active {
127
+ background: var(--dm-surface);
128
+ color: var(--dm-ink);
129
+ box-shadow: none;
130
+ border: 1px solid var(--dm-line);
131
+ }
132
+
133
+ /* --- OTP code input --- */
134
+ #page-login .dda-otp-input {
135
+ background: var(--dm-bg);
136
+ border: 1px solid var(--dm-line);
137
+ border-radius: 0;
138
+ color: var(--dm-ink);
139
+ }
140
+
141
+ /* --- Checkbox accent --- */
142
+ #page-login .checkbox-group input[type="checkbox"] {
143
+ accent-color: var(--dm-accent);
144
+ }
145
+
146
+ /* --- Buttons: flat accent instead of the app-wide purple gradient --- */
147
+ #page-login .btn,
148
+ #page-forgot-password .btn,
149
+ #page-reset-password .btn {
150
+ border-radius: 0;
151
+ font-family: var(--dm-font);
152
+ font-weight: 700;
153
+ }
154
+ #page-login .btn-primary,
155
+ #page-forgot-password .btn-primary,
156
+ #page-reset-password .btn-primary {
157
+ background: var(--dm-accent);
158
+ }
159
+ #page-login .btn-primary:hover,
160
+ #page-forgot-password .btn-primary:hover,
161
+ #page-reset-password .btn-primary:hover {
162
+ background: var(--dm-accent-hover);
163
+ filter: none;
164
+ box-shadow: none;
165
+ }
166
+ #page-login .btn-secondary,
167
+ #page-forgot-password .btn-secondary,
168
+ #page-reset-password .btn-secondary {
169
+ background: transparent;
170
+ border: 1px solid var(--dm-line);
171
+ color: var(--dm-ink);
172
+ border-radius: 0;
173
+ }
174
+ #page-login .btn-secondary:hover,
175
+ #page-forgot-password .btn-secondary:hover,
176
+ #page-reset-password .btn-secondary:hover {
177
+ background: var(--dm-bg);
178
+ border-color: var(--dm-line);
179
+ }
180
+
181
+ /* --- Links --- */
182
+ #page-login .auth-forgot-link,
183
+ #page-login .auth-toggle a,
184
+ #page-forgot-password .auth-toggle a,
185
+ #page-reset-password .auth-toggle a {
186
+ color: var(--dm-accent);
187
+ }
188
+
189
+ /* --- Alerts: sharp corners --- */
190
+ #page-login .alert,
191
+ #page-forgot-password .alert,
192
+ #page-reset-password .alert {
193
+ border-radius: 0;
194
+ }
195
+
static/css/dda-modernist-detect.css ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ Modernist Change Detection theme (from the Claude Design
3
+ redesign). Scoped entirely to #tab-detect so the rest of the
4
+ app keeps its existing look until/unless it gets redesigned
5
+ too: flat surfaces, hairline dividers, sharp corners, one
6
+ accent color, Archivo for headings. Tokens (--dm-*) are
7
+ shared, declared once at :root in dda.css — reused here, not
8
+ redeclared. Pure visual/layout reskin: no ids/classes that
9
+ compare.js/tree.js/pairs.js/result.js/map.js select on are
10
+ renamed, and no RBAC (`data-requires-permission`) attributes
11
+ are touched.
12
+ ============================================================ */
13
+
14
+ /* --- Page header (mirrors the mockup's title + subtitle) --------------- */
15
+ #tab-detect .dda-detect-hero { margin-bottom: 1.25rem; }
16
+ #tab-detect .dda-detect-hero h1 { margin: 0 0 0.2rem; font-weight: 800; letter-spacing: -0.01em; }
17
+ #tab-detect .dda-detect-hero .sub { margin: 0; color: var(--dm-ink-muted); }
18
+
19
+ /* --- Typography -------------------------------------------------------- */
20
+ #tab-detect h1,
21
+ #tab-detect .card-title,
22
+ #tab-detect .card-header h3,
23
+ #tab-detect .dda-run-bar-title {
24
+ font-family: var(--dm-font);
25
+ }
26
+ #tab-detect .card-header h3 { font-weight: 800; letter-spacing: -0.005em; }
27
+
28
+ /* --- Flat, hairline-bordered surfaces instead of soft shadow cards ----- */
29
+ #tab-detect .card,
30
+ #tab-detect .dda-modal-panel,
31
+ #tab-detect .dda-slot,
32
+ #tab-detect .dda-roi-controls,
33
+ #tab-detect .dda-upload-progress .dda-progress-bar {
34
+ background: var(--dm-surface);
35
+ border: 1px solid var(--dm-line);
36
+ border-radius: 0;
37
+ box-shadow: none;
38
+ }
39
+ #tab-detect .dda-slot { border-style: dashed; }
40
+ #tab-detect .dda-slot.filled { border-style: solid; border-color: var(--dm-accent); }
41
+ #tab-detect .dda-slot.drag-over { border-color: var(--dm-accent); background: var(--dm-accent-100); }
42
+
43
+ /* --- Buttons — flat accent instead of the app-wide purple gradient ----- */
44
+ #tab-detect .btn { border-radius: 0; font-family: var(--dm-font); font-weight: 700; }
45
+ #tab-detect .btn-primary { background: var(--dm-accent); border-color: var(--dm-accent); }
46
+ #tab-detect .btn-primary:hover:not(:disabled) { background: var(--dm-accent-hover); filter: none; box-shadow: none; }
47
+ #tab-detect .btn-secondary { background: transparent; border-color: var(--dm-line); color: var(--dm-ink); }
48
+ #tab-detect .btn-secondary:hover:not(:disabled) { background: var(--dm-bg); border-color: var(--dm-line); }
49
+ #tab-detect .btn-ghost { color: var(--dm-accent); }
50
+ #tab-detect .btn-ghost:hover:not(:disabled) { background: var(--dm-accent-100); color: var(--dm-accent-hover); }
51
+
52
+ /* --- Layout breathing room (mirrors the mockup's 20px card gaps) ------- */
53
+ #tab-detect.dda-panel { display: flex; flex-direction: column; gap: 0; }
54
+ #tab-detect .dda-layout { gap: 1.25rem; }
55
+ #tab-detect .dda-main { display: flex; flex-direction: column; gap: 1.25rem; }
56
+ #tab-detect .card { padding: 1.25rem; }
57
+ #tab-detect .dda-sidebar.card { padding: 1rem; }
58
+
59
+ /* --- Folder tree sidebar ------------------------------------------------ */
60
+ #tab-detect .dda-folder-path { color: var(--dm-ink-muted); }
61
+ #tab-detect .dda-search {
62
+ border: 1px solid var(--dm-line);
63
+ border-radius: 0;
64
+ background: var(--dm-bg);
65
+ color: var(--dm-ink);
66
+ }
67
+ #tab-detect .dda-search:focus { outline: none; border-color: var(--dm-accent); }
68
+ #tab-detect .dda-tree-all,
69
+ #tab-detect .dda-tree-node-btn,
70
+ #tab-detect .dda-tree-year,
71
+ #tab-detect .dda-tree-village {
72
+ border-radius: 0;
73
+ color: var(--dm-ink-muted);
74
+ }
75
+ #tab-detect .dda-tree-all:hover, #tab-detect .dda-tree-all.active,
76
+ #tab-detect .dda-tree-node-btn:hover, #tab-detect .dda-tree-node-btn.active,
77
+ #tab-detect .dda-tree-year:hover, #tab-detect .dda-tree-year.active,
78
+ #tab-detect .dda-tree-village:hover, #tab-detect .dda-tree-village.active {
79
+ background: var(--dm-accent-100);
80
+ color: var(--dm-accent-hover);
81
+ }
82
+ #tab-detect .dda-tree-zone > button { border-radius: 0; color: var(--dm-ink); }
83
+ #tab-detect .dda-tree-zone > button:hover { background: var(--dm-accent-100); }
84
+ #tab-detect .dda-tree-children { border-left-color: var(--dm-line); }
85
+
86
+ /* --- Detection options form -------------------------------------------- */
87
+ #tab-detect .dda-detect-options select,
88
+ #tab-detect .dda-auto-compare select,
89
+ #tab-detect .dda-image-select {
90
+ border: 1px solid var(--dm-line);
91
+ border-radius: 0;
92
+ background: var(--dm-surface);
93
+ color: var(--dm-ink);
94
+ }
95
+ #tab-detect .dda-detect-options select:focus,
96
+ #tab-detect .dda-auto-compare select:focus,
97
+ #tab-detect .dda-image-select:focus { outline: none; border-color: var(--dm-accent); box-shadow: none; }
98
+
99
+ #tab-detect .dda-sensitivity-field em { color: var(--dm-accent); font-weight: 700; }
100
+ #tab-detect .dda-sensitivity-field input[type="range"] { accent-color: var(--dm-accent); }
101
+
102
+ #tab-detect .dda-stepper-wrap {
103
+ border: 1px solid var(--dm-line);
104
+ border-radius: 0;
105
+ background: var(--dm-surface);
106
+ }
107
+ #tab-detect .dda-stepper-wrap:focus-within { border-color: var(--dm-accent); box-shadow: none; }
108
+ #tab-detect .dda-stepper-btn:first-child { border-right-color: var(--dm-line); }
109
+ #tab-detect .dda-stepper-btn:last-child { border-left-color: var(--dm-line); }
110
+ #tab-detect .dda-stepper-btn:hover:not(:disabled) { background: var(--dm-accent-100); color: var(--dm-accent-hover); }
111
+ #tab-detect .dda-input-suffix { color: var(--dm-ink-dim); }
112
+ #tab-detect .dda-input-suffix-wrap input[type="number"]:focus + .dda-input-suffix { color: var(--dm-accent); }
113
+
114
+ #tab-detect .dda-check { color: var(--dm-ink-muted); }
115
+ #tab-detect .dda-check input[type="checkbox"] { accent-color: var(--dm-accent); }
116
+ #tab-detect .dda-notify-email {
117
+ border: 1px solid var(--dm-line);
118
+ border-radius: 0;
119
+ background: var(--dm-surface);
120
+ color: var(--dm-ink);
121
+ }
122
+ #tab-detect .dda-field-hint { color: var(--dm-accent); }
123
+
124
+ /* Region-shape segmented toggle — flat, dark-ink active state (matches
125
+ the mockup's "seg"/"seg-opt" control) instead of the app-wide blue. */
126
+ #tab-detect .dda-shape-toggle,
127
+ #tab-detect .dda-mode-toggle {
128
+ border: 1px solid var(--dm-line);
129
+ border-radius: 0;
130
+ overflow: hidden;
131
+ }
132
+ #tab-detect .dda-shape-btn,
133
+ #tab-detect .dda-mode-btn {
134
+ border-radius: 0;
135
+ color: var(--dm-ink);
136
+ font-family: var(--dm-font);
137
+ font-weight: 600;
138
+ }
139
+ #tab-detect .dda-shape-btn + .dda-shape-btn,
140
+ #tab-detect .dda-mode-btn + .dda-mode-btn { border-left-color: var(--dm-line); }
141
+ #tab-detect .dda-shape-btn:hover,
142
+ #tab-detect .dda-mode-btn:hover { background: var(--dm-bg); }
143
+ #tab-detect .dda-shape-btn.is-active,
144
+ #tab-detect .dda-mode-btn.is-active { background: var(--dm-ink); color: #fff; }
145
+
146
+ #tab-detect .dda-roi-controls { padding: 0.6rem 0.85rem; color: var(--dm-ink-muted); }
147
+
148
+ #tab-detect .dda-progress-fill { background: var(--dm-accent); }
149
+
150
+ /* --- Automatic-mode area picker + schedule ------------------------------ */
151
+ #tab-detect .dda-auto-field input {
152
+ border: 1px solid var(--dm-line);
153
+ border-radius: 0;
154
+ background: var(--dm-surface);
155
+ color: var(--dm-ink);
156
+ }
157
+ #tab-detect .dda-auto-field input:focus { outline: none; border-color: var(--dm-accent); }
158
+ #tab-detect .dda-auto-schedule { color: var(--dm-ink-muted); }
159
+
160
+ /* --- Before/After compare slots ----------------------------------------- */
161
+ #tab-detect .dda-slot-label {
162
+ font-size: 0.72rem;
163
+ font-weight: 800;
164
+ text-transform: uppercase;
165
+ letter-spacing: 0.05em;
166
+ color: var(--dm-ink-muted);
167
+ }
168
+ #tab-detect .dda-slot-preview { border-radius: 0; background: var(--dm-bg); }
169
+ #tab-detect .dda-slot-meta { color: var(--dm-ink-muted); }
170
+
171
+ /* --- Same-area pairs + library grid -------------------------------------- */
172
+ #tab-detect .dda-compare-card,
173
+ #tab-detect .dda-picker-item {
174
+ border-radius: 0;
175
+ border-color: var(--dm-line);
176
+ }
177
+ #tab-detect .dda-compare-card.selected-t1 { border-color: var(--dm-accent); box-shadow: inset 0 0 0 1px var(--dm-accent); }
178
+ #tab-detect .dda-compare-card.selected-t2 { border-color: var(--dm-ink); box-shadow: inset 0 0 0 1px var(--dm-ink); }
179
+ #tab-detect .dda-compare-assign { border-top-color: var(--dm-line); }
180
+ #tab-detect .dda-compare-assign .btn { border-radius: 0; }
181
+ #tab-detect .pagination .btn,
182
+ #tab-detect .pagination button { border-radius: 0; }
183
+
184
+ /* --- Run bar — dark flat bar with the accent primary button ------------- */
185
+ #tab-detect .dda-run-bar {
186
+ border-radius: 0;
187
+ background: var(--dm-ink);
188
+ color: #fff;
189
+ border: none;
190
+ }
191
+ #tab-detect .dda-run-bar .dim,
192
+ #tab-detect .dda-run-bar #dda-run-bar-hint { color: rgba(255, 255, 255, 0.65); }
193
+
194
+ /* --- Modals (preflight / image picker) ---------------------------------- */
195
+ #tab-detect .dda-modal-panel { border-radius: 0; }
196
+ #tab-detect .dda-picker-grid .dda-picker-item:hover { border-color: var(--dm-accent); }
197
+ #tab-detect .dda-preflight-list li.fail { border-left: 3px solid var(--dm-accent); }
static/css/dda-modernist-library.css ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ Modernist Image Library theme (Claude Design redesign).
3
+ Scoped entirely to #tab-library so the rest of the app keeps its
4
+ existing look until/unless it gets redesigned too: flat surfaces,
5
+ hairline dividers, sharp corners, one accent color, Archivo for
6
+ headings. Tokens (--dm-*) are shared globals declared once at
7
+ :root in dda.css — reused here, not redeclared.
8
+
9
+ Note: the image-viewer / manage-folders / bulk-upload modals and
10
+ the folder right-click menu are rendered outside the #tab-library
11
+ DOM subtree (they're siblings appended at the end of <body> in
12
+ library_dda.html), but their ids (#dda-image-viewer-modal,
13
+ #dda-manage-modal, #dda-bulk-upload-modal, #dda-tree-menu) only
14
+ ever exist on this page's markup, so anchoring rules to those ids
15
+ is just as safe as nesting under #tab-library — no other page can
16
+ be affected.
17
+ ============================================================ */
18
+
19
+ #tab-library h1,
20
+ #tab-library .card-header h3,
21
+ #tab-library #lib-grid-title,
22
+ #dda-image-viewer-title,
23
+ #dda-manage-title,
24
+ #dda-bulk-upload-title {
25
+ font-family: var(--dm-font);
26
+ font-weight: 800;
27
+ letter-spacing: -0.01em;
28
+ }
29
+
30
+ /* Flat, hairline-bordered surfaces instead of soft shadow cards */
31
+ #tab-library .card,
32
+ #tab-library .dda-storage-info,
33
+ #dda-image-viewer-modal .dda-modal-panel,
34
+ #dda-manage-modal .dda-modal-panel,
35
+ #dda-bulk-upload-modal .dda-modal-panel {
36
+ background: var(--dm-surface);
37
+ border: 1px solid var(--dm-line);
38
+ border-radius: 0;
39
+ box-shadow: none;
40
+ }
41
+ #tab-library .card-header { border-bottom-color: var(--dm-line); }
42
+ #dda-image-viewer-modal .card-header,
43
+ #dda-manage-modal .card-header,
44
+ #dda-bulk-upload-modal .card-header { border-bottom-color: var(--dm-line); }
45
+
46
+ /* Buttons — flat accent instead of the app-wide purple gradient,
47
+ scoped to this page (+ its modals) only so other pages are
48
+ untouched. */
49
+ #tab-library .btn,
50
+ #dda-image-viewer-modal .btn,
51
+ #dda-manage-modal .btn,
52
+ #dda-bulk-upload-modal .btn,
53
+ #dda-tree-menu button {
54
+ border-radius: 0;
55
+ font-family: var(--dm-font);
56
+ font-weight: 700;
57
+ }
58
+ #tab-library .btn-primary,
59
+ #dda-image-viewer-modal .btn-primary,
60
+ #dda-manage-modal .btn-primary,
61
+ #dda-bulk-upload-modal .btn-primary {
62
+ background: var(--dm-accent);
63
+ }
64
+ #tab-library .btn-primary:hover,
65
+ #dda-image-viewer-modal .btn-primary:hover,
66
+ #dda-manage-modal .btn-primary:hover,
67
+ #dda-bulk-upload-modal .btn-primary:hover {
68
+ background: var(--dm-accent-hover);
69
+ filter: none;
70
+ box-shadow: none;
71
+ }
72
+ #tab-library .btn-secondary,
73
+ #dda-image-viewer-modal .btn-secondary,
74
+ #dda-manage-modal .btn-secondary,
75
+ #dda-bulk-upload-modal .btn-secondary {
76
+ background: transparent;
77
+ border-color: var(--dm-line);
78
+ color: var(--dm-ink);
79
+ }
80
+ #tab-library .btn-secondary:hover,
81
+ #dda-image-viewer-modal .btn-secondary:hover,
82
+ #dda-manage-modal .btn-secondary:hover,
83
+ #dda-bulk-upload-modal .btn-secondary:hover {
84
+ background: var(--dm-bg);
85
+ border-color: var(--dm-line);
86
+ }
87
+ #tab-library .btn-danger,
88
+ #dda-image-viewer-modal .btn-danger,
89
+ #dda-manage-modal .btn-danger {
90
+ box-shadow: none;
91
+ }
92
+ #tab-library .btn-danger:hover,
93
+ #dda-image-viewer-modal .btn-danger:hover,
94
+ #dda-manage-modal .btn-danger:hover {
95
+ box-shadow: none;
96
+ }
97
+ #tab-library .btn-ghost { color: var(--dm-accent); }
98
+ #tab-library .btn-ghost:hover { background: var(--dm-accent-100); color: var(--dm-accent-hover); }
99
+
100
+ /* Inputs, selects, search fields — sharp corners + hairline border */
101
+ #tab-library input[type="text"],
102
+ #tab-library input[type="search"],
103
+ #tab-library input[type="date"],
104
+ #tab-library input[type="url"],
105
+ #tab-library select,
106
+ #tab-library .dda-search,
107
+ #dda-image-viewer-modal select,
108
+ #dda-image-viewer-modal input[type="url"],
109
+ #dda-manage-modal input[type="text"],
110
+ #dda-manage-modal select,
111
+ #dda-bulk-upload-modal input[type="text"] {
112
+ border-radius: 0;
113
+ border-color: var(--dm-line);
114
+ background: var(--dm-surface);
115
+ }
116
+ #tab-library input:focus,
117
+ #tab-library select:focus,
118
+ #dda-image-viewer-modal select:focus,
119
+ #dda-manage-modal input:focus,
120
+ #dda-manage-modal select:focus {
121
+ border-color: var(--dm-accent);
122
+ box-shadow: 0 0 0 3px var(--dm-accent-100);
123
+ }
124
+
125
+ /* --- Folder tree sidebar --- */
126
+ #tab-library .dda-folder-path,
127
+ #tab-library .dda-tree-hint { color: var(--dm-ink-muted); }
128
+ #tab-library .dda-tree-toolbar-btn { color: var(--dm-ink-muted); }
129
+ #tab-library .dda-tree-toolbar-btn:hover { color: var(--dm-accent); }
130
+ #tab-library .dda-tree-all,
131
+ #tab-library .dda-tree-node-btn,
132
+ #tab-library .dda-tree-year,
133
+ #tab-library .dda-tree-zone > button,
134
+ #tab-library .dda-tree-village {
135
+ border-radius: 0;
136
+ color: var(--dm-ink);
137
+ }
138
+ #tab-library .dda-tree-all:hover,
139
+ #tab-library .dda-tree-node-btn:hover,
140
+ #tab-library .dda-tree-year:hover,
141
+ #tab-library .dda-tree-zone > button:hover,
142
+ #tab-library .dda-tree-village:hover {
143
+ background: var(--dm-accent-100);
144
+ color: var(--dm-accent);
145
+ }
146
+ #tab-library .dda-tree-all.active,
147
+ #tab-library .dda-tree-node-btn.active,
148
+ #tab-library .dda-tree-year.active,
149
+ #tab-library .dda-tree-village.active {
150
+ background: var(--dm-accent-100);
151
+ color: var(--dm-accent);
152
+ border-left: 2px solid var(--dm-accent);
153
+ padding-left: calc(0.5rem - 2px);
154
+ }
155
+ #tab-library .dda-tree-children { border-left-color: var(--dm-line); }
156
+ #tab-library .dda-tree-hit { background: var(--dm-accent-100); border-radius: 0; }
157
+ #tab-library .dda-tree-node-btn.is-drop-target {
158
+ outline-color: var(--dm-accent);
159
+ background: var(--dm-accent-100);
160
+ color: var(--dm-accent);
161
+ }
162
+ #dda-tree-menu {
163
+ border-radius: 0;
164
+ border-color: var(--dm-line);
165
+ box-shadow: none;
166
+ }
167
+ #dda-tree-menu button:hover { background: var(--dm-accent-100); color: var(--dm-accent); }
168
+
169
+ /* --- Upload card --- */
170
+ #tab-library .dda-dropzone {
171
+ border-radius: 0;
172
+ border-color: var(--dm-line);
173
+ background: var(--dm-bg);
174
+ }
175
+ #tab-library .dda-dropzone.is-dragover {
176
+ border-color: var(--dm-accent);
177
+ background: var(--dm-accent-100);
178
+ }
179
+ #tab-library .dda-progress-bar { border-radius: 0; background: var(--dm-line); }
180
+ #tab-library .dda-progress-fill { background: var(--dm-accent); }
181
+ #tab-library .dda-storage-info summary { color: var(--dm-ink-muted); }
182
+ #tab-library .dda-storage-info summary:hover { color: var(--dm-accent); }
183
+ #tab-library .dda-path-code { border-radius: 0; border-color: var(--dm-line); background: var(--dm-bg); }
184
+
185
+ /* --- Same-area pairs --- */
186
+ #tab-library .dda-area-pair {
187
+ border-radius: 0;
188
+ border-color: var(--dm-line);
189
+ background: var(--dm-surface);
190
+ }
191
+
192
+ /* --- Image grid --- */
193
+ #tab-library .dda-grid { gap: 1px; background: var(--dm-line); border: 1px solid var(--dm-line); }
194
+ #tab-library .dda-card-img {
195
+ border-radius: 0;
196
+ border: none;
197
+ background: var(--dm-surface);
198
+ transition: none;
199
+ }
200
+ #tab-library .dda-card-img:hover { border-color: transparent; outline: 2px solid var(--dm-accent); outline-offset: -2px; }
201
+ #tab-library .dda-card-delete-btn { border-radius: 0; background: rgba(32, 30, 29, 0.68); }
202
+ #tab-library .dda-card-delete-btn:hover { background: var(--dm-accent); }
203
+ #tab-library .dda-card-img .meta { color: var(--dm-ink-muted); }
204
+
205
+ /* --- Pagination --- */
206
+ #tab-library .pagination button { border-radius: 0; }
207
+ #tab-library .pagination button.active { background: var(--dm-accent); border-color: var(--dm-accent); }
208
+ #tab-library .pagination button:hover:not(:disabled):not(.active) {
209
+ border-color: var(--dm-accent);
210
+ color: var(--dm-accent);
211
+ }
212
+
213
+ /* --- Modals (viewer / manage folders / bulk upload) --- */
214
+ #dda-image-viewer-modal .dda-image-viewer-wrap { border-radius: 0; border-color: var(--dm-line); background: var(--dm-bg); }
215
+ #dda-image-viewer-modal .dda-view-btn { border-radius: 0; }
216
+ #dda-image-viewer-modal .dda-view-btn.active { background: var(--dm-accent-100); color: var(--dm-accent); }
217
+ #dda-image-viewer-modal .dda-map-canvas { border-radius: 0; }
218
+ #dda-manage-modal .dda-manage-section h4,
219
+ #dda-bulk-upload-modal .dda-manage-section h4 { font-family: var(--dm-font); font-weight: 700; color: var(--dm-ink); }
220
+ #dda-bulk-upload-modal .dda-logs-table th { color: var(--dm-ink-muted); }
221
+ #dda-bulk-upload-modal .dda-logs-table td,
222
+ #dda-bulk-upload-modal .dda-logs-table th { border-bottom-color: var(--dm-line); }
223
+
224
+ @media (max-width: 900px) {
225
+ #tab-library .dda-grid { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); }
226
+ }
227
+
228
+ /* Required-field marker on the upload form's Zone/Village labels — these
229
+ are now mandatory (see upload_image()'s validation). */
230
+ #tab-library .dda-required-mark { color: var(--dm-accent); }
static/css/dda-modernist-logs.css ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ Modernist Audit Log & Exception Log theme (Claude Design reskin).
3
+ Companion to the "Modernist dashboard theme" block in dda.css
4
+ (search that file for #tab-home) — same visual language, same
5
+ shared --dm-* tokens declared once at :root in dda.css. Purely
6
+ visual: no ids/classes added or removed, no JS behavior changed.
7
+
8
+ Scoped to this page's own containers: #tab-audit-log and
9
+ #tab-exception-log. The one exception is the exception-detail
10
+ modal (#exception-detail-modal), which the base template renders
11
+ as a sibling of .dda-main-col (outside #tab-exception-log) so it
12
+ can overlay the whole viewport — it is still scoped, just under
13
+ its own page-unique id instead of the tab container.
14
+ ============================================================ */
15
+
16
+ /* --- Headings / hero --- */
17
+ #tab-audit-log h1,
18
+ #tab-exception-log h1,
19
+ #tab-exception-log .card-header h3,
20
+ #tab-audit-log .card-header h3 {
21
+ font-family: var(--dm-font);
22
+ font-weight: 800;
23
+ letter-spacing: -0.01em;
24
+ color: var(--dm-ink);
25
+ }
26
+ #tab-audit-log .sub,
27
+ #tab-exception-log .sub {
28
+ color: var(--dm-ink-muted);
29
+ }
30
+ #tab-audit-log .dda-filter-meta,
31
+ #tab-exception-log .dda-filter-meta {
32
+ color: var(--dm-ink-dim);
33
+ font-family: var(--dm-font);
34
+ }
35
+
36
+ /* --- Flat surfaces instead of soft shadow cards --- */
37
+ #tab-audit-log .card,
38
+ #tab-exception-log .card,
39
+ #exception-detail-modal .card {
40
+ background: var(--dm-surface);
41
+ border: 1px solid var(--dm-line);
42
+ border-radius: 0;
43
+ box-shadow: none;
44
+ }
45
+ #tab-audit-log .card-header,
46
+ #tab-exception-log .card-header,
47
+ #exception-detail-modal .card-header {
48
+ border-bottom: 2px solid var(--dm-line);
49
+ }
50
+ #tab-audit-log .dda-filter-bar,
51
+ #tab-exception-log .dda-filter-bar {
52
+ background: var(--dm-surface);
53
+ border: 1px solid var(--dm-line);
54
+ border-radius: 0;
55
+ }
56
+ #tab-audit-log .dda-filter-field,
57
+ #tab-exception-log .dda-filter-field {
58
+ font-family: var(--dm-font);
59
+ color: var(--dm-ink-muted);
60
+ }
61
+
62
+ /* --- Buttons — flat accent instead of the app-wide purple gradient --- */
63
+ #tab-audit-log .btn,
64
+ #tab-exception-log .btn,
65
+ #exception-detail-modal .btn {
66
+ border-radius: 0;
67
+ font-family: var(--dm-font);
68
+ font-weight: 700;
69
+ }
70
+ #tab-audit-log .btn-primary,
71
+ #tab-exception-log .btn-primary {
72
+ background: var(--dm-accent);
73
+ }
74
+ #tab-audit-log .btn-primary:hover,
75
+ #tab-exception-log .btn-primary:hover {
76
+ background: var(--dm-accent-hover);
77
+ filter: none;
78
+ box-shadow: none;
79
+ }
80
+ #tab-audit-log .btn-secondary,
81
+ #tab-exception-log .btn-secondary,
82
+ #exception-detail-modal .btn-secondary {
83
+ background: transparent;
84
+ border-color: var(--dm-line);
85
+ color: var(--dm-ink);
86
+ }
87
+ #tab-audit-log .btn-secondary:hover,
88
+ #tab-exception-log .btn-secondary:hover,
89
+ #exception-detail-modal .btn-secondary:hover {
90
+ background: var(--dm-bg);
91
+ border-color: var(--dm-line);
92
+ }
93
+ #tab-audit-log .btn-ghost,
94
+ #tab-exception-log .btn-ghost {
95
+ color: var(--dm-accent);
96
+ }
97
+ #tab-audit-log .btn-ghost:hover,
98
+ #tab-exception-log .btn-ghost:hover {
99
+ background: var(--dm-accent-100);
100
+ color: var(--dm-accent-hover);
101
+ }
102
+
103
+ /* --- Filter inputs: selects, search box, date trigger --- */
104
+ #tab-audit-log .dda-filter-field select,
105
+ #tab-audit-log .dda-filter-field .dda-search,
106
+ #tab-audit-log input.dda-date-input:not(.dda-date-native),
107
+ #tab-audit-log .dda-date-trigger,
108
+ #tab-exception-log .dda-filter-field select,
109
+ #tab-exception-log .dda-filter-field .dda-search,
110
+ #tab-exception-log input.dda-date-input:not(.dda-date-native),
111
+ #tab-exception-log .dda-date-trigger {
112
+ border-radius: 0;
113
+ border-color: var(--dm-line);
114
+ background: var(--dm-surface);
115
+ color: var(--dm-ink);
116
+ }
117
+ #tab-exception-log .dda-check {
118
+ font-family: var(--dm-font);
119
+ color: var(--dm-ink-muted);
120
+ }
121
+ #tab-exception-log .dda-check input[type="checkbox"] {
122
+ accent-color: var(--dm-accent);
123
+ }
124
+
125
+ /* --- Tables — uppercase hairline headers, flat row dividers --- */
126
+ #tab-audit-log .dda-logs-table th,
127
+ #tab-exception-log .dda-logs-table th {
128
+ font-family: var(--dm-font);
129
+ font-size: 0.68rem;
130
+ font-weight: 800;
131
+ letter-spacing: 0.06em;
132
+ text-transform: uppercase;
133
+ color: var(--dm-ink-muted);
134
+ background: var(--dm-surface);
135
+ border-bottom: 2px solid var(--dm-line);
136
+ }
137
+ #tab-audit-log .dda-logs-table td,
138
+ #tab-exception-log .dda-logs-table td {
139
+ border-bottom: 1px solid var(--dm-line);
140
+ color: var(--dm-ink);
141
+ }
142
+ #tab-audit-log .dda-logs-table tbody tr:hover,
143
+ #tab-exception-log .dda-logs-table tbody tr:hover {
144
+ background: var(--dm-bg);
145
+ }
146
+ #tab-exception-log .dda-log-level {
147
+ border-radius: 2px;
148
+ font-family: var(--dm-font);
149
+ }
150
+
151
+ /* --- Badges / status tags — sharp corners instead of pills --- */
152
+ #tab-audit-log .dda-badge,
153
+ #tab-exception-log .dda-badge,
154
+ #tab-audit-log .dda-action-badge,
155
+ #tab-exception-log .dda-action-badge {
156
+ border-radius: 2px;
157
+ font-family: var(--dm-font);
158
+ }
159
+
160
+ /* --- Pagination --- */
161
+ #tab-audit-log .pagination button,
162
+ #tab-exception-log .pagination button {
163
+ border-radius: 0;
164
+ border-color: var(--dm-line);
165
+ background: var(--dm-surface);
166
+ color: var(--dm-ink-muted);
167
+ font-family: var(--dm-font);
168
+ }
169
+ #tab-audit-log .pagination button:hover:not(:disabled),
170
+ #tab-exception-log .pagination button:hover:not(:disabled) {
171
+ background: var(--dm-accent-100);
172
+ border-color: var(--dm-accent);
173
+ color: var(--dm-ink);
174
+ }
175
+ #tab-audit-log .pagination button.active,
176
+ #tab-exception-log .pagination button.active {
177
+ background: var(--dm-accent);
178
+ border-color: var(--dm-accent);
179
+ color: #fff;
180
+ font-weight: 700;
181
+ }
182
+ #tab-audit-log .pagination .page-jump input,
183
+ #tab-exception-log .pagination .page-jump input {
184
+ border-radius: 0;
185
+ border-color: var(--dm-line);
186
+ background: var(--dm-surface);
187
+ }
188
+
189
+ /* --- Exception Log: Exceptions / Application Logs segmented switch --- */
190
+ #tab-exception-log .dda-mode-toggle {
191
+ border: 1px solid var(--dm-line);
192
+ border-radius: 0;
193
+ overflow: hidden;
194
+ }
195
+ #tab-exception-log .dda-mode-btn {
196
+ font-family: var(--dm-font);
197
+ font-weight: 700;
198
+ font-size: 0.78rem;
199
+ letter-spacing: 0.02em;
200
+ color: var(--dm-ink-muted);
201
+ padding: 0.55rem 1.1rem;
202
+ }
203
+ #tab-exception-log .dda-mode-btn + .dda-mode-btn {
204
+ border-left: 1px solid var(--dm-line);
205
+ }
206
+ #tab-exception-log .dda-mode-btn:hover {
207
+ background: var(--dm-bg);
208
+ }
209
+ #tab-exception-log .dda-mode-btn.is-active {
210
+ background: var(--dm-ink);
211
+ color: #fff;
212
+ }
213
+
214
+ /* --- Exception Log: stat cards — flat hairline grid, no icons,
215
+ big Archivo numbers (icons stay in the DOM, just visually hidden
216
+ to match the flat numeric-tile look; nothing is removed). --- */
217
+ #tab-exception-log .dda-stat-cards {
218
+ display: grid;
219
+ grid-template-columns: repeat(4, 1fr);
220
+ gap: 1px;
221
+ background: var(--dm-line);
222
+ border: 1px solid var(--dm-line);
223
+ margin: 0 0 1.25rem;
224
+ }
225
+ #tab-exception-log .dda-stat-card {
226
+ background: var(--dm-surface);
227
+ border: none;
228
+ border-radius: 0;
229
+ padding: 1rem 1.1rem;
230
+ text-align: left;
231
+ }
232
+ #tab-exception-log .dda-stat-icon {
233
+ display: none;
234
+ }
235
+ #tab-exception-log .dda-stat-value {
236
+ font-family: var(--dm-font);
237
+ font-weight: 800;
238
+ font-size: 1.65rem;
239
+ line-height: 1.1;
240
+ color: var(--dm-ink);
241
+ }
242
+ #tab-exception-log .dda-stat-card.is-danger .dda-stat-value {
243
+ color: var(--danger);
244
+ }
245
+ #tab-exception-log .dda-stat-card.is-warn .dda-stat-value {
246
+ color: #b45309;
247
+ }
248
+ #tab-exception-log .dda-stat-label {
249
+ margin-top: 0.35rem;
250
+ font-size: 0.72rem;
251
+ font-weight: 700;
252
+ text-transform: uppercase;
253
+ letter-spacing: 0.05em;
254
+ color: var(--dm-ink-muted);
255
+ }
256
+ @media (max-width: 720px) {
257
+ #tab-exception-log .dda-stat-cards { grid-template-columns: repeat(2, 1fr); }
258
+ }
259
+ @media (max-width: 420px) {
260
+ #tab-exception-log .dda-stat-cards { grid-template-columns: 1fr; }
261
+ }
262
+
263
+ /* --- Exception detail modal --- */
264
+ #exception-detail-modal .dda-modal-panel {
265
+ border-radius: 0;
266
+ }
267
+ #exception-detail-modal .dda-log-msg {
268
+ color: var(--dm-ink);
269
+ }
static/css/dda-modernist-masters-profile.css ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ Modernist theme for Masters (Zones / Villages) and Profile.
3
+ Same visual language as the #tab-home redesign in dda.css
4
+ (flat surfaces, hairline dividers, sharp corners, one accent
5
+ color, Archivo for headings) — reuses the shared --dm-* tokens
6
+ declared at :root in dda.css. Purely visual: every rule below
7
+ is scoped under #tab-masters-zones, #tab-masters-villages or
8
+ #tab-profile, and no element id/behavior is touched.
9
+ ============================================================ */
10
+
11
+ /* ---------- Shared: Masters (Zones + Villages) ---------- */
12
+
13
+ #tab-masters-zones h1,
14
+ #tab-masters-villages h1,
15
+ #tab-masters-zones .card-header h3,
16
+ #tab-masters-villages .card-header h3,
17
+ #tab-masters-zones .dda-reports-table,
18
+ #tab-masters-villages .dda-reports-table {
19
+ font-family: var(--dm-font);
20
+ }
21
+ #tab-masters-zones h1,
22
+ #tab-masters-villages h1 {
23
+ font-weight: 800;
24
+ letter-spacing: -0.01em;
25
+ }
26
+
27
+ /* Flat, hairline-bordered surfaces instead of soft shadow cards */
28
+ #tab-masters-zones .card,
29
+ #tab-masters-villages .card {
30
+ background: var(--dm-surface);
31
+ border: 1px solid var(--dm-line);
32
+ border-radius: 0;
33
+ box-shadow: none;
34
+ }
35
+
36
+ #tab-masters-zones .card-header,
37
+ #tab-masters-villages .card-header {
38
+ border-bottom: 2px solid var(--dm-line);
39
+ }
40
+ #tab-masters-zones .card-header h3,
41
+ #tab-masters-villages .card-header h3 {
42
+ font-weight: 700;
43
+ color: var(--dm-ink);
44
+ }
45
+
46
+ /* Title row: heading + primary action on one line */
47
+ #tab-masters-zones .dda-hero.dda-hero-row,
48
+ #tab-masters-villages .dda-hero.dda-hero-row {
49
+ align-items: center;
50
+ }
51
+
52
+ /* Compact standalone search bar, mirroring the mockup's search card */
53
+ #tab-masters-zones .dda-masters-search-card,
54
+ #tab-masters-villages .dda-masters-search-card {
55
+ display: flex;
56
+ align-items: center;
57
+ flex-wrap: wrap;
58
+ gap: 0.75rem;
59
+ padding: 0.85rem 1.1rem;
60
+ margin-bottom: 1.25rem;
61
+ }
62
+ #tab-masters-zones .dda-masters-search-card .dda-search,
63
+ #tab-masters-villages .dda-masters-search-card .dda-search {
64
+ max-width: 320px;
65
+ margin-bottom: 0;
66
+ border-radius: 0;
67
+ background: var(--dm-surface);
68
+ border-color: var(--dm-line);
69
+ }
70
+ #tab-masters-villages .dda-masters-search-card select#masters-zone-filter {
71
+ max-width: 220px;
72
+ border-radius: 0;
73
+ background: var(--dm-surface);
74
+ border-color: var(--dm-line);
75
+ padding: 0.45rem 0.65rem;
76
+ }
77
+
78
+ /* Buttons — flat accent instead of the app-wide purple gradient */
79
+ #tab-masters-zones .btn,
80
+ #tab-masters-villages .btn,
81
+ #tab-masters-zones .dda-modal .btn,
82
+ #tab-masters-villages .dda-modal .btn {
83
+ border-radius: 0;
84
+ font-family: var(--dm-font);
85
+ font-weight: 700;
86
+ }
87
+ #tab-masters-zones .btn-primary,
88
+ #tab-masters-villages .btn-primary {
89
+ background: var(--dm-accent);
90
+ }
91
+ #tab-masters-zones .btn-primary:hover,
92
+ #tab-masters-villages .btn-primary:hover {
93
+ background: var(--dm-accent-hover);
94
+ filter: none;
95
+ box-shadow: none;
96
+ }
97
+ #tab-masters-zones .btn-secondary,
98
+ #tab-masters-villages .btn-secondary {
99
+ background: transparent;
100
+ border-color: var(--dm-line);
101
+ color: var(--dm-ink);
102
+ }
103
+ #tab-masters-zones .btn-secondary:hover,
104
+ #tab-masters-villages .btn-secondary:hover {
105
+ background: var(--dm-bg);
106
+ border-color: var(--dm-line);
107
+ }
108
+ #tab-masters-zones .btn-danger,
109
+ #tab-masters-villages .btn-danger {
110
+ box-shadow: none;
111
+ }
112
+
113
+ /* Table — uppercase hairline headers instead of boxed cells */
114
+ #tab-masters-zones .dda-reports-table,
115
+ #tab-masters-villages .dda-reports-table {
116
+ border-collapse: collapse;
117
+ }
118
+ #tab-masters-zones .dda-reports-table th,
119
+ #tab-masters-villages .dda-reports-table th {
120
+ border: none;
121
+ border-bottom: 2px solid var(--dm-line);
122
+ font-size: 0.68rem;
123
+ letter-spacing: 0.06em;
124
+ text-transform: uppercase;
125
+ color: var(--dm-ink-muted);
126
+ font-weight: 700;
127
+ background: transparent;
128
+ padding: 0.55rem 0.6rem;
129
+ }
130
+ #tab-masters-zones .dda-reports-table td,
131
+ #tab-masters-villages .dda-reports-table td {
132
+ border: none;
133
+ border-bottom: 1px solid var(--dm-line);
134
+ padding: 0.55rem 0.6rem;
135
+ color: var(--dm-ink);
136
+ }
137
+ #tab-masters-zones .dda-reports-table tbody tr:hover,
138
+ #tab-masters-villages .dda-reports-table tbody tr:hover {
139
+ background: var(--dm-bg);
140
+ }
141
+
142
+ /* Modal form fields — flat inputs/selects to match the rest of the page.
143
+ #masters-modal sits outside #tab-masters-zones/#tab-masters-villages in
144
+ the DOM (it's appended at body level so it can overlay the whole page),
145
+ so it's scoped via the body[data-masters] attribute each template already
146
+ sets instead — equivalent scoping, since each template only ever renders
147
+ its own single modal. */
148
+ body[data-masters="zones"] #masters-modal input[type="text"],
149
+ body[data-masters="villages"] #masters-modal input[type="text"],
150
+ body[data-masters="zones"] #masters-modal select,
151
+ body[data-masters="villages"] #masters-modal select,
152
+ body[data-masters="zones"] #masters-modal .card,
153
+ body[data-masters="villages"] #masters-modal .card,
154
+ body[data-masters="zones"] #masters-modal .dda-modal-panel,
155
+ body[data-masters="villages"] #masters-modal .dda-modal-panel {
156
+ border-radius: 0;
157
+ background: var(--dm-surface);
158
+ border-color: var(--dm-line);
159
+ box-shadow: none;
160
+ }
161
+ body[data-masters="zones"] #masters-modal .btn,
162
+ body[data-masters="villages"] #masters-modal .btn {
163
+ border-radius: 0;
164
+ font-family: var(--dm-font);
165
+ font-weight: 700;
166
+ }
167
+ body[data-masters="zones"] #masters-modal .btn-primary,
168
+ body[data-masters="villages"] #masters-modal .btn-primary {
169
+ background: var(--dm-accent);
170
+ }
171
+ body[data-masters="zones"] #masters-modal .btn-primary:hover,
172
+ body[data-masters="villages"] #masters-modal .btn-primary:hover {
173
+ background: var(--dm-accent-hover);
174
+ filter: none;
175
+ box-shadow: none;
176
+ }
177
+ body[data-masters="zones"] #masters-modal .card-header,
178
+ body[data-masters="villages"] #masters-modal .card-header {
179
+ border-bottom: 2px solid var(--dm-line);
180
+ }
181
+
182
+ /* ---------- Profile ---------- */
183
+
184
+ #tab-profile h1,
185
+ #tab-profile .card-header h3,
186
+ #tab-profile #profile-name,
187
+ #tab-profile .dda-profile-avatar {
188
+ font-family: var(--dm-font);
189
+ }
190
+ #tab-profile h1 { font-weight: 800; letter-spacing: -0.01em; }
191
+
192
+ #tab-profile .card {
193
+ background: var(--dm-surface);
194
+ border: 1px solid var(--dm-line);
195
+ border-radius: 0;
196
+ box-shadow: none;
197
+ }
198
+ #tab-profile .card-header {
199
+ border-bottom: 2px solid var(--dm-line);
200
+ }
201
+ #tab-profile .card-header h3 {
202
+ font-weight: 700;
203
+ color: var(--dm-ink);
204
+ }
205
+
206
+ /* Narrower, fixed-width profile summary column like the mockup */
207
+ #tab-profile .dda-admin-2col-roles {
208
+ grid-template-columns: 280px minmax(0, 1fr);
209
+ }
210
+
211
+ #tab-profile .dda-profile-avatar {
212
+ border-radius: 0;
213
+ background: var(--dm-ink);
214
+ font-weight: 800;
215
+ }
216
+ #tab-profile .dda-profile-badge {
217
+ border-radius: 0;
218
+ background: var(--dm-accent-100);
219
+ color: var(--dm-accent);
220
+ font-weight: 700;
221
+ }
222
+ #tab-profile .dda-profile-meta {
223
+ border-top: 1px solid var(--dm-line);
224
+ }
225
+ #tab-profile .dda-profile-meta-label { color: var(--dm-ink-muted); }
226
+ #tab-profile .dda-profile-meta-value { color: var(--dm-ink); }
227
+
228
+ /* Buttons — flat accent instead of the app-wide purple gradient */
229
+ #tab-profile .btn {
230
+ border-radius: 0;
231
+ font-family: var(--dm-font);
232
+ font-weight: 700;
233
+ }
234
+ #tab-profile .btn-primary { background: var(--dm-accent); }
235
+ #tab-profile .btn-primary:hover { background: var(--dm-accent-hover); filter: none; box-shadow: none; }
236
+
237
+ /* Flat inputs/selects inside both forms */
238
+ #tab-profile input[type="text"],
239
+ #tab-profile input[type="email"],
240
+ #tab-profile input[type="tel"],
241
+ #tab-profile input[type="password"],
242
+ #tab-profile select {
243
+ border-radius: 0;
244
+ background: var(--dm-surface);
245
+ border-color: var(--dm-line);
246
+ }
247
+ #tab-profile .dda-phone-input {
248
+ border-radius: 0;
249
+ border-color: var(--dm-line);
250
+ }
251
+
252
+ #tab-profile .alert { border-radius: 0; }
static/css/dda-modernist-reports.css ADDED
@@ -0,0 +1,286 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ Modernist Reports theme (from the Claude Design redesign).
3
+ Covers two screens:
4
+ - Reports list -> scoped under #tab-reports
5
+ - Report Detail page -> scoped under #page-report-detail
6
+ Same visual language as the Dashboard theme already shipped in
7
+ dda.css (search "#tab-home" there): flat surfaces, hairline
8
+ dividers, sharp corners, one accent color, Archivo for headings
9
+ and numbers. Reuses the shared --dm-* tokens declared once at
10
+ :root in dda.css — nothing is redeclared here. Every selector
11
+ below is scoped to one of the two pages above, so no other
12
+ screen (including the shared result modal used elsewhere) is
13
+ affected by this file.
14
+ ============================================================ */
15
+
16
+ /* ================= Reports list (#tab-reports) ================= */
17
+
18
+ #tab-reports h1,
19
+ #tab-reports .card-title,
20
+ #tab-reports h3 {
21
+ font-family: var(--dm-font);
22
+ }
23
+ #tab-reports h1 {
24
+ font-weight: 800;
25
+ letter-spacing: -0.01em;
26
+ margin: 0 0 0.35rem;
27
+ color: var(--dm-ink);
28
+ }
29
+ #tab-reports .dda-reports-hero .sub {
30
+ margin: 0;
31
+ color: var(--dm-ink-muted);
32
+ }
33
+ #tab-reports .dda-dash-hero-actions { align-items: center; }
34
+
35
+ /* Flat, hairline-bordered surfaces instead of soft shadow cards */
36
+ #tab-reports .card {
37
+ background: var(--dm-surface);
38
+ border: 1px solid var(--dm-line);
39
+ border-radius: 0;
40
+ box-shadow: none;
41
+ }
42
+ #tab-reports .dda-reports-filter-card { padding: 1rem 1.1rem; }
43
+ #tab-reports .dda-filter-bar {
44
+ border: none;
45
+ padding: 0;
46
+ margin: 0;
47
+ background: transparent;
48
+ }
49
+ #tab-reports .dda-filter-field { color: var(--dm-ink-muted); }
50
+ #tab-reports .dda-filter-meta { color: var(--dm-ink-muted); }
51
+
52
+ /* Buttons — flat accent instead of the app-wide purple gradient,
53
+ scoped to this page only so other pages are untouched. */
54
+ #tab-reports .btn { border-radius: 0; font-family: var(--dm-font); font-weight: 700; }
55
+ #tab-reports .btn-primary { background: var(--dm-accent); }
56
+ #tab-reports .btn-primary:hover { background: var(--dm-accent-hover); filter: none; box-shadow: none; }
57
+ #tab-reports .btn-secondary { background: transparent; border-color: var(--dm-line); color: var(--dm-ink); }
58
+ #tab-reports .btn-secondary:hover { background: var(--dm-bg); border-color: var(--dm-line); }
59
+ #tab-reports .btn-ghost { color: var(--dm-accent); }
60
+ #tab-reports .btn-ghost:hover { background: var(--dm-accent-100); color: var(--dm-accent-hover); }
61
+
62
+ /* Table — uppercase hairline header, row-hover, no per-cell boxes */
63
+ #tab-reports .dda-reports-table {
64
+ font-family: var(--font-sans);
65
+ }
66
+ #tab-reports .dda-reports-table th {
67
+ font-size: 0.68rem;
68
+ letter-spacing: 0.06em;
69
+ text-transform: uppercase;
70
+ color: var(--dm-ink-muted);
71
+ font-family: var(--dm-font);
72
+ border: none;
73
+ border-bottom: 2px solid var(--dm-line);
74
+ background: transparent;
75
+ }
76
+ #tab-reports .dda-reports-table td {
77
+ border: none;
78
+ border-bottom: 1px solid var(--dm-line);
79
+ }
80
+ #tab-reports .dda-reports-table tbody tr:hover { background: var(--dm-bg); }
81
+
82
+ /* Change-type / status colors are semantic (CHANGE_TYPE_COLORS) and are
83
+ left untouched here — only the pill shape follows the flat theme. */
84
+ #tab-reports .dda-changetype-pill { border-radius: 2px; }
85
+
86
+ /* Pagination */
87
+ #tab-reports .pagination button {
88
+ border-radius: 0;
89
+ font-family: var(--dm-font);
90
+ }
91
+ #tab-reports .pagination button:hover:not(:disabled) {
92
+ background: var(--dm-accent-100);
93
+ border-color: var(--dm-line);
94
+ color: var(--dm-ink);
95
+ }
96
+ #tab-reports .pagination button.active {
97
+ background: var(--dm-accent);
98
+ border-color: var(--dm-accent);
99
+ color: #fff;
100
+ }
101
+
102
+ @media (max-width: 720px) {
103
+ #tab-reports .dda-dash-hero-actions { width: 100%; justify-content: space-between; }
104
+ }
105
+
106
+ /* ================= Report Detail (#page-report-detail) ================= */
107
+
108
+ #page-report-detail {
109
+ background: var(--dm-bg);
110
+ font-family: var(--font-sans);
111
+ }
112
+ /* Interactive viewer needs the full viewport (static report cards used 1100px). */
113
+ #page-report-detail .dda-report-page {
114
+ max-width: none !important;
115
+ padding: 0 !important;
116
+ width: 100%;
117
+ }
118
+
119
+ /* Header bar — flat surface, hairline bottom border, Archivo brand type */
120
+ #page-report-detail .dda-header.dda-report-header {
121
+ background: var(--dm-surface);
122
+ border: 1px solid var(--dm-line);
123
+ border-bottom: 2px solid var(--dm-line);
124
+ margin: 0;
125
+ padding: 1rem 1.25rem;
126
+ }
127
+ #page-report-detail .app-brand span {
128
+ font-family: var(--dm-font);
129
+ font-weight: 800;
130
+ font-size: 0.82rem;
131
+ letter-spacing: 0.03em;
132
+ text-transform: uppercase;
133
+ color: var(--dm-ink);
134
+ }
135
+ #page-report-detail .dda-report-actions .btn { border-radius: 0; font-family: var(--dm-font); font-weight: 700; }
136
+ #page-report-detail .dda-report-actions .btn-primary { background: var(--dm-accent); }
137
+ #page-report-detail .dda-report-actions .btn-primary:hover { background: var(--dm-accent-hover); filter: none; box-shadow: none; }
138
+ #page-report-detail .dda-report-actions .btn-primary:disabled { background: var(--dm-line); color: var(--dm-ink-dim); }
139
+ #page-report-detail .dda-report-actions .btn-secondary { background: transparent; border-color: var(--dm-line); color: var(--dm-ink); }
140
+ #page-report-detail .dda-report-actions .btn-secondary:hover { background: var(--dm-bg); border-color: var(--dm-line); }
141
+
142
+ /* Cards — flat, hairline-bordered, sharp corners */
143
+ #page-report-detail .card {
144
+ background: var(--dm-surface);
145
+ border: 1px solid var(--dm-line);
146
+ border-radius: 0;
147
+ box-shadow: none;
148
+ padding: 1.4rem 1.5rem;
149
+ }
150
+ #page-report-detail .card + .card { margin-top: 1.25rem; }
151
+ #page-report-detail .card h1,
152
+ #page-report-detail .card h3 {
153
+ font-family: var(--dm-font);
154
+ color: var(--dm-ink);
155
+ }
156
+ #page-report-detail #report-title { font-weight: 800; letter-spacing: -0.01em; font-size: 1.5rem; margin: 0 0 0.35rem; }
157
+ #page-report-detail .card h3 { font-weight: 700; font-size: 0.95rem; margin: 0 0 0.9rem; }
158
+ #page-report-detail #report-meta { color: var(--dm-ink-muted); }
159
+
160
+ /* Summary stats — hairline grid of flat cells instead of a loose flex row */
161
+ #page-report-detail .dda-report-stats {
162
+ display: grid;
163
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
164
+ gap: 1px;
165
+ background: var(--dm-line);
166
+ border: 1px solid var(--dm-line);
167
+ margin-top: 1rem;
168
+ }
169
+ #page-report-detail .dda-stat {
170
+ background: var(--dm-surface);
171
+ padding: 0.85rem 0.9rem;
172
+ }
173
+ #page-report-detail .dda-stat .label {
174
+ font-size: 0.68rem;
175
+ letter-spacing: 0.05em;
176
+ text-transform: uppercase;
177
+ color: var(--dm-ink-muted);
178
+ }
179
+ #page-report-detail .dda-stat .value {
180
+ font-family: var(--dm-font);
181
+ font-weight: 800;
182
+ font-size: 1.35rem;
183
+ color: var(--dm-ink);
184
+ }
185
+
186
+ #page-report-detail .dda-report-review-summary {
187
+ margin: 0.85rem 0 0;
188
+ font-size: 0.85rem;
189
+ }
190
+ #page-report-detail .dda-report-review-summary strong { color: var(--dm-ink); }
191
+ #page-report-detail .dda-report-actions-row {
192
+ display: flex;
193
+ flex-wrap: wrap;
194
+ gap: 0.6rem;
195
+ margin-top: 0.85rem;
196
+ }
197
+ #page-report-detail .dda-report-actions-row .btn { border-radius: 0; font-family: var(--dm-font); font-weight: 700; }
198
+ #page-report-detail .dda-report-actions-row .btn-primary { background: var(--dm-accent); }
199
+ #page-report-detail .dda-report-actions-row .btn-primary:hover { background: var(--dm-accent-hover); filter: none; box-shadow: none; }
200
+ #page-report-detail .dda-report-actions-row .btn-primary:disabled { background: var(--dm-line); color: var(--dm-ink-dim); }
201
+ #page-report-detail .dda-report-actions-row .btn-secondary { background: transparent; border-color: var(--dm-line); color: var(--dm-ink); }
202
+ #page-report-detail .dda-report-actions-row .btn-secondary:hover { background: var(--dm-bg); border-color: var(--dm-line); }
203
+ #page-report-detail #report-submit-msg { margin-top: 0.5rem; font-size: 0.8rem; }
204
+
205
+ /* Overlay image — flat bordered frame with a small labeled chip,
206
+ same treatment as the Before/After panel in the design mockup. */
207
+ #page-report-detail .dda-report-overlay-frame {
208
+ position: relative;
209
+ display: inline-block;
210
+ max-width: 100%;
211
+ }
212
+ #page-report-detail .dda-report-overlay-chip {
213
+ position: absolute;
214
+ top: 0.65rem;
215
+ left: 0.65rem;
216
+ z-index: 1;
217
+ background: var(--dm-surface);
218
+ border: 1px solid var(--dm-line);
219
+ color: var(--dm-ink);
220
+ font-family: var(--dm-font);
221
+ font-weight: 700;
222
+ font-size: 0.68rem;
223
+ letter-spacing: 0.04em;
224
+ text-transform: uppercase;
225
+ padding: 0.25rem 0.6rem;
226
+ }
227
+ #page-report-detail .dda-report-overlay {
228
+ border-radius: 0;
229
+ border: 1px solid var(--dm-line);
230
+ background: var(--dm-bg);
231
+ display: block;
232
+ max-width: 100%;
233
+ height: auto;
234
+ }
235
+
236
+ /* Detected regions table */
237
+ #page-report-detail .table-wrap { overflow-x: auto; }
238
+ #page-report-detail .dda-report-regions {
239
+ width: 100%;
240
+ border-collapse: collapse;
241
+ }
242
+ #page-report-detail .dda-report-regions th {
243
+ font-size: 0.68rem;
244
+ letter-spacing: 0.06em;
245
+ text-transform: uppercase;
246
+ color: var(--dm-ink-muted);
247
+ font-family: var(--dm-font);
248
+ border: none;
249
+ border-bottom: 2px solid var(--dm-line);
250
+ background: transparent;
251
+ text-align: left;
252
+ padding: 0.5rem 0.6rem;
253
+ }
254
+ #page-report-detail .dda-report-regions td {
255
+ border: none;
256
+ border-bottom: 1px solid var(--dm-line);
257
+ padding: 0.5rem 0.6rem;
258
+ }
259
+ #page-report-detail .dda-report-regions tbody tr:hover { background: var(--dm-bg); }
260
+ #page-report-detail .dda-report-regions .btn { border-radius: 0; font-family: var(--dm-font); font-weight: 700; }
261
+ #page-report-detail .dda-report-regions .btn-secondary { background: transparent; border-color: var(--dm-line); color: var(--dm-ink); }
262
+ #page-report-detail .dda-report-regions .btn-secondary:hover { background: var(--dm-bg); border-color: var(--dm-line); }
263
+
264
+ /* Email this report */
265
+ #page-report-detail .dda-report-email input[type="email"] {
266
+ border-radius: 0;
267
+ border: 1px solid var(--dm-line);
268
+ background: var(--dm-surface);
269
+ }
270
+ #page-report-detail .dda-report-email input[type="email"]:focus {
271
+ border-color: var(--dm-accent);
272
+ box-shadow: none;
273
+ }
274
+ #page-report-detail .dda-report-email .btn { border-radius: 0; font-family: var(--dm-font); font-weight: 700; }
275
+ #page-report-detail .dda-report-email .btn-secondary { background: transparent; border-color: var(--dm-line); color: var(--dm-ink); }
276
+ #page-report-detail .dda-report-email .btn-secondary:hover { background: var(--dm-bg); border-color: var(--dm-line); }
277
+ #page-report-detail .dda-report-email .btn-secondary:disabled { color: var(--dm-ink-dim); border-color: var(--dm-line); }
278
+
279
+ /* Loading / error states */
280
+ #page-report-detail #report-loading { border-radius: 0; }
281
+ #page-report-detail .alert-error { border-radius: 0; }
282
+
283
+ @media (max-width: 720px) {
284
+ #page-report-detail .dda-report-header { flex-direction: column; align-items: stretch; }
285
+ #page-report-detail .dda-report-stats { grid-template-columns: repeat(2, 1fr); }
286
+ }
static/css/dda-modernist-result-modal.css ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ Modernist theme for the interactive Result modal (#result-modal)
3
+ — shared by the Change Detection page (shown right after a run)
4
+ and the standalone report page. Scoped entirely to
5
+ #result-modal so nothing else is affected.
6
+ ============================================================ */
7
+
8
+ #result-modal.result-modal-backdrop {
9
+ position: fixed !important;
10
+ inset: 0 !important;
11
+ z-index: 10000 !important;
12
+ /* Solid — never let the page underneath bleed through. */
13
+ background: var(--dm-bg, #f3f2f2) !important;
14
+ backdrop-filter: none !important;
15
+ -webkit-backdrop-filter: none !important;
16
+ display: flex;
17
+ align-items: stretch;
18
+ justify-content: center;
19
+ overflow-y: auto;
20
+ }
21
+ #result-modal .result-modal {
22
+ background: var(--dm-bg, #f3f2f2);
23
+ border-radius: 0;
24
+ box-shadow: none;
25
+ min-height: 100vh;
26
+ min-height: 100dvh;
27
+ width: 100%;
28
+ max-width: 100%;
29
+ }
30
+
31
+ /* --- Header --- */
32
+ #result-modal .result-modal-header h3 {
33
+ font-family: var(--dm-font);
34
+ font-weight: 800;
35
+ font-size: 1.3rem;
36
+ letter-spacing: -0.01em;
37
+ color: var(--dm-ink);
38
+ }
39
+ #result-modal .result-modal-header-actions .btn {
40
+ border-radius: 0;
41
+ font-family: var(--dm-font);
42
+ font-weight: 700;
43
+ }
44
+ #result-modal .result-modal-header-actions .btn-secondary {
45
+ background: transparent;
46
+ border-color: var(--dm-line);
47
+ color: var(--dm-ink);
48
+ }
49
+ #result-modal .result-modal-header-actions .btn-secondary:hover { background: var(--dm-surface); }
50
+ #result-modal .btn-close-modal:hover { background: var(--dm-accent-100); color: var(--dm-accent); border-radius: 0; }
51
+
52
+ /* --- Stat grid --- */
53
+ #result-modal .result-stats {
54
+ display: grid;
55
+ grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
56
+ gap: 1px;
57
+ background: var(--dm-line);
58
+ border: 1px solid var(--dm-line);
59
+ margin-bottom: 0.75rem;
60
+ }
61
+ #result-modal .stat-box {
62
+ background: var(--dm-surface);
63
+ border: none;
64
+ border-radius: 0;
65
+ padding: 0.85rem 0.75rem;
66
+ }
67
+ #result-modal .stat-box:hover { border-color: transparent; }
68
+ #result-modal .stat-box .value {
69
+ font-family: var(--dm-font);
70
+ font-weight: 800;
71
+ background: none;
72
+ -webkit-background-clip: initial;
73
+ background-clip: initial;
74
+ -webkit-text-fill-color: var(--dm-ink);
75
+ color: var(--dm-ink);
76
+ }
77
+ #result-modal .stat-box .label {
78
+ color: var(--dm-ink-muted);
79
+ font-weight: 600;
80
+ }
81
+
82
+ /* --- View toolbar --- */
83
+ #result-modal .dda-view-toolbar {
84
+ border: 1px solid var(--dm-line);
85
+ padding: 0.3rem;
86
+ display: inline-flex;
87
+ gap: 0;
88
+ width: auto;
89
+ }
90
+ #result-modal .dda-view-label { color: var(--dm-ink-muted); margin: 0 0.5rem 0 0.15rem; }
91
+ #result-modal .dda-view-btn {
92
+ border-radius: 0;
93
+ font-family: var(--dm-font);
94
+ font-weight: 700;
95
+ }
96
+ #result-modal .dda-view-btn:hover { background: var(--dm-bg); }
97
+ #result-modal .dda-view-btn.active {
98
+ background: var(--dm-accent);
99
+ color: #fff;
100
+ }
101
+ #result-modal #dda-locate-btn.btn-secondary {
102
+ border-radius: 0;
103
+ font-family: var(--dm-font);
104
+ font-weight: 700;
105
+ }
106
+ #result-modal .btn-zoom {
107
+ border-radius: 0;
108
+ font-family: var(--dm-font);
109
+ }
110
+ #result-modal .zoom-level { font-family: var(--dm-font); font-weight: 700; color: var(--dm-ink); }
111
+
112
+ #result-modal .compare-slider { border: 1px solid var(--dm-line); border-radius: 0; }
113
+ #result-modal .compare-label {
114
+ border-radius: 0;
115
+ font-family: var(--dm-font);
116
+ font-weight: 700;
117
+ }
118
+ #result-modal .compare-label-left { background: var(--dm-ink); color: #fff; }
119
+ #result-modal .compare-label-right { background: var(--dm-accent); color: #fff; }
120
+ #result-modal .compare-handle-knob { border-radius: 0; background: var(--dm-ink); color: #fff; }
121
+
122
+ #result-modal .dda-map-toolbar {
123
+ border: 1px solid var(--dm-line);
124
+ border-radius: 0;
125
+ }
126
+ #result-modal .dda-map-toolbar select,
127
+ #result-modal .dda-map-xyz {
128
+ border-radius: 0;
129
+ }
130
+ #result-modal .dda-map-toolbar .btn { border-radius: 0; font-family: var(--dm-font); font-weight: 700; }
131
+ #result-modal .dda-map-canvas { border: 1px solid var(--dm-line); border-radius: 0; }
132
+
133
+ #result-modal .result-modal-layout {
134
+ align-items: stretch;
135
+ }
136
+ #result-modal .result-modal-side {
137
+ border-left-color: var(--dm-line);
138
+ display: flex;
139
+ flex-direction: column;
140
+ min-height: 0;
141
+ max-height: calc(100vh - 12rem);
142
+ max-height: calc(100dvh - 12rem);
143
+ }
144
+ #result-modal .dda-review-summary { color: var(--dm-ink-muted); }
145
+ #result-modal .dda-review-summary strong { color: var(--dm-ink); }
146
+ #result-modal .dda-review-actions .btn {
147
+ border-radius: 0;
148
+ font-family: var(--dm-font);
149
+ font-weight: 700;
150
+ }
151
+ #result-modal .dda-review-actions .btn-secondary {
152
+ background: transparent;
153
+ border-color: var(--dm-line);
154
+ color: var(--dm-ink);
155
+ }
156
+ #result-modal .dda-review-actions .btn-secondary:hover { background: var(--dm-surface); }
157
+ #result-modal .dda-review-actions .btn-primary { background: var(--dm-accent); }
158
+ #result-modal .dda-review-actions .btn-primary:hover { background: var(--dm-accent-hover); filter: none; box-shadow: none; }
159
+
160
+ #result-modal .regions-table {
161
+ border-collapse: separate;
162
+ border-spacing: 0;
163
+ }
164
+ #result-modal .regions-table th {
165
+ font-family: var(--dm-font);
166
+ text-transform: uppercase;
167
+ letter-spacing: 0.05em;
168
+ font-size: 0.68rem;
169
+ color: var(--dm-ink-muted);
170
+ position: sticky;
171
+ top: 0;
172
+ z-index: 3;
173
+ background: var(--dm-surface, #fff);
174
+ box-shadow: 0 1px 0 var(--dm-line);
175
+ }
176
+ #result-modal .regions-table td {
177
+ border-color: var(--dm-line);
178
+ background: var(--dm-surface, #fff);
179
+ }
180
+ #result-modal .regions-table td:nth-child(2) { color: var(--dm-ink); }
181
+ #result-modal .regions-table tr:hover td { background: var(--dm-bg); }
182
+ #result-modal .regions-table tr.region-hover td { background: var(--dm-accent-100); }
183
+ #result-modal .regions-table tr.region-selected td { background: var(--dm-accent-100); }
184
+ #result-modal .severity-badge,
185
+ #result-modal .review-badge {
186
+ border-radius: 2px;
187
+ }
188
+ #result-modal .dda-review-btns .btn { border-radius: 0; }
189
+ #result-modal .pagination { font-family: var(--dm-font); }
190
+
191
+ #result-modal .result-modal-side .regions-table-wrap {
192
+ flex: 1 1 auto;
193
+ min-height: 12rem;
194
+ max-height: none;
195
+ overflow: auto;
196
+ overscroll-behavior: contain;
197
+ -webkit-overflow-scrolling: touch;
198
+ }
199
+ #result-modal .result-modal-side .regions-table {
200
+ min-width: 960px;
201
+ }
202
+ #result-modal .result-modal-side > .pagination {
203
+ flex: 0 0 auto;
204
+ }
205
+
206
+ /* ---- Standalone report page: never clip the viewport ----
207
+ The report page embeds the result UI in-flow (not a fixed overlay).
208
+ Cap-heights + body overflow:hidden were cutting the bottom of the screen. */
209
+ #page-report-detail {
210
+ overflow: auto !important;
211
+ height: auto !important;
212
+ min-height: 100vh;
213
+ }
214
+ #page-report-detail .dda-report-page {
215
+ overflow: visible !important;
216
+ height: auto !important;
217
+ }
218
+ #page-report-detail #result-modal.result-modal-backdrop,
219
+ .dda-report-page #result-modal.result-modal-backdrop {
220
+ position: relative !important;
221
+ inset: auto !important;
222
+ z-index: 1 !important;
223
+ display: block !important;
224
+ overflow: visible !important;
225
+ min-height: 0 !important;
226
+ height: auto !important;
227
+ background: var(--dm-bg, #f3f2f2) !important;
228
+ backdrop-filter: none !important;
229
+ }
230
+ #page-report-detail #result-modal .result-modal,
231
+ .dda-report-page #result-modal .result-modal {
232
+ min-height: 0 !important;
233
+ height: auto !important;
234
+ overflow: visible !important;
235
+ }
236
+ #page-report-detail #result-modal .result-modal-layout,
237
+ .dda-report-page #result-modal .result-modal-layout {
238
+ align-items: start;
239
+ }
240
+ #page-report-detail #result-modal .result-modal-side,
241
+ .dda-report-page #result-modal .result-modal-side {
242
+ max-height: none !important;
243
+ height: auto !important;
244
+ overflow: visible !important;
245
+ }
246
+ #page-report-detail #result-modal .result-modal-side .regions-table-wrap,
247
+ .dda-report-page #result-modal .result-modal-side .regions-table-wrap {
248
+ flex: none !important;
249
+ max-height: none !important;
250
+ min-height: 0 !important;
251
+ /* Only horizontal scroll for wide columns; vertical uses the page. */
252
+ overflow-x: auto !important;
253
+ overflow-y: visible !important;
254
+ }
255
+ #page-report-detail #result-modal .regions-table th,
256
+ .dda-report-page #result-modal .regions-table th {
257
+ position: static;
258
+ box-shadow: none;
259
+ }
260
+ #page-report-detail .dda-header.dda-report-header {
261
+ margin: 0 !important;
262
+ }
263
+
264
+ /* --- Editable report title --- */
265
+ #result-modal .result-title-edit-btn {
266
+ border-radius: 0;
267
+ color: var(--dm-ink-muted);
268
+ }
269
+ #result-modal .result-title-edit-btn:hover { background: var(--dm-accent-100); color: var(--dm-accent); }
270
+ #result-modal .result-title-input {
271
+ border-radius: 0;
272
+ border-color: var(--dm-line);
273
+ font-family: var(--dm-font);
274
+ }
275
+ #result-modal .result-modal-title-edit .btn {
276
+ border-radius: 0;
277
+ font-family: var(--dm-font);
278
+ font-weight: 700;
279
+ }
280
+ #result-modal .result-modal-title-edit .btn-primary { background: var(--dm-accent); }
281
+ #result-modal .result-modal-title-edit .btn-primary:hover { background: var(--dm-accent-hover); filter: none; box-shadow: none; }
282
+ #result-modal .result-title-msg { color: var(--dm-accent); }
static/css/dda-modernist-sidebar.css ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ Modernist theme for the main left sidebar (navbar_dda.html) —
3
+ shared, identical partial injected into every page, so this
4
+ reskin applies globally rather than being scoped to one page.
5
+ Reuses the shared --dm-* tokens declared once at :root in
6
+ dda.css: flat surfaces, hairline dividers, sharp corners, one
7
+ accent color, Archivo for the brand/nav. Pure visual restyle —
8
+ the DB-driven menu structure, RBAC, and every id/class app.js/
9
+ notifications.js select on are untouched.
10
+ ============================================================ */
11
+
12
+ .dda-sidebar-nav {
13
+ background: var(--dm-bg);
14
+ border-right: 2px solid var(--dm-line);
15
+ }
16
+
17
+ /* --- Brand --- */
18
+ .dda-sidebar-brand > svg { color: var(--dm-accent); }
19
+ .dda-sidebar-brand-name {
20
+ font-family: var(--dm-font);
21
+ font-weight: 800;
22
+ font-size: 1rem;
23
+ letter-spacing: 0.02em;
24
+ background: none;
25
+ -webkit-background-clip: initial;
26
+ background-clip: initial;
27
+ color: var(--dm-ink);
28
+ }
29
+ .dda-sidebar-brand-sub { color: var(--dm-ink-muted); text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.68rem; }
30
+ .dda-sidebar-toggle { border-color: var(--dm-line); border-radius: 0; color: var(--dm-ink-muted); }
31
+ .dda-sidebar-toggle:hover { background: var(--dm-accent-100); color: var(--dm-accent); }
32
+
33
+ /* --- Nav tabs --- */
34
+ .dda-tab {
35
+ border-radius: 0;
36
+ border-left: 2px solid transparent;
37
+ color: var(--dm-ink);
38
+ font-weight: 500;
39
+ }
40
+ .dda-tab:hover { background: var(--dm-bg); color: var(--dm-ink); }
41
+ .dda-tab.active {
42
+ background: var(--dm-accent-100);
43
+ border-left-color: var(--dm-accent);
44
+ color: var(--dm-accent);
45
+ font-weight: 700;
46
+ }
47
+ .dda-subtab { color: var(--dm-ink); font-weight: 500; }
48
+ .dda-subtab.active {
49
+ background: var(--dm-accent-100);
50
+ border-left-color: var(--dm-accent);
51
+ color: var(--dm-accent);
52
+ font-weight: 700;
53
+ }
54
+ .dda-sidebar-submenu { border-left-color: var(--dm-line); }
55
+ .dda-sidebar-group > summary.dda-tab-group { border-radius: 0; }
56
+
57
+ /* --- Footer: notifications + user menu --- */
58
+ .dda-sidebar-footer { border-top-color: var(--dm-line); }
59
+ .dda-notify-btn { border-color: var(--dm-line); border-radius: 0; color: var(--dm-ink); }
60
+ .dda-notify-btn:hover { background: var(--dm-accent-100); color: var(--dm-accent); }
61
+ .dda-notify-badge { background: var(--dm-accent); border-radius: 0; }
62
+ .dda-notify-panel { border-radius: 0; border-color: var(--dm-line); background: var(--dm-surface); }
63
+ .dda-notify-item { border-radius: 0; }
64
+ .dda-notify-item:hover { background: var(--dm-bg); }
65
+ .dda-notify-item.unread { background: var(--dm-accent-100); }
66
+
67
+ .dda-user-avatar {
68
+ border-radius: 0;
69
+ background: var(--dm-ink);
70
+ }
71
+ .dda-user-wrap #dda-user-email { color: var(--dm-ink); }
72
+ .dda-user-menu-panel { border-top-color: var(--dm-line); }
73
+ .dda-user-menu-link { border-radius: 0; color: var(--dm-ink); }
74
+ .dda-user-menu-link:hover { background: var(--dm-accent-100); color: var(--dm-accent); }
75
+ .dda-user-menu-link.active { color: var(--dm-accent); font-weight: 700; }
76
+ .dda-sidebar-nav.collapsed .dda-user-menu-panel { border-radius: 0; border-color: var(--dm-line); background: var(--dm-surface); box-shadow: none; }
static/css/dda.css CHANGED
@@ -1,4 +1,23 @@
1
  /* Vedangsoft dev UI — extends style.css */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  .dda-app {
4
  max-width: 1600px;
@@ -287,7 +306,7 @@
287
  help text collapses behind <details> — together these keep the upload
288
  card short so the page reads as three compact sections, not four tall
289
  stacked cards. */
290
- .dda-upload-row { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
291
 
292
  /* Detect page: manual-mode pairs + library grid split into their own cards
293
  (same pattern as the Library page) instead of nesting inside the main
@@ -397,6 +416,19 @@
397
  .dda-tree-node-summary { list-style: none; cursor: pointer; }
398
  .dda-tree-node-summary .dda-tree-node-btn { display: inline; width: auto; }
399
  .dda-tree-children { padding-left: 0.65rem; border-left: 1px solid var(--border); margin-left: 0.35rem; }
 
 
 
 
 
 
 
 
 
 
 
 
 
400
  .dda-tree-leaf { padding-left: 0.75rem; }
401
  .dda-crumb { font-size: 0.78rem; line-height: 1.3; }
402
  .dda-manage-panel { max-width: 640px; width: 100%; }
@@ -516,8 +548,9 @@
516
  line-height: 1.3;
517
  /* Fixed height regardless of path length — the variable-length crumb
518
  text (folder path + filename) was the other source of uneven card
519
- heights, wrapping to 1-3 lines depending on the image. */
520
- height: 2.6em;
 
521
  overflow: hidden;
522
  }
523
  .dda-card-img .meta .dda-crumb {
@@ -526,6 +559,12 @@
526
  overflow: hidden;
527
  text-overflow: ellipsis;
528
  }
 
 
 
 
 
 
529
 
530
  .dda-compare-slots {
531
  display: grid;
@@ -607,6 +646,89 @@
607
  }
608
  .dda-detect-options .form-group { margin: 0; min-width: 160px; }
609
  .dda-check { display: flex; align-items: center; gap: 0.35rem; font-size: 0.88rem; color: var(--text-muted); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
610
 
611
  .dda-modal {
612
  position: fixed;
@@ -703,6 +825,9 @@
703
  }
704
  .dda-image-viewer-status { min-height: 1.2rem; margin: 0 0 0.5rem; }
705
  .dda-image-viewer-status.dda-viewer-error { color: var(--danger, #dc2626); }
 
 
 
706
 
707
  .dda-viewer-mode-bar {
708
  display: flex;
@@ -793,6 +918,19 @@
793
  }
794
 
795
  .dda-upload-form .location-row { margin-bottom: 0.5rem; }
 
 
 
 
 
 
 
 
 
 
 
 
 
796
 
797
  .dda-upload-progress { margin: 0.75rem 0 1rem; }
798
  .dda-progress-bar {
@@ -919,8 +1057,112 @@
919
  .dda-report-actions-cell { white-space: nowrap; }
920
  .dda-report-actions-cell .btn, .dda-report-actions-cell a.btn { margin-right: 0.25rem; margin-bottom: 0.25rem; }
921
 
922
- /* Standalone report page has no sidebar nav — override the sidebar-shell flex layout. */
923
- .dda-report-page { display: block; max-width: 960px; margin: 0 auto; padding: 1rem; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
924
  .dda-header {
925
  display: flex;
926
  flex-wrap: wrap;
@@ -1014,11 +1256,18 @@
1014
  .regions-table-wrap .regions-table {
1015
  min-width: 960px;
1016
  }
 
 
 
 
1017
 
1018
  @media (max-width: 768px) {
1019
  .dda-compare-slots {
1020
  grid-template-columns: 1fr;
1021
  }
 
 
 
1022
  }
1023
 
1024
  /* ROI (region-of-interest) draw tool on the T2 preview */
@@ -1384,7 +1633,8 @@
1384
  }
1385
  .dda-filter-field .dda-search,
1386
  .dda-filter-field select,
1387
- .dda-date-input {
 
1388
  min-width: 9.5rem;
1389
  margin-bottom: 0;
1390
  padding: 0.4rem 0.55rem;
@@ -1493,7 +1743,8 @@ time.dda-date {
1493
  @media (max-width: 720px) {
1494
  .dda-filter-field .dda-search,
1495
  .dda-filter-field select,
1496
- .dda-date-input,
 
1497
  .dda-datewrap { min-width: 100%; }
1498
  .dda-run-bar { position: static; }
1499
  }
@@ -1508,6 +1759,50 @@ time.dda-date {
1508
  }
1509
  .dda-dash-hero-actions { display: flex; align-items: center; gap: 0.75rem; }
1510
  .dda-dash-updated { font-size: 0.8rem; white-space: nowrap; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1511
 
1512
  .dda-dash-kpi-grid {
1513
  display: grid;
@@ -1518,9 +1813,20 @@ a.stat-box.dda-dash-kpi {
1518
  display: flex;
1519
  flex-direction: column;
1520
  text-decoration: none;
1521
- transition: border-color 0.15s ease, transform 0.15s ease;
 
 
 
 
 
 
1522
  }
1523
- a.stat-box.dda-dash-kpi:hover { border-color: var(--accent); transform: translateY(-1px); }
 
 
 
 
 
1524
 
1525
  .dda-dash-latest-row { display: flex; gap: 1.25rem; align-items: flex-start; flex-wrap: wrap; }
1526
  .dda-dash-latest-thumb {
@@ -1542,9 +1848,23 @@ a.stat-box.dda-dash-kpi:hover { border-color: var(--accent); transform: translat
1542
  .dda-dash-chart-grid {
1543
  display: grid;
1544
  grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
1545
- gap: 1.5rem;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1546
  }
1547
- .dda-dash-chart-box h4 { margin: 0 0 0.6rem; font-size: 0.9rem; color: var(--text-muted); }
1548
 
1549
  .dda-dash-compare-grid {
1550
  display: grid;
@@ -1561,31 +1881,319 @@ a.stat-box.dda-dash-kpi:hover { border-color: var(--accent); transform: translat
1561
  .dda-dash-compare-col p { margin: 0 0 0.35rem; }
1562
  .dda-dash-delta { font-weight: 700; font-family: var(--font-mono); }
1563
  .dda-dash-history-title { margin: 0 0 0.6rem; font-size: 0.95rem; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1564
 
1565
  @media (max-width: 720px) {
1566
  .dda-dash-latest-row { flex-direction: column; }
1567
  .dda-dash-latest-thumb { width: 100%; height: 160px; }
1568
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1569
  /* Styled calendar date picker */
1570
  .dda-datewrap {
1571
  position: relative;
1572
- min-width: 11.5rem;
 
1573
  overflow: visible;
1574
  }
 
 
1575
  .dda-date-native {
1576
- position: absolute;
1577
- width: 1px;
1578
- height: 1px;
1579
- opacity: 0;
 
 
 
 
 
 
 
1580
  pointer-events: none;
 
 
 
 
 
 
 
1581
  }
1582
  .dda-date-trigger {
1583
  display: flex;
1584
  align-items: center;
1585
  gap: 0.5rem;
1586
  width: 100%;
 
1587
  min-height: 2.35rem;
1588
  padding: 0.4rem 0.7rem;
 
1589
  border: 1px solid var(--border);
1590
  border-radius: 8px;
1591
  background: var(--bg-card);
@@ -1602,6 +2210,12 @@ a.stat-box.dda-dash-kpi:hover { border-color: var(--accent); transform: translat
1602
  box-shadow: 0 0 0 3px var(--accent-glow);
1603
  }
1604
  .dda-date-placeholder { color: var(--text-dim); font-weight: 450; }
 
 
 
 
 
 
1605
  .dda-date-clear {
1606
  margin-left: auto;
1607
  color: var(--text-dim);
 
1
  /* Vedangsoft dev UI — extends style.css */
2
+ @import url('https://fonts.googleapis.com/css2?family=Archivo:wght@400;600;800&display=swap');
3
+
4
+ /* Modernist design tokens (from the Claude Design redesign) — shared by
5
+ every screen that opts into the new look via its own scoped block below.
6
+ Global/additive: these names don't collide with anything style.css
7
+ already defines, so declaring them at :root can't affect pages that
8
+ don't reference them. */
9
+ :root {
10
+ --dm-bg: #f3f2f2;
11
+ --dm-surface: #ffffff;
12
+ --dm-line: rgba(32, 30, 29, 0.14);
13
+ --dm-ink: #201e1d;
14
+ --dm-ink-muted: #7d7979;
15
+ --dm-ink-dim: #9b9797;
16
+ --dm-accent: #ec3013;
17
+ --dm-accent-hover: #dd2b0f;
18
+ --dm-accent-100: #fff2ef;
19
+ --dm-font: 'Archivo', system-ui, sans-serif;
20
+ }
21
 
22
  .dda-app {
23
  max-width: 1600px;
 
306
  help text collapses behind <details> — together these keep the upload
307
  card short so the page reads as three compact sections, not four tall
308
  stacked cards. */
309
+ .dda-upload-row { grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); }
310
 
311
  /* Detect page: manual-mode pairs + library grid split into their own cards
312
  (same pattern as the Library page) instead of nesting inside the main
 
416
  .dda-tree-node-summary { list-style: none; cursor: pointer; }
417
  .dda-tree-node-summary .dda-tree-node-btn { display: inline; width: auto; }
418
  .dda-tree-children { padding-left: 0.65rem; border-left: 1px solid var(--border); margin-left: 0.35rem; }
419
+ .dda-tree-toolbar { display: flex; gap: 0.5rem; margin: -0.35rem 0 0.6rem; }
420
+ .dda-tree-toolbar-btn {
421
+ border: none;
422
+ background: transparent;
423
+ color: var(--text-muted);
424
+ font-size: 0.76rem;
425
+ padding: 0;
426
+ cursor: pointer;
427
+ text-decoration: underline;
428
+ text-underline-offset: 2px;
429
+ }
430
+ .dda-tree-toolbar-btn:hover { color: var(--grad-start); }
431
+ .dda-tree-hit { background: rgba(255, 205, 60, 0.45); color: inherit; border-radius: 2px; }
432
  .dda-tree-leaf { padding-left: 0.75rem; }
433
  .dda-crumb { font-size: 0.78rem; line-height: 1.3; }
434
  .dda-manage-panel { max-width: 640px; width: 100%; }
 
548
  line-height: 1.3;
549
  /* Fixed height regardless of path length — the variable-length crumb
550
  text (folder path + filename) was the other source of uneven card
551
+ heights, wrapping to 1-4 lines depending on the image: crumb, optional
552
+ village/zone place, capture date/type/size/dims, uploaded date. */
553
+ height: 5.2em;
554
  overflow: hidden;
555
  }
556
  .dda-card-img .meta .dda-crumb {
 
559
  overflow: hidden;
560
  text-overflow: ellipsis;
561
  }
562
+ .dda-card-img .meta .dda-card-uploaded {
563
+ display: block;
564
+ white-space: nowrap;
565
+ overflow: hidden;
566
+ text-overflow: ellipsis;
567
+ }
568
 
569
  .dda-compare-slots {
570
  display: grid;
 
646
  }
647
  .dda-detect-options .form-group { margin: 0; min-width: 160px; }
648
  .dda-check { display: flex; align-items: center; gap: 0.35rem; font-size: 0.88rem; color: var(--text-muted); }
649
+ .dda-sensitivity-field label { display: flex; align-items: baseline; gap: 0.35rem; }
650
+ .dda-sensitivity-field em { font-style: normal; color: var(--text); font-variant-numeric: tabular-nums; }
651
+ .dda-sensitivity-field input[type="range"] { width: 100%; min-width: 140px; }
652
+ .dda-field-hint { font-size: 0.78rem; margin: 0.3rem 0 0; color: var(--accent-dim); font-weight: 500; }
653
+
654
+ /* Min region area: a unified [−] [140 px] [+] stepper control — one bordered
655
+ group instead of a plain number input, since the browser's own spinner
656
+ arrows collide with our "px" suffix and can't be styled consistently. */
657
+ .dda-stepper-wrap {
658
+ display: flex;
659
+ align-items: stretch;
660
+ border: 1px solid var(--border);
661
+ border-radius: var(--radius-sm);
662
+ background: var(--bg-elevated);
663
+ overflow: hidden;
664
+ transition: border-color var(--transition), box-shadow var(--transition);
665
+ }
666
+ .dda-stepper-wrap:focus-within {
667
+ border-color: var(--border-focus);
668
+ box-shadow: 0 0 0 3px rgba(46, 51, 197, 0.18);
669
+ }
670
+ .dda-stepper-wrap .dda-input-suffix-wrap { flex: 1; min-width: 0; }
671
+ .dda-stepper-wrap input[type="number"] {
672
+ border: none;
673
+ border-radius: 0;
674
+ background: transparent;
675
+ }
676
+ .dda-stepper-wrap input[type="number"]:focus { outline: none; box-shadow: none; }
677
+ .dda-stepper-btn {
678
+ flex: 0 0 auto;
679
+ width: 2.25rem;
680
+ margin: 0;
681
+ padding: 0;
682
+ display: flex;
683
+ align-items: center;
684
+ justify-content: center;
685
+ border: none;
686
+ background: transparent;
687
+ color: var(--text-muted);
688
+ font-size: 1.15rem;
689
+ line-height: 1;
690
+ font-family: var(--font-sans);
691
+ cursor: pointer;
692
+ transition: background var(--transition), color var(--transition);
693
+ }
694
+ .dda-stepper-btn:first-child { border-right: 1px solid var(--border); }
695
+ .dda-stepper-btn:last-child { border-left: 1px solid var(--border); }
696
+ .dda-stepper-btn:hover:not(:disabled) { background: var(--bg-hover); color: var(--accent-dim); }
697
+ .dda-stepper-btn:active:not(:disabled) { background: var(--accent-glow); }
698
+ .dda-stepper-btn:disabled { opacity: 0.35; cursor: not-allowed; }
699
+
700
+ .dda-input-suffix-wrap {
701
+ position: relative;
702
+ display: flex;
703
+ align-items: center;
704
+ }
705
+ .dda-input-suffix-wrap input[type="number"] {
706
+ padding-right: 2.75rem;
707
+ /* Hide the native spinner — it renders at a browser-chosen width that
708
+ collides with our own "px" suffix instead of sitting cleanly beside it;
709
+ our own stepper buttons replace its increment/decrement role. */
710
+ -moz-appearance: textfield;
711
+ }
712
+ .dda-input-suffix-wrap input[type="number"]::-webkit-outer-spin-button,
713
+ .dda-input-suffix-wrap input[type="number"]::-webkit-inner-spin-button {
714
+ -webkit-appearance: none;
715
+ margin: 0;
716
+ }
717
+ .dda-input-suffix-wrap input[type="number"]:focus + .dda-input-suffix {
718
+ color: var(--accent-dim);
719
+ }
720
+ .dda-input-suffix {
721
+ position: absolute;
722
+ right: 1rem;
723
+ top: 50%;
724
+ transform: translateY(-50%);
725
+ color: var(--text-dim);
726
+ font-size: 0.8rem;
727
+ font-weight: 500;
728
+ letter-spacing: 0.02em;
729
+ pointer-events: none;
730
+ transition: color var(--transition);
731
+ }
732
 
733
  .dda-modal {
734
  position: fixed;
 
825
  }
826
  .dda-image-viewer-status { min-height: 1.2rem; margin: 0 0 0.5rem; }
827
  .dda-image-viewer-status.dda-viewer-error { color: var(--danger, #dc2626); }
828
+ .dda-image-viewer-meta { margin: 0 0 0.4rem; }
829
+ .dda-viewer-meta-row { line-height: 1.5; }
830
+ .dda-viewer-meta-row .dda-meta-sep { opacity: 0.5; margin: 0 0.15rem; }
831
 
832
  .dda-viewer-mode-bar {
833
  display: flex;
 
918
  }
919
 
920
  .dda-upload-form .location-row { margin-bottom: 0.5rem; }
921
+ .dda-upload-form .form-group {
922
+ margin-bottom: 0;
923
+ min-width: 0;
924
+ }
925
+ .dda-upload-form .location-row > .form-group > label {
926
+ display: block;
927
+ align-items: unset;
928
+ gap: 0;
929
+ }
930
+ .dda-upload-form .dda-datewrap {
931
+ min-width: 0;
932
+ width: 100%;
933
+ }
934
 
935
  .dda-upload-progress { margin: 0.75rem 0 1rem; }
936
  .dda-progress-bar {
 
1057
  .dda-report-actions-cell { white-space: nowrap; }
1058
  .dda-report-actions-cell .btn, .dda-report-actions-cell a.btn { margin-right: 0.25rem; margin-bottom: 0.25rem; }
1059
 
1060
+ /* Standalone report page has no sidebar nav — full viewport interactive viewer. */
1061
+ .dda-report-page.dda-app,
1062
+ #page-report-detail .dda-report-page {
1063
+ display: block;
1064
+ max-width: none;
1065
+ width: 100%;
1066
+ margin: 0;
1067
+ padding: 0;
1068
+ min-height: 100vh;
1069
+ height: auto;
1070
+ overflow: visible;
1071
+ background: var(--dm-bg, #f3f2f2);
1072
+ }
1073
+ .dda-report-page .dda-report-header {
1074
+ padding: 0.75rem 1rem;
1075
+ margin: 0;
1076
+ border-bottom: 1px solid var(--border);
1077
+ }
1078
+ .dda-report-page #result-modal.result-modal-backdrop {
1079
+ position: relative !important;
1080
+ inset: auto !important;
1081
+ z-index: 1 !important;
1082
+ display: block !important;
1083
+ background: var(--dm-bg, #f3f2f2) !important;
1084
+ backdrop-filter: none !important;
1085
+ overflow: visible !important;
1086
+ min-height: 0;
1087
+ height: auto;
1088
+ }
1089
+ .dda-report-page #result-modal .result-modal {
1090
+ min-height: 0;
1091
+ height: auto;
1092
+ overflow: visible;
1093
+ box-shadow: none;
1094
+ }
1095
+ .dda-report-page #result-modal-close {
1096
+ display: none;
1097
+ }
1098
+ .dda-report-page #report-loading.hidden { display: none !important; }
1099
+
1100
+ .table-wrap {
1101
+ overflow-x: auto;
1102
+ width: 100%;
1103
+ }
1104
+ .dda-upload-type-error {
1105
+ color: var(--danger, #b91c1c);
1106
+ font-size: 0.85rem;
1107
+ margin: 0.5rem 0 0;
1108
+ }
1109
+ .dda-upload-type-error.hidden { display: none; }
1110
+ .dda-upload-file-list-wrap {
1111
+ margin-top: 0.75rem;
1112
+ overflow-x: auto;
1113
+ max-height: 220px;
1114
+ border: 1px solid var(--border);
1115
+ border-radius: 8px;
1116
+ }
1117
+ .dda-upload-file-table {
1118
+ width: 100%;
1119
+ border-collapse: collapse;
1120
+ font-size: 0.85rem;
1121
+ }
1122
+ .dda-upload-file-table th,
1123
+ .dda-upload-file-table td {
1124
+ padding: 0.4rem 0.55rem;
1125
+ border-bottom: 1px solid var(--border);
1126
+ text-align: left;
1127
+ vertical-align: middle;
1128
+ }
1129
+ .dda-upload-file-table .upload-server-name {
1130
+ width: 100%;
1131
+ min-width: 140px;
1132
+ font-size: 0.85rem;
1133
+ padding: 0.25rem 0.4rem;
1134
+ }
1135
+ .dda-lib-grid-actions {
1136
+ display: flex;
1137
+ flex-wrap: wrap;
1138
+ gap: 0.4rem;
1139
+ align-items: center;
1140
+ }
1141
+ .dda-card-img { position: relative; }
1142
+ .dda-card-select-wrap {
1143
+ position: absolute;
1144
+ top: 0.35rem;
1145
+ left: 0.35rem;
1146
+ z-index: 2;
1147
+ background: rgba(255,255,255,0.9);
1148
+ border-radius: 4px;
1149
+ padding: 0.1rem 0.2rem;
1150
+ line-height: 1;
1151
+ }
1152
+ .dda-card-filename {
1153
+ font-size: 0.85rem;
1154
+ font-weight: 600;
1155
+ }
1156
+ .dda-card-capture {
1157
+ font-size: 0.8rem;
1158
+ color: var(--text, #222);
1159
+ }
1160
+ .dda-upload-row {
1161
+ display: grid;
1162
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
1163
+ gap: 0.65rem 0.75rem;
1164
+ align-items: end;
1165
+ }
1166
  .dda-header {
1167
  display: flex;
1168
  flex-wrap: wrap;
 
1256
  .regions-table-wrap .regions-table {
1257
  min-width: 960px;
1258
  }
1259
+ /* Modal side panel owns its own scroll; don't force a second min-width fight. */
1260
+ .result-modal-side .regions-table-wrap .regions-table {
1261
+ min-width: 960px;
1262
+ }
1263
 
1264
  @media (max-width: 768px) {
1265
  .dda-compare-slots {
1266
  grid-template-columns: 1fr;
1267
  }
1268
+ .dda-detect-options .form-group {
1269
+ min-width: 100%;
1270
+ }
1271
  }
1272
 
1273
  /* ROI (region-of-interest) draw tool on the T2 preview */
 
1633
  }
1634
  .dda-filter-field .dda-search,
1635
  .dda-filter-field select,
1636
+ .dda-filter-field .dda-datewrap,
1637
+ input.dda-date-input:not(.dda-date-native) {
1638
  min-width: 9.5rem;
1639
  margin-bottom: 0;
1640
  padding: 0.4rem 0.55rem;
 
1743
  @media (max-width: 720px) {
1744
  .dda-filter-field .dda-search,
1745
  .dda-filter-field select,
1746
+ .dda-filter-field .dda-datewrap,
1747
+ input.dda-date-input:not(.dda-date-native),
1748
  .dda-datewrap { min-width: 100%; }
1749
  .dda-run-bar { position: static; }
1750
  }
 
1759
  }
1760
  .dda-dash-hero-actions { display: flex; align-items: center; gap: 0.75rem; }
1761
  .dda-dash-updated { font-size: 0.8rem; white-space: nowrap; }
1762
+ .dda-dash-filter-row { margin-top: 0.75rem; flex-wrap: wrap; }
1763
+
1764
+ /* A distinct, bordered toolbar strip for the date filter — visually
1765
+ grouped instead of loose inline controls, so it reads as one control
1766
+ panel rather than stray fields sitting under the title. Label sits on
1767
+ its own line above the controls row so it never fights the taller
1768
+ labeled inputs for vertical alignment. */
1769
+ .dda-dash-filter-bar {
1770
+ margin-top: 1rem;
1771
+ padding: 0.85rem 1rem 1rem;
1772
+ background: var(--bg-elevated);
1773
+ border: 1px solid var(--border);
1774
+ border-radius: var(--radius-sm);
1775
+ }
1776
+ .dda-dash-filter-bar .card-header { margin-bottom: 0.85rem; }
1777
+ .dda-dash-filter-bar .card-header svg { flex-shrink: 0; color: var(--text-muted); }
1778
+ .dda-dash-filter-bar-controls {
1779
+ display: flex;
1780
+ align-items: flex-end;
1781
+ justify-content: space-between;
1782
+ flex-wrap: wrap;
1783
+ gap: 0.85rem;
1784
+ }
1785
+ .dda-dash-filter-range { display: flex; align-items: flex-end; gap: 0.65rem; }
1786
+ .dda-dash-filter-arrow { padding-bottom: 0.55rem; color: var(--text-dim); font-size: 0.9rem; }
1787
+ .dda-dash-filter-actions { display: flex; align-items: flex-end; gap: 0.6rem; }
1788
+
1789
+ .dda-dash-section + .dda-dash-section { margin-top: 2rem; }
1790
+ .dda-dash-section-title {
1791
+ font-size: 0.78rem;
1792
+ font-weight: 700;
1793
+ letter-spacing: 0.08em;
1794
+ text-transform: uppercase;
1795
+ color: var(--text-dim, var(--text-muted));
1796
+ margin: 0 0 0.75rem;
1797
+ padding-left: 0.15rem;
1798
+ }
1799
+ .dda-dash-overview-grid {
1800
+ display: grid;
1801
+ grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
1802
+ gap: 1.25rem;
1803
+ align-items: stretch;
1804
+ }
1805
+ .dda-dash-overview-grid .card { margin-top: 0; }
1806
 
1807
  .dda-dash-kpi-grid {
1808
  display: grid;
 
1813
  display: flex;
1814
  flex-direction: column;
1815
  text-decoration: none;
1816
+ border-top: 3px solid var(--accent);
1817
+ transition: border-color 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
1818
+ }
1819
+ a.stat-box.dda-dash-kpi:hover {
1820
+ border-color: var(--accent);
1821
+ transform: translateY(-2px);
1822
+ box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
1823
  }
1824
+ a.stat-box.dda-dash-kpi:nth-child(6n+1) { border-top-color: var(--grad-start, var(--accent)); }
1825
+ a.stat-box.dda-dash-kpi:nth-child(6n+2) { border-top-color: var(--accent); }
1826
+ a.stat-box.dda-dash-kpi:nth-child(6n+3) { border-top-color: var(--success, var(--accent)); }
1827
+ a.stat-box.dda-dash-kpi:nth-child(6n+4) { border-top-color: var(--grad-mid, var(--accent)); }
1828
+ a.stat-box.dda-dash-kpi:nth-child(6n+5) { border-top-color: var(--accent-dim, var(--accent)); }
1829
+ a.stat-box.dda-dash-kpi:nth-child(6n+6) { border-top-color: var(--grad-end, var(--accent)); }
1830
 
1831
  .dda-dash-latest-row { display: flex; gap: 1.25rem; align-items: flex-start; flex-wrap: wrap; }
1832
  .dda-dash-latest-thumb {
 
1848
  .dda-dash-chart-grid {
1849
  display: grid;
1850
  grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
1851
+ gap: 1.25rem;
1852
+ }
1853
+ .dda-dash-chart-box-wide { grid-column: 1 / -1; }
1854
+ .dda-dash-chart-box {
1855
+ background: var(--bg-elevated);
1856
+ border: 1px solid var(--border);
1857
+ border-radius: var(--radius-sm);
1858
+ padding: 1rem 1.1rem 0.6rem;
1859
+ }
1860
+ .dda-dash-chart-box h4 {
1861
+ margin: 0 0 0.75rem;
1862
+ font-size: 0.82rem;
1863
+ font-weight: 700;
1864
+ text-transform: uppercase;
1865
+ letter-spacing: 0.03em;
1866
+ color: var(--text-muted);
1867
  }
 
1868
 
1869
  .dda-dash-compare-grid {
1870
  display: grid;
 
1881
  .dda-dash-compare-col p { margin: 0 0 0.35rem; }
1882
  .dda-dash-delta { font-weight: 700; font-family: var(--font-mono); }
1883
  .dda-dash-history-title { margin: 0 0 0.6rem; font-size: 0.95rem; }
1884
+ .dda-dash-class-line { font-size: 0.82rem; }
1885
+
1886
+ /* Classification mini-bar — a compact stacked-proportion visual standing in
1887
+ for four separate table columns (confirmed/false positive/submitted/
1888
+ unclassified), used across Region-wise, Village-wise, and Detection
1889
+ History wherever a full breakdown would otherwise need its own charts. */
1890
+ .dda-class-bar {
1891
+ display: flex;
1892
+ width: 100%;
1893
+ min-width: 90px;
1894
+ height: 10px;
1895
+ border-radius: 5px;
1896
+ overflow: hidden;
1897
+ background: var(--bg-elevated);
1898
+ }
1899
+ .dda-class-seg { display: block; height: 100%; }
1900
+ .dda-class-confirmed { background: #16a34a; }
1901
+ .dda-class-fp { background: #dc2626; }
1902
+ .dda-class-submitted { background: #2563eb; }
1903
+ .dda-class-unclassified { background: #ca8a04; }
1904
+ .dda-class-bar-caption {
1905
+ display: block;
1906
+ font-size: 0.7rem;
1907
+ margin-top: 0.2rem;
1908
+ font-family: var(--font-mono);
1909
+ }
1910
+
1911
+ .dda-accuracy-good { color: var(--success, #16a34a); font-weight: 700; }
1912
+ .dda-accuracy-mid { color: #ca8a04; font-weight: 700; }
1913
+ .dda-accuracy-low { color: var(--danger, #dc2626); font-weight: 700; }
1914
+
1915
+ .dda-dash-summary {
1916
+ margin: 0 0 1.25rem;
1917
+ padding: 0.75rem 1rem;
1918
+ background: var(--accent-glow);
1919
+ border-left: 3px solid var(--accent);
1920
+ border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
1921
+ font-size: 0.88rem;
1922
+ line-height: 1.5;
1923
+ }
1924
+ .dda-dash-compare-chart { margin: 0 0 1.25rem; }
1925
+
1926
+ /* Change-type pill — colors match CHANGE_TYPE_COLORS in home.js exactly,
1927
+ one fixed meaning per color across every chart/table on the dashboard. */
1928
+ .dda-changetype-pill {
1929
+ display: inline-block;
1930
+ padding: 0.15rem 0.55rem;
1931
+ border-radius: 999px;
1932
+ font-size: 0.72rem;
1933
+ font-weight: 600;
1934
+ white-space: nowrap;
1935
+ }
1936
+ .dda-changetype-New-Construction { background: rgba(249, 115, 22, 0.15); color: #c2410c; }
1937
+ .dda-changetype-Demolition { background: rgba(220, 38, 38, 0.15); color: #b91c1c; }
1938
+ .dda-changetype-Extension { background: rgba(37, 99, 235, 0.15); color: #1d4ed8; }
1939
+ .dda-changetype-Vegetation-Change { background: rgba(22, 163, 74, 0.15); color: #15803d; }
1940
+ .dda-changetype-Other { background: rgba(100, 116, 139, 0.15); color: #475569; }
1941
+ .dda-changetype-pill-group { display: flex; flex-wrap: wrap; gap: 0.3rem; }
1942
+ .dda-dash-latest-changetypes { margin: 0 0 0.85rem; }
1943
 
1944
  @media (max-width: 720px) {
1945
  .dda-dash-latest-row { flex-direction: column; }
1946
  .dda-dash-latest-thumb { width: 100%; height: 160px; }
1947
  }
1948
+
1949
+ /* ============================================================
1950
+ Modernist dashboard theme (from the Claude Design redesign).
1951
+ Scoped entirely to #tab-home so the rest of the app keeps its
1952
+ existing look until/unless it gets redesigned too: flat
1953
+ surfaces, hairline dividers, sharp corners, one accent color,
1954
+ Archivo for headings and numbers. Tokens (--dm-*) are shared,
1955
+ declared once at :root above.
1956
+ ============================================================ */
1957
+
1958
+ #tab-home h1,
1959
+ #tab-home .dda-dash-section-title,
1960
+ #tab-home .card-title,
1961
+ #tab-home .dda-dash-chart-box h4,
1962
+ #tab-home .dda-dash-ranked-count,
1963
+ #tab-home .stat-box .value {
1964
+ font-family: var(--dm-font);
1965
+ }
1966
+ #tab-home h1#dda-home-greeting { font-weight: 800; letter-spacing: -0.01em; }
1967
+
1968
+ #tab-home .dda-dash-section-title {
1969
+ font-size: 0.8rem;
1970
+ font-weight: 800;
1971
+ letter-spacing: 0.06em;
1972
+ border-bottom: 2px solid var(--dm-line);
1973
+ padding-bottom: 0.6rem;
1974
+ color: var(--dm-ink);
1975
+ }
1976
+
1977
+ /* Flat, hairline-bordered surfaces instead of soft shadow cards */
1978
+ #tab-home .card,
1979
+ #tab-home .dda-dash-chart-box,
1980
+ #tab-home .dda-dash-filter-bar,
1981
+ #tab-home .dda-dash-compare-grid {
1982
+ background: var(--dm-surface);
1983
+ border: 1px solid var(--dm-line);
1984
+ border-radius: 0;
1985
+ box-shadow: none;
1986
+ }
1987
+ #tab-home .dda-dash-chart-box h4 { font-weight: 700; color: var(--dm-ink); }
1988
+ #tab-home .dda-dash-filter-bar .card-header svg { color: var(--dm-accent); }
1989
+ #tab-home .dda-dash-filter-arrow { color: var(--dm-ink-dim); }
1990
+ #tab-home .dda-dash-latest-thumb { border-radius: 0; }
1991
+ #tab-home .dda-dash-summary { background: var(--dm-accent-100); border-left-color: var(--dm-accent); border-radius: 0; }
1992
+
1993
+ /* Buttons — flat accent instead of the app-wide purple gradient,
1994
+ scoped to this page only so other pages are untouched. */
1995
+ #tab-home .btn { border-radius: 0; font-family: var(--dm-font); font-weight: 700; }
1996
+ #tab-home .btn-primary { background: var(--dm-accent); }
1997
+ #tab-home .btn-primary:hover { background: var(--dm-accent-hover); filter: none; box-shadow: none; }
1998
+ #tab-home .btn-secondary { background: transparent; border-color: var(--dm-line); color: var(--dm-ink); }
1999
+ #tab-home .btn-secondary:hover { background: var(--dm-bg); border-color: var(--dm-line); }
2000
+ #tab-home .btn-ghost { color: var(--dm-accent); }
2001
+ #tab-home .btn-ghost:hover { background: var(--dm-accent-100); color: var(--dm-accent-hover); }
2002
+
2003
+ /* Overview / Charts & Graphs / Breakdown / Image Library tab switch — a row
2004
+ of small flat cards (icon + label + sub-label) instead of a plain button
2005
+ toolbar, so each section reads as its own destination rather than one of
2006
+ several equal-weight pill options. */
2007
+ #tab-home .dda-dash-tab-cards {
2008
+ display: grid;
2009
+ grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
2010
+ gap: 0.75rem;
2011
+ margin-bottom: 1.5rem;
2012
+ }
2013
+ #tab-home .dda-dash-tab-card {
2014
+ display: flex;
2015
+ align-items: center;
2016
+ gap: 0.75rem;
2017
+ padding: 0.9rem 1rem;
2018
+ background: var(--dm-surface);
2019
+ border: 1px solid var(--dm-line);
2020
+ border-radius: 0;
2021
+ cursor: pointer;
2022
+ text-align: left;
2023
+ transition: border-color 0.15s ease, background 0.15s ease;
2024
+ }
2025
+ #tab-home .dda-dash-tab-card:hover { background: var(--dm-bg); }
2026
+ #tab-home .dda-dash-tab-card-icon {
2027
+ flex-shrink: 0;
2028
+ width: 36px;
2029
+ height: 36px;
2030
+ display: flex;
2031
+ align-items: center;
2032
+ justify-content: center;
2033
+ color: var(--dm-ink-muted);
2034
+ background: var(--dm-bg);
2035
+ border: 1px solid var(--dm-line);
2036
+ }
2037
+ #tab-home .dda-dash-tab-card-text { display: flex; flex-direction: column; gap: 0.1rem; min-width: 0; }
2038
+ #tab-home .dda-dash-tab-card-label {
2039
+ font-family: var(--dm-font);
2040
+ font-weight: 700;
2041
+ font-size: 0.92rem;
2042
+ color: var(--dm-ink);
2043
+ }
2044
+ #tab-home .dda-dash-tab-card-sub {
2045
+ font-size: 0.72rem;
2046
+ color: var(--dm-ink-muted);
2047
+ white-space: nowrap;
2048
+ overflow: hidden;
2049
+ text-overflow: ellipsis;
2050
+ }
2051
+ #tab-home .dda-dash-tab-card.is-active {
2052
+ border-color: var(--dm-accent);
2053
+ background: var(--dm-accent-100);
2054
+ }
2055
+ #tab-home .dda-dash-tab-card.is-active .dda-dash-tab-card-icon {
2056
+ color: var(--dm-accent);
2057
+ border-color: var(--dm-accent);
2058
+ background: var(--dm-surface);
2059
+ }
2060
+ #tab-home .dda-dash-tab-card.is-active .dda-dash-tab-card-label { color: var(--dm-accent); }
2061
+
2062
+ /* KPI grid — one shared hairline grid instead of individually
2063
+ bordered/shadowed tiles, each cell a plain flat surface. */
2064
+ #tab-home .dda-dash-kpi-grid {
2065
+ display: grid;
2066
+ grid-template-columns: repeat(4, 1fr);
2067
+ gap: 1px;
2068
+ background: var(--dm-line);
2069
+ border: 1px solid var(--dm-line);
2070
+ }
2071
+ #tab-home a.stat-box.dda-dash-kpi {
2072
+ background: var(--dm-surface);
2073
+ border: none !important;
2074
+ border-radius: 0;
2075
+ padding: 0.9rem 0.75rem;
2076
+ text-align: left;
2077
+ align-items: flex-start;
2078
+ transform: none !important;
2079
+ box-shadow: none !important;
2080
+ }
2081
+ #tab-home a.stat-box.dda-dash-kpi:hover { background: var(--dm-accent-100); }
2082
+ #tab-home .stat-box .value {
2083
+ font-weight: 800;
2084
+ font-size: 1.3rem;
2085
+ background: none;
2086
+ -webkit-background-clip: initial;
2087
+ background-clip: initial;
2088
+ -webkit-text-fill-color: var(--dm-ink);
2089
+ color: var(--dm-ink);
2090
+ white-space: normal;
2091
+ }
2092
+ #tab-home .stat-box .label { color: var(--dm-ink-muted); font-weight: 600; }
2093
+ @media (max-width: 900px) { #tab-home .dda-dash-kpi-grid { grid-template-columns: repeat(2, 1fr); } }
2094
+ @media (max-width: 480px) { #tab-home .dda-dash-kpi-grid { grid-template-columns: 1fr; } }
2095
+
2096
+ /* Tables — uppercase hairline headers, no card wrapper needed per row */
2097
+ #tab-home .dda-reports-table th {
2098
+ font-size: 0.68rem;
2099
+ letter-spacing: 0.06em;
2100
+ text-transform: uppercase;
2101
+ color: var(--dm-ink-muted);
2102
+ border-bottom: 2px solid var(--dm-line);
2103
+ }
2104
+ #tab-home .dda-reports-table td { border-bottom: 1px solid var(--dm-line); }
2105
+ #tab-home .dda-reports-table tbody tr:hover { background: var(--dm-bg); }
2106
+ #tab-home .dda-dash-row-active { background: var(--dm-accent-100) !important; }
2107
+
2108
+ #tab-home .dda-changetype-pill { border-radius: 2px; }
2109
+
2110
+ /* Live detection-job banner — real job data (zone/village/method/
2111
+ progress) from GET /api/dda/jobs, not a mockup placeholder. */
2112
+ .dda-dash-live-banner {
2113
+ display: flex;
2114
+ align-items: center;
2115
+ gap: 1rem;
2116
+ flex-wrap: wrap;
2117
+ padding: 0.85rem 1.1rem;
2118
+ margin-bottom: 1.25rem;
2119
+ background: var(--dm-surface);
2120
+ border: 1px solid var(--dm-line);
2121
+ border-left: 3px solid var(--dm-accent);
2122
+ }
2123
+ .dda-dash-live-dot {
2124
+ width: 9px;
2125
+ height: 9px;
2126
+ border-radius: 50%;
2127
+ background: var(--dm-accent);
2128
+ flex-shrink: 0;
2129
+ animation: dda-dash-pulse 1.4s infinite;
2130
+ }
2131
+ @keyframes dda-dash-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
2132
+ .dda-dash-live-info { flex: 1; min-width: 220px; }
2133
+ .dda-dash-live-title { font-weight: 700; font-size: 0.9rem; color: var(--dm-ink); }
2134
+ .dda-dash-live-meta { font-size: 0.78rem; color: var(--dm-ink-muted); margin-top: 0.15rem; }
2135
+ .dda-dash-live-bar { width: 180px; height: 6px; background: var(--dm-line); flex-shrink: 0; }
2136
+ .dda-dash-live-bar-fill { height: 100%; background: var(--dm-accent); transition: width 0.6s ease; }
2137
+ .dda-dash-live-queued {
2138
+ font-size: 0.68rem;
2139
+ font-weight: 700;
2140
+ text-transform: uppercase;
2141
+ letter-spacing: 0.04em;
2142
+ border: 1px solid var(--dm-accent);
2143
+ color: var(--dm-accent);
2144
+ padding: 0.2rem 0.5rem;
2145
+ white-space: nowrap;
2146
+ }
2147
+ @media (max-width: 640px) { .dda-dash-live-bar { width: 100%; } }
2148
+
2149
+ /* Ranked change-types list — replaces a second duplicate chart with
2150
+ the same totals the stacked "Change Types by Zone" bar already shows. */
2151
+ .dda-dash-ranked-list { display: flex; flex-direction: column; gap: 0.55rem; }
2152
+ .dda-dash-ranked-row { display: flex; align-items: center; gap: 0.65rem; font-size: 0.85rem; }
2153
+ .dda-dash-ranked-swatch { width: 10px; height: 10px; flex-shrink: 0; }
2154
+ .dda-dash-ranked-label { flex: 1; color: var(--dm-ink); }
2155
+ .dda-dash-ranked-count { font-weight: 700; }
2156
+ .dda-dash-ranked-hint { font-size: 0.72rem; color: var(--dm-ink-dim); margin: -0.4rem 0 0.65rem; }
2157
+
2158
  /* Styled calendar date picker */
2159
  .dda-datewrap {
2160
  position: relative;
2161
+ min-width: 0;
2162
+ width: 100%;
2163
  overflow: visible;
2164
  }
2165
+ .dda-filter-field .dda-datewrap { min-width: 9.5rem; }
2166
+ input.dda-date-input.dda-date-native,
2167
  .dda-date-native {
2168
+ position: absolute !important;
2169
+ left: 0;
2170
+ top: 0;
2171
+ width: 1px !important;
2172
+ min-width: 0 !important;
2173
+ max-width: 1px !important;
2174
+ height: 1px !important;
2175
+ margin: 0 !important;
2176
+ padding: 0 !important;
2177
+ border: 0 !important;
2178
+ opacity: 0 !important;
2179
  pointer-events: none;
2180
+ clip: rect(0, 0, 0, 0);
2181
+ appearance: none;
2182
+ -webkit-appearance: none;
2183
+ }
2184
+ .dda-date-native:focus {
2185
+ outline: none !important;
2186
+ box-shadow: none !important;
2187
  }
2188
  .dda-date-trigger {
2189
  display: flex;
2190
  align-items: center;
2191
  gap: 0.5rem;
2192
  width: 100%;
2193
+ min-width: 0;
2194
  min-height: 2.35rem;
2195
  padding: 0.4rem 0.7rem;
2196
+ overflow: hidden;
2197
  border: 1px solid var(--border);
2198
  border-radius: 8px;
2199
  background: var(--bg-card);
 
2210
  box-shadow: 0 0 0 3px var(--accent-glow);
2211
  }
2212
  .dda-date-placeholder { color: var(--text-dim); font-weight: 450; }
2213
+ .dda-date-trigger > span:not(.dda-date-clear) {
2214
+ min-width: 0;
2215
+ overflow: hidden;
2216
+ text-overflow: ellipsis;
2217
+ white-space: nowrap;
2218
+ }
2219
  .dda-date-clear {
2220
  margin-left: auto;
2221
  color: var(--text-dim);
static/css/style.css CHANGED
@@ -238,6 +238,7 @@ body::before {
238
  input[type="email"],
239
  input[type="password"],
240
  input[type="text"],
 
241
  select,
242
  textarea {
243
  width: 100%;
@@ -1087,6 +1088,41 @@ input:focus, select:focus, textarea:focus {
1087
  font-weight: 600;
1088
  margin: 0;
1089
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1090
  .result-modal-header-actions {
1091
  display: flex;
1092
  align-items: center;
@@ -1096,7 +1132,7 @@ input:focus, select:focus, textarea:focus {
1096
  display: grid;
1097
  grid-template-columns: minmax(0, 1fr) minmax(360px, 420px);
1098
  gap: 1rem;
1099
- align-items: start;
1100
  transition: grid-template-columns var(--transition);
1101
  }
1102
  .result-modal-layout.report-hidden {
@@ -1109,18 +1145,41 @@ input:focus, select:focus, textarea:focus {
1109
  min-width: 0;
1110
  border-left: 1px solid var(--border);
1111
  padding-left: 1rem;
 
 
 
 
1112
  }
1113
  .result-modal-side .dda-review-bar {
1114
  margin-top: 0;
1115
  padding-top: 0;
1116
  border-top: 0;
 
1117
  }
1118
  .result-modal-side .regions-table-wrap {
1119
- max-height: 68vh;
 
 
1120
  overflow: auto;
 
 
1121
  }
1122
  .result-modal-side .regions-table {
1123
- min-width: 860px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1124
  }
1125
  .btn-close-modal {
1126
  background: none;
@@ -1154,9 +1213,10 @@ input:focus, select:focus, textarea:focus {
1154
  border-top: 1px solid var(--border);
1155
  padding-left: 0;
1156
  padding-top: 0.75rem;
 
1157
  }
1158
  .result-modal-side .regions-table-wrap {
1159
- max-height: none;
1160
  }
1161
  }
1162
 
 
238
  input[type="email"],
239
  input[type="password"],
240
  input[type="text"],
241
+ input[type="number"],
242
  select,
243
  textarea {
244
  width: 100%;
 
1088
  font-weight: 600;
1089
  margin: 0;
1090
  }
1091
+ .result-modal-title-wrap {
1092
+ display: flex;
1093
+ align-items: center;
1094
+ gap: 0.4rem;
1095
+ min-width: 0;
1096
+ }
1097
+ .result-title-edit-btn {
1098
+ flex-shrink: 0;
1099
+ width: 26px;
1100
+ height: 26px;
1101
+ padding: 0;
1102
+ display: inline-flex;
1103
+ align-items: center;
1104
+ justify-content: center;
1105
+ }
1106
+ .result-modal-title-edit {
1107
+ display: flex;
1108
+ align-items: center;
1109
+ gap: 0.5rem;
1110
+ flex-wrap: wrap;
1111
+ margin-bottom: 1rem;
1112
+ }
1113
+ .result-title-input {
1114
+ flex: 1 1 320px;
1115
+ min-width: 200px;
1116
+ max-width: 480px;
1117
+ padding: 0.45rem 0.65rem;
1118
+ font-family: var(--font-sans);
1119
+ font-size: 0.95rem;
1120
+ color: var(--text);
1121
+ background: var(--bg-elevated);
1122
+ border: 1px solid var(--border);
1123
+ border-radius: var(--radius-sm);
1124
+ }
1125
+ .result-title-msg { font-size: 0.8rem; }
1126
  .result-modal-header-actions {
1127
  display: flex;
1128
  align-items: center;
 
1132
  display: grid;
1133
  grid-template-columns: minmax(0, 1fr) minmax(360px, 420px);
1134
  gap: 1rem;
1135
+ align-items: stretch;
1136
  transition: grid-template-columns var(--transition);
1137
  }
1138
  .result-modal-layout.report-hidden {
 
1145
  min-width: 0;
1146
  border-left: 1px solid var(--border);
1147
  padding-left: 1rem;
1148
+ display: flex;
1149
+ flex-direction: column;
1150
+ min-height: 0;
1151
+ max-height: calc(100vh - 12rem);
1152
  }
1153
  .result-modal-side .dda-review-bar {
1154
  margin-top: 0;
1155
  padding-top: 0;
1156
  border-top: 0;
1157
+ flex: 0 0 auto;
1158
  }
1159
  .result-modal-side .regions-table-wrap {
1160
+ flex: 1 1 auto;
1161
+ min-height: 0;
1162
+ max-height: none;
1163
  overflow: auto;
1164
+ overscroll-behavior: contain;
1165
+ margin-top: 0.5rem;
1166
  }
1167
  .result-modal-side .regions-table {
1168
+ min-width: 960px;
1169
+ /* sticky thead + collapse clips mid-row in Chromium */
1170
+ border-collapse: separate;
1171
+ border-spacing: 0;
1172
+ }
1173
+ .result-modal-side .regions-table th {
1174
+ position: sticky;
1175
+ top: 0;
1176
+ z-index: 3;
1177
+ background: var(--bg-elevated, #f5f5f5);
1178
+ box-shadow: 0 1px 0 var(--border);
1179
+ }
1180
+ .result-modal-side > .pagination {
1181
+ flex: 0 0 auto;
1182
+ padding-top: 0.5rem;
1183
  }
1184
  .btn-close-modal {
1185
  background: none;
 
1213
  border-top: 1px solid var(--border);
1214
  padding-left: 0;
1215
  padding-top: 0.75rem;
1216
+ max-height: none;
1217
  }
1218
  .result-modal-side .regions-table-wrap {
1219
+ max-height: min(50vh, 420px);
1220
  }
1221
  }
1222
 
static/js/dda/app.js CHANGED
@@ -35,7 +35,7 @@ async function initDda() {
35
  if (!needsLibrary && !needsDetect) return;
36
  try {
37
  const [me, cfg, localCfg] = await Promise.all([
38
- ddaApi('GET', '/api/dda/me').catch(() => null),
39
  ddaApi('GET', '/api/dda/config'),
40
  ddaApi('GET', '/api/dda/local/config'),
41
  ]);
@@ -55,9 +55,7 @@ async function initDda() {
55
 
56
  const folderPath = document.getElementById('lib-folder-path');
57
  if (folderPath) {
58
- folderPath.textContent = localCfg.isHosted
59
- ? 'Hugging Face — tree library'
60
- : `Storage: ${localCfg.storageRoot || ''}`;
61
  }
62
 
63
  const instr = document.getElementById('lib-instructions');
@@ -74,10 +72,16 @@ async function initDda() {
74
  }
75
 
76
  if (needsLibrary) {
77
- await Promise.all([
78
- typeof loadTree === 'function' ? loadTree() : Promise.resolve(),
79
- loadLibraryImages({ resetPage: true }),
80
- ]);
 
 
 
 
 
 
81
  }
82
  } catch (err) {
83
  showDdaError(err.message || 'Failed to load library');
@@ -109,19 +113,35 @@ function renderLibGrid() {
109
  grid.innerHTML = items.map((img) => {
110
  const thumb = img.thumbUrl || '';
111
  const crumb = img.breadcrumb || img.nodePath || img.filename;
 
112
  const encPath = encodeURIComponent(img.path);
 
 
113
  return `
114
  <div class="dda-card-img" draggable="true" data-image-path="${encPath}" data-image-id="${img.id}" title="Click to view — ${escapeHtml(img.filename)}">
 
 
 
115
  <button type="button" class="dda-card-delete-btn" title="Delete image" aria-label="Delete image" data-requires-permission="library:delete">×</button>
116
  ${thumb ? `<img src="${thumb}" alt="" loading="lazy" />` : '<div class="meta">No preview</div>'}
117
  <div class="meta">
 
118
  <span class="dim dda-crumb">${escapeHtml(crumb)}</span><br/>
119
- <span class="dim">${img.captureDate ? String(img.captureDate).slice(0, 10) + ' · ' : ''}${img.imageType || ''} · ${formatBytes(img.fileSizeBytes)}</span>
 
 
 
120
  </div>
121
  </div>`;
122
  }).join('');
123
  if (typeof bindLibraryGridCards === 'function') bindLibraryGridCards(grid);
124
  if (typeof applyPermissionGating === 'function') applyPermissionGating(window.ddaPermissions || {}, grid);
 
 
 
 
 
 
125
 
126
  if (typeof renderPaginationControls === 'function') {
127
  renderPaginationControls(document.getElementById('lib-grid-pagination'), libGridPage, totalPages, (p) => {
@@ -137,7 +157,7 @@ async function loadLibraryImages(opts) {
137
  if (!grid) return;
138
  if (opts && opts.resetPage) libGridPage = 0;
139
  const q = document.getElementById('lib-filter')?.value?.trim() || '';
140
- const params = new URLSearchParams();
141
  if (selectedNode.id) params.set('node_id', String(selectedNode.id));
142
  if (q) params.set('q', q);
143
  params.set('limit', String(LIB_GRID_PER_PAGE));
@@ -155,11 +175,6 @@ async function loadLibraryImages(opts) {
155
  return loadLibraryImages();
156
  }
157
  renderLibGrid();
158
- if (typeof loadAreaPairs === 'function') {
159
- const run = () => loadAreaPairs({ nodeId: selectedNode.id || null });
160
- if (typeof deferIdle === 'function') deferIdle(run);
161
- else setTimeout(run, 0);
162
- }
163
  } catch (err) {
164
  grid.innerHTML = `<p class="dim">Could not load images: ${err.message}</p>`;
165
  }
@@ -192,7 +207,7 @@ document.getElementById('btn-refresh-lib')?.addEventListener('click', async () =
192
  const emailEl = document.getElementById('dda-user-email');
193
  if (!emailEl) return;
194
  try {
195
- const me = await ddaApi('GET', '/api/me');
196
  emailEl.textContent = me.email || '';
197
  const avatarEl = document.getElementById('dda-user-avatar');
198
  if (avatarEl) avatarEl.textContent = (me.full_name || me.email || '?').trim().charAt(0).toUpperCase();
 
35
  if (!needsLibrary && !needsDetect) return;
36
  try {
37
  const [me, cfg, localCfg] = await Promise.all([
38
+ (typeof ensureDdaMe === 'function' ? ensureDdaMe() : ddaApi('GET', '/api/me')).catch(() => null),
39
  ddaApi('GET', '/api/dda/config'),
40
  ddaApi('GET', '/api/dda/local/config'),
41
  ]);
 
55
 
56
  const folderPath = document.getElementById('lib-folder-path');
57
  if (folderPath) {
58
+ folderPath.textContent = `Storage: ${localCfg.storageRoot || ''}`;
 
 
59
  }
60
 
61
  const instr = document.getElementById('lib-instructions');
 
72
  }
73
 
74
  if (needsLibrary) {
75
+ // Grid first — tree must not block first paint of images.
76
+ loadLibraryImages({ resetPage: true });
77
+ if (typeof loadTree === 'function') {
78
+ loadTree().catch(() => {
79
+ const treeEl = document.getElementById('lib-tree');
80
+ if (treeEl && !treeEl.querySelector('.dda-tree-node, .dda-tree-item')) {
81
+ treeEl.innerHTML = '<p class="dim">Could not load folders.</p>';
82
+ }
83
+ });
84
+ }
85
  }
86
  } catch (err) {
87
  showDdaError(err.message || 'Failed to load library');
 
113
  grid.innerHTML = items.map((img) => {
114
  const thumb = img.thumbUrl || '';
115
  const crumb = img.breadcrumb || img.nodePath || img.filename;
116
+ const place = [img.villageName, img.zoneName].filter(Boolean).join(', ');
117
  const encPath = encodeURIComponent(img.path);
118
+ const capture = img.captureDate ? String(img.captureDate).slice(0, 10) : '';
119
+ const year = img.year != null ? String(img.year) : '';
120
  return `
121
  <div class="dda-card-img" draggable="true" data-image-path="${encPath}" data-image-id="${img.id}" title="Click to view — ${escapeHtml(img.filename)}">
122
+ <label class="dda-card-select-wrap" onclick="event.stopPropagation()">
123
+ <input type="checkbox" class="dda-card-select" data-image-id="${img.id}" />
124
+ </label>
125
  <button type="button" class="dda-card-delete-btn" title="Delete image" aria-label="Delete image" data-requires-permission="library:delete">×</button>
126
  ${thumb ? `<img src="${thumb}" alt="" loading="lazy" />` : '<div class="meta">No preview</div>'}
127
  <div class="meta">
128
+ <strong class="dda-card-filename">${escapeHtml(img.filename || img.imageName || '')}</strong><br/>
129
  <span class="dim dda-crumb">${escapeHtml(crumb)}</span><br/>
130
+ ${place ? `<span class="dim dda-crumb">${escapeHtml(place)}</span><br/>` : ''}
131
+ ${capture ? `<span class="dda-card-capture">Capture: ${escapeHtml(capture)}</span><br/>` : ''}
132
+ <span class="dim">${year ? escapeHtml(year) + ' · ' : ''}${escapeHtml(img.imageType || '')}${img.source ? ' · ' + escapeHtml(img.source) : ''} · ${formatBytes(img.fileSizeBytes)}${img.width && img.height ? ` · ${img.width}×${img.height}` : ''}</span><br/>
133
+ <span class="dim dda-card-uploaded">Uploaded ${typeof formatDateIst === 'function' ? formatDateIst(img.uploadedOn) : (img.uploadedOn || '—')}</span>
134
  </div>
135
  </div>`;
136
  }).join('');
137
  if (typeof bindLibraryGridCards === 'function') bindLibraryGridCards(grid);
138
  if (typeof applyPermissionGating === 'function') applyPermissionGating(window.ddaPermissions || {}, grid);
139
+ grid.querySelectorAll('.dda-card-select').forEach((cb) => {
140
+ cb.addEventListener('change', () => {
141
+ if (typeof updateLibDeleteSelectedBtn === 'function') updateLibDeleteSelectedBtn();
142
+ });
143
+ });
144
+ if (typeof updateLibDeleteSelectedBtn === 'function') updateLibDeleteSelectedBtn();
145
 
146
  if (typeof renderPaginationControls === 'function') {
147
  renderPaginationControls(document.getElementById('lib-grid-pagination'), libGridPage, totalPages, (p) => {
 
157
  if (!grid) return;
158
  if (opts && opts.resetPage) libGridPage = 0;
159
  const q = document.getElementById('lib-filter')?.value?.trim() || '';
160
+ const params = typeof libFilterParams === 'function' ? libFilterParams() : new URLSearchParams();
161
  if (selectedNode.id) params.set('node_id', String(selectedNode.id));
162
  if (q) params.set('q', q);
163
  params.set('limit', String(LIB_GRID_PER_PAGE));
 
175
  return loadLibraryImages();
176
  }
177
  renderLibGrid();
 
 
 
 
 
178
  } catch (err) {
179
  grid.innerHTML = `<p class="dim">Could not load images: ${err.message}</p>`;
180
  }
 
207
  const emailEl = document.getElementById('dda-user-email');
208
  if (!emailEl) return;
209
  try {
210
+ const me = typeof ensureDdaMe === 'function' ? await ensureDdaMe() : await ddaApi('GET', '/api/me');
211
  emailEl.textContent = me.email || '';
212
  const avatarEl = document.getElementById('dda-user-avatar');
213
  if (avatarEl) avatarEl.textContent = (me.full_name || me.email || '?').trim().charAt(0).toUpperCase();
static/js/dda/audit_log.js CHANGED
@@ -9,6 +9,13 @@
9
 
10
  let auditPage = 0;
11
 
 
 
 
 
 
 
 
12
  function fillSelect(select, values, current) {
13
  if (!select) return;
14
  const placeholder = select.options[0];
@@ -70,7 +77,7 @@
70
  <td><time class="dda-date">${typeof formatDateIst === 'function' ? formatDateIst(r.timestamp) : escapeHtml(r.timestamp || '')}</time></td>
71
  <td>${escapeHtml(r.user || '—')}</td>
72
  <td>${escapeHtml(r.role || '—')}</td>
73
- <td class="dda-action-cell">${escapeHtml(r.action)}</td>
74
  <td>${escapeHtml(r.module || '—')}</td>
75
  <td class="dda-log-msg">${escapeHtml(r.newValue || r.oldValue || '')}</td>
76
  </tr>`).join('')}
 
9
 
10
  let auditPage = 0;
11
 
12
+ function actionBadgeClass(action) {
13
+ const a = (action || '').toLowerCase();
14
+ if (a.includes('delete') || a.includes('remove') || a.includes('clear')) return 'is-danger';
15
+ if (a.includes('create') || a.includes('add') || a.includes('upload') || a.includes('login')) return 'is-ok';
16
+ return 'is-neutral';
17
+ }
18
+
19
  function fillSelect(select, values, current) {
20
  if (!select) return;
21
  const placeholder = select.options[0];
 
77
  <td><time class="dda-date">${typeof formatDateIst === 'function' ? formatDateIst(r.timestamp) : escapeHtml(r.timestamp || '')}</time></td>
78
  <td>${escapeHtml(r.user || '—')}</td>
79
  <td>${escapeHtml(r.role || '—')}</td>
80
+ <td class="dda-action-cell"><span class="dda-badge ${actionBadgeClass(r.action)}">${escapeHtml(r.action)}</span></td>
81
  <td>${escapeHtml(r.module || '—')}</td>
82
  <td class="dda-log-msg">${escapeHtml(r.newValue || r.oldValue || '')}</td>
83
  </tr>`).join('')}
static/js/dda/compare.js CHANGED
@@ -29,6 +29,72 @@ function initShapeToggle() {
29
  apply();
30
  }
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  function compareFormatBytes(n) {
33
  if (n >= 1024 ** 3) return (n / 1024 ** 3).toFixed(1) + ' GB';
34
  if (n >= 1024 ** 2) return (n / 1024 ** 2).toFixed(1) + ' MB';
@@ -117,12 +183,25 @@ function setDetectMode(mode, { applyPair } = { applyPair: true }) {
117
  ? `Automatic: the system selects the oldest image as <strong>Before</strong> and the newest as <strong>After</strong> for the chosen area. Set the interval and time, then click <strong>Start schedule</strong> — it does not run when the app launches. Use <strong>Run automatic detection</strong> for a one-off run.`
118
  : 'Manual: pick the Before and After images yourself (dropdowns, library cards, or drag-and-drop), then run change detection. Manual runs are not scheduled.';
119
  }
120
- if (compareState.mode === 'automatic' && applyPair !== false && typeof applySelectedAutoPair === 'function') {
121
- applySelectedAutoPair();
 
 
122
  }
123
  updateRunButton();
124
  }
125
 
 
 
 
 
 
 
 
 
 
 
 
126
  function initDetectMode() {
127
  const wrap = document.getElementById('dda-detect-mode');
128
  if (!wrap || wrap.dataset.bound) {
@@ -157,9 +236,10 @@ function renderSlotPreview(slotKey, selection) {
157
 
158
  slot.classList.add('filled');
159
  const thumb = selection.thumbUrl || thumbUrlFor(selection.path);
 
160
  wrap.innerHTML = `
161
  <img class="dda-slot-preview" src="${thumb}" alt="" />
162
- <div class="dda-slot-meta">${escapeHtml(selection.label || selection.filename)}${selection.captureDate ? `<br/><span class="dim">${escapeHtml(String(selection.captureDate).slice(0, 10))}</span>` : ''}</div>`;
163
  if (slotKey === 't2') setupRoiDraw();
164
  }
165
 
@@ -339,6 +419,18 @@ function setSlot(slotKey, img, options) {
339
  filename: img.filename,
340
  thumbUrl: img.thumbUrl || thumbUrlFor(img.path),
341
  captureDate: img.captureDate || null,
 
 
 
 
 
 
 
 
 
 
 
 
342
  };
343
  if (slotKey === 't1') {
344
  compareState.t1 = item;
@@ -509,14 +601,6 @@ async function loadCompareLibraryGrid(opts) {
509
  ensureDdaState().libraryItems = page.items;
510
  populateSelects(selectSourceItems(page.items));
511
  renderCompareGrid(page.items, page.total);
512
- if (typeof loadAreaPairs === 'function' && !opts?.keepPage) {
513
- const run = () => loadAreaPairs({
514
- nodeId: compareState.selectedNode?.id || null,
515
- autoFillCompare: true,
516
- });
517
- if (typeof deferIdle === 'function') deferIdle(run);
518
- else setTimeout(run, 0);
519
- }
520
  } catch (err) {
521
  grid.innerHTML = `<p class="dim">Could not load images: ${err.message}</p>`;
522
  if (typeof showDdaError === 'function') showDdaError(err.message);
@@ -862,6 +946,42 @@ async function pollJobUntilDone(jobId) {
862
  }
863
  }
864
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
865
  async function runLibraryDetection() {
866
  if (!compareState.t1 || !compareState.t2) {
867
  if (typeof showDdaError === 'function') showDdaError(
@@ -908,9 +1028,8 @@ async function runLibraryDetection() {
908
  form.append('method', document.getElementById('dda-detect-method')?.value || 'AI-Based Deep Learning');
909
  form.append('enable_registration', String(document.getElementById('dda-detect-registration')?.checked !== false));
910
  form.append('enable_normalization', String(document.getElementById('dda-detect-normalization')?.checked !== false));
911
- form.append('detection_sensitivity', String(Math.max(0, Math.min(1, Number(document.getElementById('dda-detect-sensitivity')?.value ?? 0.45)))));
912
- const minArea = Number(document.getElementById('dda-detect-min-area')?.value ?? 150);
913
- if (!Number.isNaN(minArea) && minArea >= 50) form.append('min_region_area', String(Math.round(minArea)));
914
  const notifyCb = document.getElementById('dda-detect-notify');
915
  const notifyEmail = document.getElementById('dda-detect-notify-email');
916
  if (notifyCb?.checked && notifyEmail?.value?.trim()) {
@@ -944,6 +1063,7 @@ let compareInitialized = false;
944
  function initCompareTab() {
945
  initShapeToggle();
946
  initDetectMode();
 
947
  if (!compareInitialized) {
948
  if (typeof registerCompareTreeSidebar === 'function') {
949
  registerCompareTreeSidebar({
@@ -967,6 +1087,11 @@ function initCompareTab() {
967
  updateRunButton();
968
  loadCompareLibraryGrid();
969
  if (typeof loadTree === 'function') loadTree().catch(() => {});
 
 
 
 
 
970
  }
971
 
972
  window.loadCompareLibraryGrid = loadCompareLibraryGrid;
 
29
  apply();
30
  }
31
 
32
+ // --- Sensitivity slider + Minimum Region validation --------------------
33
+ function clampSensitivity(raw) {
34
+ const n = Number(raw);
35
+ return Number.isNaN(n) ? 0.45 : Math.max(0, Math.min(1, n));
36
+ }
37
+ function clampMinRegionArea(raw) {
38
+ const n = Math.round(Number(raw));
39
+ return Number.isNaN(n) ? 50 : Math.max(50, Math.min(10000, n));
40
+ }
41
+
42
+ function initDetectSettingsControls() {
43
+ const sens = document.getElementById('dda-detect-sensitivity');
44
+ const sensVal = document.getElementById('dda-detect-sensitivity-val');
45
+ if (sens && !sens.dataset.bound) {
46
+ sens.dataset.bound = '1';
47
+ const syncSensLabel = () => { if (sensVal) sensVal.textContent = clampSensitivity(sens.value).toFixed(2); };
48
+ sens.addEventListener('input', syncSensLabel);
49
+ syncSensLabel();
50
+ }
51
+
52
+ const minArea = document.getElementById('dda-detect-min-area');
53
+ const minAreaHint = document.getElementById('dda-detect-min-area-hint');
54
+ const decBtn = document.getElementById('dda-detect-min-area-dec');
55
+ const incBtn = document.getElementById('dda-detect-min-area-inc');
56
+ const MIN_AREA_STEP = 10;
57
+
58
+ function updateMinAreaStepperState() {
59
+ if (!minArea) return;
60
+ const v = clampMinRegionArea(minArea.value);
61
+ if (decBtn) decBtn.disabled = v <= 50;
62
+ if (incBtn) incBtn.disabled = v >= 10000;
63
+ }
64
+
65
+ function applyMinAreaValue(rawValue, { wasBlank = false } = {}) {
66
+ if (!minArea) return;
67
+ const before = wasBlank ? NaN : Number(rawValue);
68
+ const clamped = clampMinRegionArea(rawValue);
69
+ const changed = wasBlank || Number.isNaN(before) || before !== clamped;
70
+ minArea.value = String(clamped);
71
+ if (minAreaHint) {
72
+ if (changed) {
73
+ const reason = clamped === 50 ? 'minimum allowed' : clamped === 10000 ? 'maximum allowed' : 'valid range';
74
+ minAreaHint.textContent = `Adjusted to ${clamped} (${reason}).`;
75
+ minAreaHint.classList.remove('hidden');
76
+ } else {
77
+ minAreaHint.classList.add('hidden');
78
+ }
79
+ }
80
+ updateMinAreaStepperState();
81
+ }
82
+
83
+ if (minArea && !minArea.dataset.bound) {
84
+ minArea.dataset.bound = '1';
85
+ minArea.addEventListener('blur', () => {
86
+ applyMinAreaValue(minArea.value, { wasBlank: minArea.value.trim() === '' });
87
+ });
88
+ decBtn?.addEventListener('click', () => {
89
+ applyMinAreaValue(clampMinRegionArea(minArea.value) - MIN_AREA_STEP);
90
+ });
91
+ incBtn?.addEventListener('click', () => {
92
+ applyMinAreaValue(clampMinRegionArea(minArea.value) + MIN_AREA_STEP);
93
+ });
94
+ updateMinAreaStepperState();
95
+ }
96
+ }
97
+
98
  function compareFormatBytes(n) {
99
  if (n >= 1024 ** 3) return (n / 1024 ** 3).toFixed(1) + ' GB';
100
  if (n >= 1024 ** 2) return (n / 1024 ** 2).toFixed(1) + ' MB';
 
183
  ? `Automatic: the system selects the oldest image as <strong>Before</strong> and the newest as <strong>After</strong> for the chosen area. Set the interval and time, then click <strong>Start schedule</strong> — it does not run when the app launches. Use <strong>Run automatic detection</strong> for a one-off run.`
184
  : 'Manual: pick the Before and After images yourself (dropdowns, library cards, or drag-and-drop), then run change detection. Manual runs are not scheduled.';
185
  }
186
+ if (compareState.mode === 'automatic') {
187
+ ensureDetectPairsLoaded({ applyPair: applyPair !== false });
188
+ } else if (applyPair !== false && typeof applySelectedAutoPair === 'function') {
189
+ // no-op for manual
190
  }
191
  updateRunButton();
192
  }
193
 
194
+ async function ensureDetectPairsLoaded({ applyPair } = {}) {
195
+ if (typeof loadAreaPairs !== 'function') return;
196
+ const nodeId = compareState.selectedNode?.id || null;
197
+ await loadAreaPairs({
198
+ nodeId,
199
+ includeUnpaired: true,
200
+ autoFillCompare: !!applyPair,
201
+ });
202
+ if (applyPair && typeof applySelectedAutoPair === 'function') applySelectedAutoPair();
203
+ }
204
+
205
  function initDetectMode() {
206
  const wrap = document.getElementById('dda-detect-mode');
207
  if (!wrap || wrap.dataset.bound) {
 
236
 
237
  slot.classList.add('filled');
238
  const thumb = selection.thumbUrl || thumbUrlFor(selection.path);
239
+ const metaHtml = typeof buildImageMetaHtml === 'function' ? buildImageMetaHtml(selection) : '';
240
  wrap.innerHTML = `
241
  <img class="dda-slot-preview" src="${thumb}" alt="" />
242
+ <div class="dda-slot-meta">${metaHtml || escapeHtml(selection.label || selection.filename)}</div>`;
243
  if (slotKey === 't2') setupRoiDraw();
244
  }
245
 
 
419
  filename: img.filename,
420
  thumbUrl: img.thumbUrl || thumbUrlFor(img.path),
421
  captureDate: img.captureDate || null,
422
+ breadcrumb: img.breadcrumb || null,
423
+ nodePath: img.nodePath || null,
424
+ imageType: img.imageType || null,
425
+ format: img.format || null,
426
+ fileSizeBytes: img.fileSizeBytes || 0,
427
+ width: img.width || 0,
428
+ height: img.height || 0,
429
+ hasGeoref: !!img.hasGeoref,
430
+ uploadedBy: img.uploadedBy || null,
431
+ uploadedOn: img.uploadedOn || null,
432
+ villageName: img.villageName || null,
433
+ zoneName: img.zoneName || null,
434
  };
435
  if (slotKey === 't1') {
436
  compareState.t1 = item;
 
601
  ensureDdaState().libraryItems = page.items;
602
  populateSelects(selectSourceItems(page.items));
603
  renderCompareGrid(page.items, page.total);
 
 
 
 
 
 
 
 
604
  } catch (err) {
605
  grid.innerHTML = `<p class="dim">Could not load images: ${err.message}</p>`;
606
  if (typeof showDdaError === 'function') showDdaError(err.message);
 
946
  }
947
  }
948
 
949
+ /** If this user already has a job running/queued (e.g. they navigated away
950
+ * from /detect and came back, or reloaded the page) resume showing its live
951
+ * progress instead of silently presenting the idle "Ready to detect?" form
952
+ * — that blank state is exactly what makes a still-running job look dead
953
+ * and tempts a server restart, which actually does kill it. */
954
+ async function resumeActiveJobIfAny() {
955
+ let jobs;
956
+ try {
957
+ jobs = typeof getDdaNotifyJobs === 'function'
958
+ ? await getDdaNotifyJobs()
959
+ : ((await ddaApi('GET', '/api/dda/jobs?limit=20')).jobs || []);
960
+ } catch (_) {
961
+ return;
962
+ }
963
+ const active = jobs.find((j) => j.status === 'running' || j.status === 'queued');
964
+ if (!active) return;
965
+
966
+ if (typeof showDdaSuccess === 'function') {
967
+ showDdaSuccess('Resuming progress for a detection already in progress…');
968
+ }
969
+ forEachRunButton((btn) => { btn.disabled = true; });
970
+ showDetectProgress();
971
+ try {
972
+ const job = await pollJobUntilDone(active.id);
973
+ if (job.jobId && typeof window.markDdaJobSeen === 'function') window.markDdaJobSeen(job.jobId);
974
+ showDetectResult(job.result);
975
+ if (typeof showDdaSuccess === 'function') showDdaSuccess('Detection complete.');
976
+ if (typeof loadReportsList === 'function') loadReportsList();
977
+ } catch (err) {
978
+ if (typeof showDdaError === 'function') showDdaError(err.message || 'Detection failed');
979
+ } finally {
980
+ updateRunButton();
981
+ hideDetectProgress(2000);
982
+ }
983
+ }
984
+
985
  async function runLibraryDetection() {
986
  if (!compareState.t1 || !compareState.t2) {
987
  if (typeof showDdaError === 'function') showDdaError(
 
1028
  form.append('method', document.getElementById('dda-detect-method')?.value || 'AI-Based Deep Learning');
1029
  form.append('enable_registration', String(document.getElementById('dda-detect-registration')?.checked !== false));
1030
  form.append('enable_normalization', String(document.getElementById('dda-detect-normalization')?.checked !== false));
1031
+ form.append('detection_sensitivity', String(clampSensitivity(document.getElementById('dda-detect-sensitivity')?.value ?? 0.45)));
1032
+ form.append('min_region_area', String(clampMinRegionArea(document.getElementById('dda-detect-min-area')?.value ?? 150)));
 
1033
  const notifyCb = document.getElementById('dda-detect-notify');
1034
  const notifyEmail = document.getElementById('dda-detect-notify-email');
1035
  if (notifyCb?.checked && notifyEmail?.value?.trim()) {
 
1063
  function initCompareTab() {
1064
  initShapeToggle();
1065
  initDetectMode();
1066
+ initDetectSettingsControls();
1067
  if (!compareInitialized) {
1068
  if (typeof registerCompareTreeSidebar === 'function') {
1069
  registerCompareTreeSidebar({
 
1087
  updateRunButton();
1088
  loadCompareLibraryGrid();
1089
  if (typeof loadTree === 'function') loadTree().catch(() => {});
1090
+ resumeActiveJobIfAny();
1091
+ // Automatic mode loads pairs from setDetectMode; ?pair= on Manual still needs a fetch.
1092
+ if (new URLSearchParams(window.location.search).get('pair') && compareState.mode !== 'automatic') {
1093
+ ensureDetectPairsLoaded({ applyPair: false });
1094
+ }
1095
  }
1096
 
1097
  window.loadCompareLibraryGrid = loadCompareLibraryGrid;
static/js/dda/exception_log.js CHANGED
@@ -7,8 +7,8 @@
7
 
8
  // ------------------------------------------------------------- Tabs ----
9
  const TITLES = {
10
- exceptions: ['💥 Exception Log', 'System errors captured by the global exception handler'],
11
- applogs: ['📝 Application Logs', 'Live view of recent server events. Filter, download, or clear the buffer.'],
12
  };
13
 
14
  function activateTab(tab) {
@@ -106,7 +106,7 @@
106
  wrap.innerHTML = `
107
  <table class="dda-logs-table">
108
  <thead>
109
- <tr><th>Timestamp (IST)</th><th>Type</th><th>Message</th><th>Path</th><th>Code</th><th>Handled</th><th>Details</th></tr>
110
  </thead>
111
  <tbody>
112
  ${rows.map((r) => `
@@ -116,6 +116,8 @@
116
  <td class="dda-log-msg">${escapeHtml(r.message || '')}</td>
117
  <td class="dda-action-cell">${escapeHtml(r.method || '')} ${escapeHtml(r.path || '')}</td>
118
  <td>${escapeHtml(String(r.statusCode ?? ''))}</td>
 
 
119
  <td><span class="dda-badge ${r.handled ? 'is-ok' : 'is-danger'}">${r.handled ? '✓ Yes' : '✗ No'}</span></td>
120
  <td><button type="button" class="btn btn-secondary btn-sm exception-view-btn" data-id="${r.id}">View</button></td>
121
  </tr>`).join('')}
@@ -141,6 +143,7 @@
141
  `${entry.type} — ${entry.statusCode} (${entry.handled ? 'handled' : 'unhandled'})\n` +
142
  `${entry.method} ${entry.path}\n` +
143
  `User: ${entry.userEmail || '—'}\n` +
 
144
  `Time: ${entry.timestamp}\n\n${entry.message}\n\n${entry.traceback || ''}`;
145
  } catch (err) {
146
  content.textContent = err.message || 'Could not load exception details.';
 
7
 
8
  // ------------------------------------------------------------- Tabs ----
9
  const TITLES = {
10
+ exceptions: ['Exception Log', 'System errors captured by the global exception handler'],
11
+ applogs: ['Application Logs', 'Live view of recent server events. Filter, download, or clear the buffer.'],
12
  };
13
 
14
  function activateTab(tab) {
 
106
  wrap.innerHTML = `
107
  <table class="dda-logs-table">
108
  <thead>
109
+ <tr><th>Timestamp (IST)</th><th>Type</th><th>Message</th><th>Path</th><th>Code</th><th>User</th><th>Role</th><th>Handled</th><th>Details</th></tr>
110
  </thead>
111
  <tbody>
112
  ${rows.map((r) => `
 
116
  <td class="dda-log-msg">${escapeHtml(r.message || '')}</td>
117
  <td class="dda-action-cell">${escapeHtml(r.method || '')} ${escapeHtml(r.path || '')}</td>
118
  <td>${escapeHtml(String(r.statusCode ?? ''))}</td>
119
+ <td>${escapeHtml(r.userEmail || '—')}</td>
120
+ <td>${escapeHtml(r.userRole || '—')}</td>
121
  <td><span class="dda-badge ${r.handled ? 'is-ok' : 'is-danger'}">${r.handled ? '✓ Yes' : '✗ No'}</span></td>
122
  <td><button type="button" class="btn btn-secondary btn-sm exception-view-btn" data-id="${r.id}">View</button></td>
123
  </tr>`).join('')}
 
143
  `${entry.type} — ${entry.statusCode} (${entry.handled ? 'handled' : 'unhandled'})\n` +
144
  `${entry.method} ${entry.path}\n` +
145
  `User: ${entry.userEmail || '—'}\n` +
146
+ `Role: ${entry.userRole || '—'}\n` +
147
  `Time: ${entry.timestamp}\n\n${entry.message}\n\n${entry.traceback || ''}`;
148
  } catch (err) {
149
  content.textContent = err.message || 'Could not load exception details.';
static/js/dda/home.js CHANGED
@@ -2,6 +2,35 @@
2
  * change comparison, reports feed, and charts. Replaces the old
3
  * greeting+3-cards Home page in place. */
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  function formatHomeDate(iso) {
6
  return typeof formatDateIst === 'function' ? formatDateIst(iso) : (iso || '—');
7
  }
@@ -16,22 +45,173 @@ function dashDelta(v, suffix) {
16
  return `${sign}${typeof v === 'number' && suffix === '%' ? v.toFixed(2) : v}${suffix}`;
17
  }
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  const dashCharts = {};
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  function renderDashChart(canvasId, config) {
22
  const canvas = document.getElementById(canvasId);
23
  if (!canvas || typeof Chart === 'undefined') return;
24
  if (dashCharts[canvasId]) {
25
  dashCharts[canvasId].destroy();
26
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  dashCharts[canvasId] = new Chart(canvas, config);
28
  }
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  async function loadHomeGreeting() {
31
  const el = document.getElementById('dda-home-greeting');
32
  if (!el) return;
33
  try {
34
- const me = await ddaApi('GET', '/api/me');
35
  el.textContent = me.full_name ? `Welcome, ${me.full_name}` : 'Welcome';
36
  } catch (_) {
37
  // Not authenticated — the page-load redirect to /login should already
@@ -45,12 +225,14 @@ function renderDashKpis(summary) {
45
  const el = document.getElementById('dda-dash-kpis');
46
  if (!el) return;
47
  const tiles = [
48
- ['Total Detection Runs', summary.totalRuns],
49
- ['Total Detected Changes', summary.totalDetectedChanges],
50
- ['Total Unclassified', summary.totalUnclassified],
 
 
51
  ['Avg Change %', dashPct(summary.avgChangePercentage)],
52
- ['Regions Covered', summary.totalRegions],
53
- ['Villages Covered', summary.totalVillages],
54
  ];
55
  el.innerHTML = tiles.map(([label, value]) => `
56
  <a class="stat-box dda-dash-kpi" href="/reports" title="Open Reports">
@@ -59,6 +241,26 @@ function renderDashKpis(summary) {
59
  </a>`).join('');
60
  }
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  function renderDashLatest(run) {
63
  const el = document.getElementById('dda-dash-latest');
64
  if (!el) return;
@@ -78,6 +280,7 @@ function renderDashLatest(run) {
78
  <span><strong>${run.regionsCount ?? '—'}</strong> regions</span>
79
  <span><strong>${run.unclassified ?? '—'}</strong> unclassified</span>
80
  </div>
 
81
  <a class="btn btn-secondary btn-sm" href="/dda/reports/${run.id}" target="_blank" rel="noopener">View Report</a>
82
  </div>
83
  </div>`;
@@ -85,37 +288,69 @@ function renderDashLatest(run) {
85
 
86
  async function loadDashboardSummary() {
87
  try {
88
- const summary = await ddaApi('GET', '/api/dda/dashboard/summary');
89
- dashSummaryCache = summary;
90
- const empty = document.getElementById('dda-dash-empty');
91
- const content = document.getElementById('dda-dash-content');
92
- if (!summary.totalRuns) {
93
- empty?.classList.remove('hidden');
94
- content?.classList.add('hidden');
95
- return summary;
96
- }
97
- empty?.classList.add('hidden');
98
- content?.classList.remove('hidden');
99
- renderDashKpis(summary);
100
- renderDashLatest(summary.latestRun);
101
- renderDashChart('dda-chart-status', {
102
- type: 'doughnut',
103
- data: {
104
- labels: ['Confirmed', 'False Positive', 'Submitted', 'Unclassified'],
105
- datasets: [{
106
- data: [summary.totalConfirmed, summary.totalFalsePositive, summary.totalSubmitted, summary.totalUnclassified],
107
- backgroundColor: ['#16a34a', '#dc2626', '#6358d4', '#ca8a04'],
108
- }],
109
- },
110
- options: { responsive: true, plugins: { legend: { position: 'bottom' } } },
111
- });
112
- return summary;
113
  } catch (err) {
114
  document.getElementById('dda-dash-kpis').innerHTML = `<p class="dim">Could not load overview: ${escapeHtml(err.message || 'error')}</p>`;
115
  return null;
116
  }
117
  }
118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  function roleLabel(role) {
120
  const r = String(role || '').trim();
121
  return r ? r.charAt(0).toUpperCase() + r.slice(1) : 'Unknown';
@@ -159,25 +394,56 @@ function renderDashRolesTable(roles) {
159
  async function loadDashboardRoles() {
160
  const el = document.getElementById('dda-dash-roles');
161
  try {
162
- const data = await ddaApi('GET', '/api/dda/dashboard/roles');
163
- const roles = data.roles || [];
164
- renderDashRolesTable(roles);
165
- renderDashChart('dda-chart-roles', {
166
- type: 'bar',
167
- data: {
168
- labels: roles.map((r) => roleLabel(r.role)),
169
- datasets: [{ label: 'Detections', data: roles.map((r) => r.runsCount), backgroundColor: '#16a34a' }],
170
- },
171
- options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { precision: 0 } } } },
172
- });
173
  } catch (err) {
174
  if (el) el.innerHTML = `<p class="dim">Could not load role breakdown: ${escapeHtml(err.message || 'error')}</p>`;
175
  }
176
  }
177
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  let dashRegionsCache = [];
179
  let dashSelectedZone = '';
180
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  function renderDashRegionsTable(regions) {
182
  const el = document.getElementById('dda-dash-regions');
183
  if (!el) return;
@@ -189,10 +455,13 @@ function renderDashRegionsTable(regions) {
189
  <table class="dda-reports-table">
190
  <thead>
191
  <tr>
192
- <th>Region</th>
193
  <th>Runs</th>
 
194
  <th>Detected Changes</th>
195
- <th>Unclassified</th>
 
 
196
  <th>Villages</th>
197
  <th>Avg Change %</th>
198
  <th>Last Detection</th>
@@ -203,8 +472,11 @@ function renderDashRegionsTable(regions) {
203
  <tr class="dda-dash-row-clickable${r.zone === dashSelectedZone ? ' dda-dash-row-active' : ''}" data-zone="${escapeHtml(r.zone)}">
204
  <td>${escapeHtml(r.zone)}</td>
205
  <td>${r.runsCount}</td>
 
206
  <td>${r.detectedChanges}</td>
207
- <td>${r.unclassified}</td>
 
 
208
  <td>${r.villagesCount}</td>
209
  <td>${dashPct(r.avgChangePercentage)}</td>
210
  <td><time datetime="${escapeHtml(r.lastDetectionAt || '')}">${formatHomeDate(r.lastDetectionAt)}</time></td>
@@ -218,12 +490,13 @@ function renderDashRegionsTable(regions) {
218
 
219
  function selectDashRegion(zone) {
220
  dashSelectedZone = dashSelectedZone === zone ? '' : zone;
 
221
  renderDashRegionsTable(dashRegionsCache);
222
  loadDashboardVillages(dashSelectedZone);
223
  const label = document.getElementById('dda-dash-village-filter-label');
224
  const clearBtn = document.getElementById('btn-dash-village-clear');
225
  if (dashSelectedZone) {
226
- label.textContent = `Filtered to region: ${dashSelectedZone}`;
227
  label.classList.remove('hidden');
228
  clearBtn.classList.remove('hidden');
229
  } else {
@@ -234,22 +507,30 @@ function selectDashRegion(zone) {
234
 
235
  async function loadDashboardRegions() {
236
  try {
237
- const data = await ddaApi('GET', '/api/dda/dashboard/regions');
238
- dashRegionsCache = data.regions || [];
239
- renderDashRegionsTable(dashRegionsCache);
240
- renderDashChart('dda-chart-regions', {
241
- type: 'bar',
242
- data: {
243
- labels: dashRegionsCache.map((r) => r.zone),
244
- datasets: [{ label: 'Detections', data: dashRegionsCache.map((r) => r.runsCount), backgroundColor: '#7a6aed' }],
245
- },
246
- options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { precision: 0 } } } },
247
- });
248
  } catch (err) {
249
  document.getElementById('dda-dash-regions').innerHTML = `<p class="dim">Could not load regions: ${escapeHtml(err.message || 'error')}</p>`;
250
  }
251
  }
252
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
  function renderDashVillagesTable(villages) {
254
  const el = document.getElementById('dda-dash-villages');
255
  if (!el) return;
@@ -261,30 +542,42 @@ function renderDashVillagesTable(villages) {
261
  <table class="dda-reports-table">
262
  <thead>
263
  <tr>
264
- <th>Region</th>
265
  <th>Village</th>
266
  <th>Runs</th>
 
267
  <th>Detected Changes</th>
268
- <th>Unclassified</th>
 
 
269
  <th>Avg Change %</th>
270
  <th>Last Detection</th>
271
  </tr>
272
  </thead>
273
  <tbody>
274
- ${villages.map((v) => `
275
- <tr class="dda-dash-row-clickable" data-zone="${escapeHtml(v.zone)}" data-village="${escapeHtml(v.village)}">
 
 
 
276
  <td>${escapeHtml(v.zone)}</td>
277
  <td>${escapeHtml(v.village)}</td>
278
  <td>${v.runsCount}</td>
 
279
  <td>${v.detectedChanges}</td>
280
- <td>${v.unclassified}</td>
 
 
281
  <td>${dashPct(v.avgChangePercentage)}</td>
282
  <td><time datetime="${escapeHtml(v.lastDetectionAt || '')}">${formatHomeDate(v.lastDetectionAt)}</time></td>
283
- </tr>`).join('')}
 
284
  </tbody>
285
  </table>`;
286
  el.querySelectorAll('[data-village]').forEach((row) => {
287
  row.addEventListener('click', () => {
 
 
288
  loadDashboardLocation(row.dataset.zone, row.dataset.village);
289
  document.getElementById('dda-dash-location-card')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
290
  });
@@ -295,29 +588,51 @@ async function loadDashboardVillages(zone) {
295
  const el = document.getElementById('dda-dash-villages');
296
  if (el) el.innerHTML = '<p class="dim">Loading villages…</p>';
297
  try {
298
- const qs = zone ? `?zone=${encodeURIComponent(zone)}` : '';
299
- const data = await ddaApi('GET', `/api/dda/dashboard/villages${qs}`);
300
- const villages = data.villages || [];
301
- renderDashVillagesTable(villages);
302
- const top = villages.slice(0, 15);
303
- renderDashChart('dda-chart-villages', {
304
- type: 'bar',
305
- data: {
306
- labels: top.map((v) => v.village),
307
- datasets: [{ label: 'Detections', data: top.map((v) => v.runsCount), backgroundColor: '#6358d4' }],
308
- },
309
- options: {
310
- indexAxis: 'y',
311
- responsive: true,
312
- plugins: { legend: { display: false } },
313
- scales: { x: { beginAtZero: true, ticks: { precision: 0 } } },
314
- },
315
- });
316
  } catch (err) {
317
  if (el) el.innerHTML = `<p class="dim">Could not load villages: ${escapeHtml(err.message || 'error')}</p>`;
318
  }
319
  }
320
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
321
  function renderDashLocation(data) {
322
  const el = document.getElementById('dda-dash-location');
323
  if (!el) return;
@@ -331,32 +646,38 @@ function renderDashLocation(data) {
331
  <p class="sub dim">${data.runsCount} detection run${data.runsCount === 1 ? '' : 's'} recorded for this location.</p>
332
  <div class="dda-dash-compare-grid">
333
  <div class="dda-dash-compare-col">
334
- <span class="dim">Current</span>
335
  <h4>${escapeHtml(data.latest.title)}</h4>
336
- <p>${dashPct(data.latest.changePercentage)} change · ${data.latest.regionsCount} regions</p>
 
337
  <time class="dim" datetime="${escapeHtml(data.latest.createdAt || '')}">${formatHomeDate(data.latest.createdAt)}</time><br/>
338
  <a class="btn btn-secondary btn-sm" href="/dda/reports/${data.latest.id}" target="_blank" rel="noopener">View Report</a>
339
  </div>
340
  <div class="dda-dash-compare-col">
341
- <span class="dim">Previous</span>
342
  ${data.previous ? `
343
  <h4>${escapeHtml(data.previous.title)}</h4>
344
- <p>${dashPct(data.previous.changePercentage)} change · ${data.previous.regionsCount} regions</p>
 
345
  <time class="dim" datetime="${escapeHtml(data.previous.createdAt || '')}">${formatHomeDate(data.previous.createdAt)}</time><br/>
346
  <a class="btn btn-secondary btn-sm" href="/dda/reports/${data.previous.id}" target="_blank" rel="noopener">View Report</a>
347
  ` : '<p class="dim">No earlier run to compare against.</p>'}
348
  </div>
349
  <div class="dda-dash-compare-col">
350
- <span class="dim">Change vs previous</span>
351
  ${cmp ? `
352
  <p class="dda-dash-delta">${dashDelta(cmp.changePercentageDelta, '%')} change %</p>
353
  <p class="dda-dash-delta">${dashDelta(cmp.regionsCountDelta, '')} regions</p>
354
- ` : '<p class="dim">—</p>'}
 
 
355
  </div>
356
  </div>
 
 
357
  <h4 class="dda-dash-history-title">Detection History</h4>
358
  <table class="dda-reports-table">
359
- <thead><tr><th>Date</th><th>Title</th><th>Change %</th><th>Regions</th><th>Unclassified</th><th></th></tr></thead>
360
  <tbody>
361
  ${data.history.map((r) => `
362
  <tr>
@@ -364,11 +685,40 @@ function renderDashLocation(data) {
364
  <td>${escapeHtml(r.title)}</td>
365
  <td>${dashPct(r.changePercentage)}</td>
366
  <td>${r.regionsCount}</td>
367
- <td>${r.unclassified}</td>
 
 
368
  <td><a class="btn btn-secondary btn-sm" href="/dda/reports/${r.id}" target="_blank" rel="noopener">Report</a></td>
369
  </tr>`).join('')}
370
  </tbody>
371
  </table>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
372
  }
373
 
374
  async function loadDashboardLocation(zone, village) {
@@ -385,67 +735,99 @@ async function loadDashboardLocation(zone, village) {
385
  async function loadDashboardTrend() {
386
  try {
387
  const data = await ddaApi('GET', '/api/dda/dashboard/trend?months=6');
388
- const buckets = data.buckets || [];
389
- renderDashChart('dda-chart-trend', {
390
- type: 'line',
391
- data: {
392
- labels: buckets.map((b) => b.label),
393
- datasets: [{
394
- label: 'Detection runs',
395
- data: buckets.map((b) => b.runsCount),
396
- borderColor: '#7a6aed',
397
- backgroundColor: 'rgba(122,106,237,0.15)',
398
- tension: 0.3,
399
- fill: true,
400
- }],
401
- },
402
- options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { precision: 0 } } } },
403
- });
404
  } catch (_) {
405
  // Chart stays absent — non-critical widget, other sections still work.
406
  }
407
  }
408
 
409
- async function loadDashboardReports() {
410
- const el = document.getElementById('dda-dash-reports');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
411
  if (!el) return;
412
- try {
413
- const data = await ddaApi('GET', '/api/dda/dashboard/recent-reports?limit=8');
414
- const rows = data.reports || [];
415
- if (!rows.length) {
416
- el.innerHTML = '<p class="dim">No detection reports yet. Run a comparison on the Change Detection page.</p>';
417
- return;
 
 
418
  }
419
- el.innerHTML = `
420
- <table class="dda-reports-table">
421
- <thead>
422
- <tr>
423
- <th>Date (IST)</th>
424
- <th>Title</th>
425
- <th>Area</th>
426
- <th>Change %</th>
427
- <th>Regions</th>
428
- <th>Confirmed</th>
429
- <th>Unclassified</th>
430
- <th></th>
431
- </tr>
432
- </thead>
433
- <tbody>
434
- ${rows.map((r) => `
435
- <tr>
436
- <td><time datetime="${escapeHtml(r.createdAt || '')}">${formatHomeDate(r.createdAt)}</time></td>
437
- <td>${escapeHtml(r.title)}</td>
438
- <td>${escapeHtml(r.zone)} &rsaquo; ${escapeHtml(r.village)}</td>
439
- <td>${dashPct(r.changePercentage)}</td>
440
- <td>${r.regionsCount}</td>
441
- <td>${r.confirmed}</td>
442
- <td>${r.unclassified}</td>
443
- <td><a class="btn btn-secondary btn-sm" href="/dda/reports/${r.id}" target="_blank" rel="noopener">Report</a></td>
444
- </tr>`).join('')}
445
- </tbody>
446
- </table>`;
447
- } catch (err) {
448
- el.innerHTML = `<p class="dim">Could not load recent reports: ${escapeHtml(err.message || 'error')}</p>`;
449
  }
450
  }
451
 
@@ -455,31 +837,118 @@ function markDashUpdated() {
455
  el.textContent = `Updated ${formatHomeDate(new Date().toISOString())}`;
456
  }
457
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
458
  async function loadDashboard() {
459
  dashSelectedZone = '';
 
460
  document.getElementById('dda-dash-village-filter-label')?.classList.add('hidden');
461
  document.getElementById('btn-dash-village-clear')?.classList.add('hidden');
462
  document.getElementById('dda-dash-location').innerHTML =
463
  '<p class="dim">Select a village above to compare its latest and previous detection results.</p>';
464
 
465
- const summary = await loadDashboardSummary();
466
- if (!summary || !summary.totalRuns) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
467
  markDashUpdated();
468
- return;
 
469
  }
470
- await Promise.all([
471
- loadDashboardRoles(),
472
- loadDashboardRegions(),
473
- loadDashboardVillages(''),
474
- loadDashboardTrend(),
475
- loadDashboardReports(),
476
- ]);
477
- markDashUpdated();
478
  }
479
 
480
  document.getElementById('btn-dash-refresh')?.addEventListener('click', loadDashboard);
481
  document.getElementById('btn-dash-village-clear')?.addEventListener('click', () => selectDashRegion(''));
482
 
 
 
 
 
 
 
 
 
 
 
 
483
  document.addEventListener('DOMContentLoaded', () => {
484
  loadHomeGreeting();
485
  loadDashboard();
 
2
  * change comparison, reports feed, and charts. Replaces the old
3
  * greeting+3-cards Home page in place. */
4
 
5
+ // ---------------------------------------------------------------- Tabs ----
6
+ // Overview / Charts & Graphs / Breakdown / Image Library, one visible at a
7
+ // time via small card-style tabs, so the dashboard doesn't force one long
8
+ // scroll through every section and sub-section.
9
+ const DASH_TABS = ['overview', 'charts', 'breakdown', 'library'];
10
+ function activateDashTab(tab) {
11
+ DASH_TABS.forEach((t) => {
12
+ const panel = document.getElementById(`dash-tab-${t}`);
13
+ if (panel) panel.hidden = t !== tab;
14
+ });
15
+ document.querySelectorAll('#dda-dash-tab-switch .dda-dash-tab-card').forEach((card) => {
16
+ const active = card.dataset.tab === tab;
17
+ card.classList.toggle('is-active', active);
18
+ card.setAttribute('aria-selected', String(active));
19
+ });
20
+ // Chart.js lays a canvas out at 0x0 if it was created while its container
21
+ // had `hidden` set; force every existing chart to recompute now that its
22
+ // panel is actually visible. Covers both the Charts & Graphs canvases and
23
+ // the Image Library's format doughnut.
24
+ if (tab === 'charts' || tab === 'library') {
25
+ Object.values(dashCharts).forEach((chart) => {
26
+ try { chart.resize(); } catch (_) { /* chart may have been destroyed/replaced */ }
27
+ });
28
+ }
29
+ }
30
+ document.querySelectorAll('#dda-dash-tab-switch .dda-dash-tab-card').forEach((card) => {
31
+ card.addEventListener('click', () => activateDashTab(card.dataset.tab));
32
+ });
33
+
34
  function formatHomeDate(iso) {
35
  return typeof formatDateIst === 'function' ? formatDateIst(iso) : (iso || '—');
36
  }
 
45
  return `${sign}${typeof v === 'number' && suffix === '%' ? v.toFixed(2) : v}${suffix}`;
46
  }
47
 
48
+ // Fixed taxonomy every region is already classified into (see
49
+ // app/dda/change_type_map.py) — same colors/order used everywhere a change
50
+ // type shows up, so a color always means the same thing across the page.
51
+ const CHANGE_TYPE_ORDER = ['New Construction', 'Demolition', 'Extension', 'Vegetation Change', 'Other'];
52
+ const CHANGE_TYPE_COLORS = {
53
+ 'New Construction': '#f97316',
54
+ 'Demolition': '#dc2626',
55
+ 'Extension': '#2563eb',
56
+ 'Vegetation Change': '#16a34a',
57
+ 'Other': '#64748b',
58
+ };
59
+ function changeTypeSlug(t) {
60
+ return String(t || 'Other').replace(/\s+/g, '-');
61
+ }
62
+ /** Small colored pill for the single most common change type in a
63
+ * confirmed set of counts — a lightweight way to surface "what kind of
64
+ * change" without adding a full breakdown column to already-dense tables. */
65
+ function topChangeTypePill(changeTypes) {
66
+ const entries = Object.entries(changeTypes || {});
67
+ if (!entries.length) return '<span class="dim">—</span>';
68
+ entries.sort((a, b) => b[1] - a[1]);
69
+ const [type, count] = entries[0];
70
+ const total = entries.reduce((sum, [, c]) => sum + c, 0);
71
+ return `<span class="dda-changetype-pill dda-changetype-${changeTypeSlug(type)}" title="${escapeHtml(type)}: ${count} of ${total} region(s)">${escapeHtml(type)}${entries.length > 1 ? ` +${entries.length - 1}` : ''}</span>`;
72
+ }
73
+
74
+ /** Every change type present, each its own pill with a count — used for a
75
+ * single report (Detection History), where "per report" detail matters more
76
+ * than the compact single-pill summary that's appropriate when a row is
77
+ * already an aggregate across several reports (Region-wise/Village-wise). */
78
+ function allChangeTypePills(changeTypes) {
79
+ const entries = Object.entries(changeTypes || {});
80
+ if (!entries.length) return '<span class="dim">No regions classified</span>';
81
+ entries.sort((a, b) => b[1] - a[1]);
82
+ return `<div class="dda-changetype-pill-group">${entries.map(([type, count]) =>
83
+ `<span class="dda-changetype-pill dda-changetype-${changeTypeSlug(type)}">${escapeHtml(type)}: ${count}</span>`
84
+ ).join('')}</div>`;
85
+ }
86
+
87
+ /** Stacked bar — one bar per region, one colored segment per change type —
88
+ * the only change-type chart on the dashboard: a separate system-wide
89
+ * doughnut was dropped since its totals are just these bars summed, and
90
+ * showing both read as the same information twice. */
91
+ function renderChangeTypesByRegionChart(regions) {
92
+ const top = regions.slice(0, 10);
93
+ renderDashChart('dda-chart-change-types-region', {
94
+ type: 'bar',
95
+ data: {
96
+ labels: top.map((r) => r.zone),
97
+ datasets: CHANGE_TYPE_ORDER
98
+ .filter((t) => top.some((r) => (r.changeTypes || {})[t]))
99
+ .map((t) => ({
100
+ label: t,
101
+ data: top.map((r) => (r.changeTypes || {})[t] || 0),
102
+ backgroundColor: CHANGE_TYPE_COLORS[t],
103
+ })),
104
+ },
105
+ options: {
106
+ responsive: true,
107
+ plugins: { legend: { position: 'bottom' } },
108
+ scales: {
109
+ x: { stacked: true },
110
+ y: { stacked: true, beginAtZero: true, ticks: { precision: 0 } },
111
+ },
112
+ },
113
+ });
114
+ }
115
+
116
  const dashCharts = {};
117
 
118
+ /** Current From/To date filter, as a URLSearchParams-ready query string
119
+ * fragment (without leading '?') — shared by every dashboard fetch except
120
+ * the trend chart, which has its own fixed month window. */
121
+ function dashDateParams() {
122
+ const from = document.getElementById('dash-filter-from')?.value || '';
123
+ const to = document.getElementById('dash-filter-to')?.value || '';
124
+ const params = new URLSearchParams();
125
+ if (from) params.set('date_from', from);
126
+ if (to) params.set('date_to', to);
127
+ return params.toString();
128
+ }
129
+
130
+ function dashUrl(path) {
131
+ const qs = dashDateParams();
132
+ if (!qs) return path;
133
+ return path + (path.includes('?') ? '&' : '?') + qs;
134
+ }
135
+
136
+ /** Every dashboard chart goes through here so animation and hover behavior
137
+ * stay consistent without repeating options at each call site. Pass
138
+ * `onClick(index)` in config to make chart segments clickable — the cursor
139
+ * switches to a pointer only where a click actually does something. */
140
  function renderDashChart(canvasId, config) {
141
  const canvas = document.getElementById(canvasId);
142
  if (!canvas || typeof Chart === 'undefined') return;
143
  if (dashCharts[canvasId]) {
144
  dashCharts[canvasId].destroy();
145
  }
146
+ const onClick = config.onClick;
147
+ delete config.onClick;
148
+
149
+ // Rounded, hover-highlighted bars and a pop-out hover on doughnut slices —
150
+ // applied to every dataset that doesn't already set these, so each chart
151
+ // call site doesn't have to repeat the same styling.
152
+ (config.data?.datasets || []).forEach((ds) => {
153
+ if (config.type === 'bar' && ds.borderRadius === undefined) {
154
+ ds.borderRadius = 6;
155
+ ds.borderSkipped = false;
156
+ ds.hoverBackgroundColor = ds.hoverBackgroundColor || _dashHoverShade(ds.backgroundColor);
157
+ }
158
+ if (config.type === 'doughnut' && ds.hoverOffset === undefined) {
159
+ ds.hoverOffset = 14;
160
+ }
161
+ if (config.type === 'line' && ds.pointHoverRadius === undefined) {
162
+ ds.pointRadius = ds.pointRadius ?? 3;
163
+ ds.pointHoverRadius = 7;
164
+ }
165
+ });
166
+
167
+ const multiDataset = (config.data?.datasets || []).length > 1;
168
+ const incomingOptions = config.options || {};
169
+ const incomingPlugins = incomingOptions.plugins || {};
170
+ config.options = {
171
+ animation: { duration: 700, easing: 'easeOutQuart' },
172
+ interaction: { intersect: true, mode: 'nearest' },
173
+ ...incomingOptions,
174
+ plugins: {
175
+ ...incomingPlugins,
176
+ tooltip: {
177
+ padding: 10,
178
+ cornerRadius: 8,
179
+ titleFont: { weight: '600' },
180
+ displayColors: config.type === 'doughnut' || multiDataset,
181
+ ...incomingPlugins.tooltip,
182
+ },
183
+ },
184
+ };
185
+
186
+ if (onClick) {
187
+ config.options.onClick = (evt, elements) => {
188
+ if (elements.length) onClick(elements[0].index);
189
+ };
190
+ config.options.onHover = (evt, elements) => {
191
+ evt.native.target.style.cursor = elements.length ? 'pointer' : 'default';
192
+ };
193
+ }
194
  dashCharts[canvasId] = new Chart(canvas, config);
195
  }
196
 
197
+ /** Darken/lighten a hex color a touch for a bar's hover state — cheap
198
+ * "interactive" affordance without needing a second color per dataset. */
199
+ function _dashHoverShade(hex) {
200
+ if (typeof hex !== 'string' || !hex.startsWith('#') || hex.length !== 7) return hex;
201
+ const amt = -24;
202
+ const num = parseInt(hex.slice(1), 16);
203
+ const clamp = (v) => Math.max(0, Math.min(255, v));
204
+ const r = clamp(((num >> 16) & 0xff) + amt);
205
+ const g = clamp(((num >> 8) & 0xff) + amt);
206
+ const b = clamp((num & 0xff) + amt);
207
+ return `rgb(${r}, ${g}, ${b})`;
208
+ }
209
+
210
  async function loadHomeGreeting() {
211
  const el = document.getElementById('dda-home-greeting');
212
  if (!el) return;
213
  try {
214
+ const me = typeof ensureDdaMe === 'function' ? await ensureDdaMe() : await ddaApi('GET', '/api/me');
215
  el.textContent = me.full_name ? `Welcome, ${me.full_name}` : 'Welcome';
216
  } catch (_) {
217
  // Not authenticated — the page-load redirect to /login should already
 
225
  const el = document.getElementById('dda-dash-kpis');
226
  if (!el) return;
227
  const tiles = [
228
+ ['Detection Runs', summary.totalRuns],
229
+ ['Regions Detected', summary.totalDetectedChanges],
230
+ ['Confirmed', summary.totalConfirmed],
231
+ ['False Positive', summary.totalFalsePositive],
232
+ ['Submitted', summary.totalSubmitted],
233
  ['Avg Change %', dashPct(summary.avgChangePercentage)],
234
+ ['Zones Covered', `${summary.totalRegions}${summary.zonesTotal ? `/${summary.zonesTotal}` : ''}`],
235
+ ['Villages Covered', `${summary.totalVillages}${summary.villagesTotal ? `/${summary.villagesTotal}` : ''}`],
236
  ];
237
  el.innerHTML = tiles.map(([label, value]) => `
238
  <a class="stat-box dda-dash-kpi" href="/reports" title="Open Reports">
 
241
  </a>`).join('');
242
  }
243
 
244
+ /** Ranked list of system-wide change-type totals — same numbers as the
245
+ * "Change Types by Zone" stacked chart, just ranked instead of charted a
246
+ * second time (a duplicate doughnut used to sit here and was dropped). */
247
+ function renderRankedChangeTypes(changeTypes) {
248
+ const el = document.getElementById('dda-dash-ranked-changetypes');
249
+ if (!el) return;
250
+ const entries = Object.entries(changeTypes || {});
251
+ if (!entries.length) {
252
+ el.innerHTML = '<p class="dim">No classified changes yet.</p>';
253
+ return;
254
+ }
255
+ entries.sort((a, b) => b[1] - a[1]);
256
+ el.innerHTML = `<div class="dda-dash-ranked-list">${entries.map(([type, count]) => `
257
+ <div class="dda-dash-ranked-row">
258
+ <span class="dda-dash-ranked-swatch" style="background:${CHANGE_TYPE_COLORS[type] || '#64748b'}"></span>
259
+ <span class="dda-dash-ranked-label">${escapeHtml(type)}</span>
260
+ <span class="dda-dash-ranked-count">${count}</span>
261
+ </div>`).join('')}</div>`;
262
+ }
263
+
264
  function renderDashLatest(run) {
265
  const el = document.getElementById('dda-dash-latest');
266
  if (!el) return;
 
280
  <span><strong>${run.regionsCount ?? '—'}</strong> regions</span>
281
  <span><strong>${run.unclassified ?? '—'}</strong> unclassified</span>
282
  </div>
283
+ <div class="dda-dash-latest-changetypes">${allChangeTypePills(run.changeTypes)}</div>
284
  <a class="btn btn-secondary btn-sm" href="/dda/reports/${run.id}" target="_blank" rel="noopener">View Report</a>
285
  </div>
286
  </div>`;
 
288
 
289
  async function loadDashboardSummary() {
290
  try {
291
+ const summary = await ddaApi('GET', dashUrl('/api/dda/dashboard/summary'));
292
+ return applyDashboardSummary(summary);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
  } catch (err) {
294
  document.getElementById('dda-dash-kpis').innerHTML = `<p class="dim">Could not load overview: ${escapeHtml(err.message || 'error')}</p>`;
295
  return null;
296
  }
297
  }
298
 
299
+ function applyDashboardSummary(summary) {
300
+ dashSummaryCache = summary;
301
+ const empty = document.getElementById('dda-dash-empty');
302
+ // Only the Overview tab's KPI/Latest-result cards hide behind the empty
303
+ // state — Charts & Graphs, Breakdown, and Image Library stay reachable.
304
+ const overviewGrid = document.getElementById('dda-dash-overview-grid');
305
+ if (!summary.totalRuns) {
306
+ const filtered = !!dashDateParams();
307
+ const heading = empty?.querySelector('h3');
308
+ const body = empty?.querySelector('p');
309
+ const cta = empty?.querySelector('a');
310
+ if (heading) heading.textContent = filtered ? 'No runs in this date range' : 'No detections yet';
311
+ if (body) {
312
+ body.textContent = filtered
313
+ ? 'Try clearing the From/To filter or picking a wider range.'
314
+ : 'Run your first comparison to start populating the dashboard with detection statistics, region/village breakdowns, and reports.';
315
+ }
316
+ if (cta) cta.classList.toggle('hidden', filtered);
317
+ empty?.classList.remove('hidden');
318
+ overviewGrid?.classList.add('hidden');
319
+ return summary;
320
+ }
321
+ empty?.classList.add('hidden');
322
+ overviewGrid?.classList.remove('hidden');
323
+ renderDashKpis(summary);
324
+ renderDashLatest(summary.latestRun);
325
+ renderRankedChangeTypes(summary.changeTypes);
326
+ renderDashChart('dda-chart-status', {
327
+ type: 'doughnut',
328
+ data: {
329
+ labels: ['Confirmed', 'False Positive', 'Submitted', 'Unclassified'],
330
+ datasets: [{
331
+ data: [summary.totalConfirmed, summary.totalFalsePositive, summary.totalSubmitted, summary.totalUnclassified],
332
+ backgroundColor: ['#16a34a', '#dc2626', '#2563eb', '#ca8a04'],
333
+ }],
334
+ },
335
+ options: {
336
+ responsive: true,
337
+ plugins: {
338
+ legend: { position: 'bottom' },
339
+ tooltip: {
340
+ callbacks: {
341
+ label: (ctx) => {
342
+ const total = ctx.dataset.data.reduce((a, b) => a + b, 0);
343
+ const pct = total ? Math.round((ctx.parsed / total) * 100) : 0;
344
+ return ` ${ctx.label}: ${ctx.parsed} (${pct}%)`;
345
+ },
346
+ },
347
+ },
348
+ },
349
+ },
350
+ });
351
+ return summary;
352
+ }
353
+
354
  function roleLabel(role) {
355
  const r = String(role || '').trim();
356
  return r ? r.charAt(0).toUpperCase() + r.slice(1) : 'Unknown';
 
394
  async function loadDashboardRoles() {
395
  const el = document.getElementById('dda-dash-roles');
396
  try {
397
+ const data = await ddaApi('GET', dashUrl('/api/dda/dashboard/roles'));
398
+ applyDashboardRoles(data.roles || []);
 
 
 
 
 
 
 
 
 
399
  } catch (err) {
400
  if (el) el.innerHTML = `<p class="dim">Could not load role breakdown: ${escapeHtml(err.message || 'error')}</p>`;
401
  }
402
  }
403
 
404
+ function applyDashboardRoles(roles) {
405
+ const el = document.getElementById('dda-dash-roles');
406
+ renderDashRolesTable(roles);
407
+ renderDashChart('dda-chart-roles', {
408
+ type: 'bar',
409
+ data: {
410
+ labels: roles.map((r) => roleLabel(r.role)),
411
+ datasets: [{ label: 'Detections', data: roles.map((r) => r.runsCount), backgroundColor: '#2d2b2b' }],
412
+ },
413
+ options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { precision: 0 } } } },
414
+ });
415
+ }
416
+
417
  let dashRegionsCache = [];
418
  let dashSelectedZone = '';
419
 
420
+ /** Small horizontal stacked bar visualizing Confirmed/False Positive/
421
+ * Submitted/Unclassified as proportions of total regions — used wherever a
422
+ * classification breakdown would otherwise be four more table columns. */
423
+ function classificationBar(r) {
424
+ const confirmed = r.confirmed || 0;
425
+ const falsePositive = r.falsePositive || 0;
426
+ const submitted = r.submitted || 0;
427
+ const unclassified = r.unclassified || 0;
428
+ const total = confirmed + falsePositive + submitted + unclassified;
429
+ if (!total) return '<span class="dim">—</span>';
430
+ const seg = (val, cls) => (val > 0
431
+ ? `<span class="dda-class-seg dda-class-${cls}" style="width:${(val / total * 100).toFixed(1)}%"></span>`
432
+ : '');
433
+ const title = `${confirmed} confirmed · ${falsePositive} false positive · ${submitted} submitted · ${unclassified} unclassified`;
434
+ return `
435
+ <div class="dda-class-bar" title="${escapeHtml(title)}">
436
+ ${seg(confirmed, 'confirmed')}${seg(falsePositive, 'fp')}${seg(submitted, 'submitted')}${seg(unclassified, 'unclassified')}
437
+ </div>
438
+ <span class="dim dda-class-bar-caption">${confirmed}/${falsePositive}/${submitted}/${unclassified}</span>`;
439
+ }
440
+
441
+ function accuracyCell(rate) {
442
+ if (rate == null) return '<span class="dim">Not yet reviewed</span>';
443
+ const cls = rate >= 66 ? 'dda-accuracy-good' : rate >= 33 ? 'dda-accuracy-mid' : 'dda-accuracy-low';
444
+ return `<span class="${cls}">${rate.toFixed(1)}%</span>`;
445
+ }
446
+
447
  function renderDashRegionsTable(regions) {
448
  const el = document.getElementById('dda-dash-regions');
449
  if (!el) return;
 
455
  <table class="dda-reports-table">
456
  <thead>
457
  <tr>
458
+ <th>Zone</th>
459
  <th>Runs</th>
460
+ <th>Images</th>
461
  <th>Detected Changes</th>
462
+ <th>Top Change Type</th>
463
+ <th>Classification (confirmed/FP/submitted/unclassified)</th>
464
+ <th>Accuracy</th>
465
  <th>Villages</th>
466
  <th>Avg Change %</th>
467
  <th>Last Detection</th>
 
472
  <tr class="dda-dash-row-clickable${r.zone === dashSelectedZone ? ' dda-dash-row-active' : ''}" data-zone="${escapeHtml(r.zone)}">
473
  <td>${escapeHtml(r.zone)}</td>
474
  <td>${r.runsCount}</td>
475
+ <td>${r.imageCount ?? 0}</td>
476
  <td>${r.detectedChanges}</td>
477
+ <td>${topChangeTypePill(r.changeTypes)}</td>
478
+ <td>${classificationBar(r)}</td>
479
+ <td>${accuracyCell(r.confirmedRate)}</td>
480
  <td>${r.villagesCount}</td>
481
  <td>${dashPct(r.avgChangePercentage)}</td>
482
  <td><time datetime="${escapeHtml(r.lastDetectionAt || '')}">${formatHomeDate(r.lastDetectionAt)}</time></td>
 
490
 
491
  function selectDashRegion(zone) {
492
  dashSelectedZone = dashSelectedZone === zone ? '' : zone;
493
+ dashSelectedVillageKey = '';
494
  renderDashRegionsTable(dashRegionsCache);
495
  loadDashboardVillages(dashSelectedZone);
496
  const label = document.getElementById('dda-dash-village-filter-label');
497
  const clearBtn = document.getElementById('btn-dash-village-clear');
498
  if (dashSelectedZone) {
499
+ label.textContent = `Filtered to zone: ${dashSelectedZone}`;
500
  label.classList.remove('hidden');
501
  clearBtn.classList.remove('hidden');
502
  } else {
 
507
 
508
  async function loadDashboardRegions() {
509
  try {
510
+ const data = await ddaApi('GET', dashUrl('/api/dda/dashboard/regions'));
511
+ applyDashboardRegions(data.regions || []);
 
 
 
 
 
 
 
 
 
512
  } catch (err) {
513
  document.getElementById('dda-dash-regions').innerHTML = `<p class="dim">Could not load regions: ${escapeHtml(err.message || 'error')}</p>`;
514
  }
515
  }
516
 
517
+ function applyDashboardRegions(regions) {
518
+ dashRegionsCache = regions;
519
+ renderDashRegionsTable(dashRegionsCache);
520
+ renderDashChart('dda-chart-regions', {
521
+ type: 'bar',
522
+ data: {
523
+ labels: dashRegionsCache.map((r) => r.zone),
524
+ datasets: [{ label: 'Detections', data: dashRegionsCache.map((r) => r.runsCount), backgroundColor: '#ec3013' }],
525
+ },
526
+ options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { precision: 0 } } } },
527
+ onClick: (i) => selectDashRegion(dashRegionsCache[i]?.zone),
528
+ });
529
+ renderChangeTypesByRegionChart(dashRegionsCache);
530
+ }
531
+
532
+ let dashSelectedVillageKey = '';
533
+
534
  function renderDashVillagesTable(villages) {
535
  const el = document.getElementById('dda-dash-villages');
536
  if (!el) return;
 
542
  <table class="dda-reports-table">
543
  <thead>
544
  <tr>
545
+ <th>Zone</th>
546
  <th>Village</th>
547
  <th>Runs</th>
548
+ <th>Images</th>
549
  <th>Detected Changes</th>
550
+ <th>Top Change Type</th>
551
+ <th>Classification (confirmed/FP/submitted/unclassified)</th>
552
+ <th>Accuracy</th>
553
  <th>Avg Change %</th>
554
  <th>Last Detection</th>
555
  </tr>
556
  </thead>
557
  <tbody>
558
+ ${villages.map((v) => {
559
+ const key = `${v.zone} ${v.village}`;
560
+ return `
561
+ <tr class="dda-dash-row-clickable${key === dashSelectedVillageKey ? ' dda-dash-row-active' : ''}"
562
+ data-zone="${escapeHtml(v.zone)}" data-village="${escapeHtml(v.village)}">
563
  <td>${escapeHtml(v.zone)}</td>
564
  <td>${escapeHtml(v.village)}</td>
565
  <td>${v.runsCount}</td>
566
+ <td>${v.imageCount ?? 0}</td>
567
  <td>${v.detectedChanges}</td>
568
+ <td>${topChangeTypePill(v.changeTypes)}</td>
569
+ <td>${classificationBar(v)}</td>
570
+ <td>${accuracyCell(v.confirmedRate)}</td>
571
  <td>${dashPct(v.avgChangePercentage)}</td>
572
  <td><time datetime="${escapeHtml(v.lastDetectionAt || '')}">${formatHomeDate(v.lastDetectionAt)}</time></td>
573
+ </tr>`;
574
+ }).join('')}
575
  </tbody>
576
  </table>`;
577
  el.querySelectorAll('[data-village]').forEach((row) => {
578
  row.addEventListener('click', () => {
579
+ dashSelectedVillageKey = `${row.dataset.zone} ${row.dataset.village}`;
580
+ renderDashVillagesTable(villages);
581
  loadDashboardLocation(row.dataset.zone, row.dataset.village);
582
  document.getElementById('dda-dash-location-card')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
583
  });
 
588
  const el = document.getElementById('dda-dash-villages');
589
  if (el) el.innerHTML = '<p class="dim">Loading villages…</p>';
590
  try {
591
+ const params = new URLSearchParams(dashDateParams());
592
+ if (zone) params.set('zone', zone);
593
+ const qs = params.toString();
594
+ const data = await ddaApi('GET', `/api/dda/dashboard/villages${qs ? `?${qs}` : ''}`);
595
+ applyDashboardVillages(data.villages || []);
 
 
 
 
 
 
 
 
 
 
 
 
 
596
  } catch (err) {
597
  if (el) el.innerHTML = `<p class="dim">Could not load villages: ${escapeHtml(err.message || 'error')}</p>`;
598
  }
599
  }
600
 
601
+ function applyDashboardVillages(villages) {
602
+ renderDashVillagesTable(villages);
603
+ if (villages.length && !dashSelectedVillageKey) {
604
+ dashSelectedVillageKey = `${villages[0].zone} ${villages[0].village}`;
605
+ renderDashVillagesTable(villages);
606
+ loadDashboardLocation(villages[0].zone, villages[0].village);
607
+ }
608
+ const top = villages.slice(0, 15);
609
+ renderDashChart('dda-chart-villages', {
610
+ type: 'bar',
611
+ data: {
612
+ labels: top.map((v) => v.village),
613
+ datasets: [{ label: 'Detections', data: top.map((v) => v.runsCount), backgroundColor: '#e15b47' }],
614
+ },
615
+ options: {
616
+ indexAxis: 'y',
617
+ responsive: true,
618
+ plugins: { legend: { display: false } },
619
+ scales: { x: { beginAtZero: true, ticks: { precision: 0 } } },
620
+ },
621
+ onClick: (i) => {
622
+ const v = top[i];
623
+ if (!v) return;
624
+ dashSelectedVillageKey = `${v.zone} ${v.village}`;
625
+ renderDashVillagesTable(villages);
626
+ loadDashboardLocation(v.zone, v.village);
627
+ document.getElementById('dda-dash-location-card')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
628
+ },
629
+ });
630
+ }
631
+
632
+ function dashClassRow(label, r) {
633
+ return `<p class="dda-dash-class-line"><span class="dim">${label}</span> ${r.confirmed}/${r.falsePositive}/${r.submitted}/${r.unclassified} &middot; ${accuracyCell(r.confirmedRate)}</p>`;
634
+ }
635
+
636
  function renderDashLocation(data) {
637
  const el = document.getElementById('dda-dash-location');
638
  if (!el) return;
 
646
  <p class="sub dim">${data.runsCount} detection run${data.runsCount === 1 ? '' : 's'} recorded for this location.</p>
647
  <div class="dda-dash-compare-grid">
648
  <div class="dda-dash-compare-col">
649
+ <span class="dim">Recent Change Detection</span>
650
  <h4>${escapeHtml(data.latest.title)}</h4>
651
+ <p>${dashPct(data.latest.changePercentage)} change &middot; ${data.latest.regionsCount} regions</p>
652
+ ${dashClassRow('Confirmed/FP/Submitted/Unclassified', data.latest)}
653
  <time class="dim" datetime="${escapeHtml(data.latest.createdAt || '')}">${formatHomeDate(data.latest.createdAt)}</time><br/>
654
  <a class="btn btn-secondary btn-sm" href="/dda/reports/${data.latest.id}" target="_blank" rel="noopener">View Report</a>
655
  </div>
656
  <div class="dda-dash-compare-col">
657
+ <span class="dim">Previous Change Detection</span>
658
  ${data.previous ? `
659
  <h4>${escapeHtml(data.previous.title)}</h4>
660
+ <p>${dashPct(data.previous.changePercentage)} change &middot; ${data.previous.regionsCount} regions</p>
661
+ ${dashClassRow('Confirmed/FP/Submitted/Unclassified', data.previous)}
662
  <time class="dim" datetime="${escapeHtml(data.previous.createdAt || '')}">${formatHomeDate(data.previous.createdAt)}</time><br/>
663
  <a class="btn btn-secondary btn-sm" href="/dda/reports/${data.previous.id}" target="_blank" rel="noopener">View Report</a>
664
  ` : '<p class="dim">No earlier run to compare against.</p>'}
665
  </div>
666
  <div class="dda-dash-compare-col">
667
+ <span class="dim">Recent vs Previous</span>
668
  ${cmp ? `
669
  <p class="dda-dash-delta">${dashDelta(cmp.changePercentageDelta, '%')} change %</p>
670
  <p class="dda-dash-delta">${dashDelta(cmp.regionsCountDelta, '')} regions</p>
671
+ <p class="dda-dash-delta">${dashDelta(cmp.confirmedRateDelta, '%')} accuracy</p>
672
+ <p class="dda-dash-delta">${dashDelta(cmp.falsePositiveDelta, '')} false positives</p>
673
+ ` : '<p class="dim">Run a second detection here to see a comparison.</p>'}
674
  </div>
675
  </div>
676
+ ${data.summary ? `<p class="dda-dash-summary">${escapeHtml(data.summary)}</p>` : ''}
677
+ ${data.previous ? '<div class="dda-dash-chart-box dda-dash-compare-chart"><canvas id="dda-chart-location-compare" height="140"></canvas></div>' : ''}
678
  <h4 class="dda-dash-history-title">Detection History</h4>
679
  <table class="dda-reports-table">
680
+ <thead><tr><th>Date</th><th>Title</th><th>Change %</th><th>Regions</th><th>Change Types (this report)</th><th>Classification (confirmed/FP/submitted/unclassified)</th><th>Accuracy</th><th></th></tr></thead>
681
  <tbody>
682
  ${data.history.map((r) => `
683
  <tr>
 
685
  <td>${escapeHtml(r.title)}</td>
686
  <td>${dashPct(r.changePercentage)}</td>
687
  <td>${r.regionsCount}</td>
688
+ <td>${allChangeTypePills(r.changeTypes)}</td>
689
+ <td>${classificationBar(r)}</td>
690
+ <td>${accuracyCell(r.confirmedRate)}</td>
691
  <td><a class="btn btn-secondary btn-sm" href="/dda/reports/${r.id}" target="_blank" rel="noopener">Report</a></td>
692
  </tr>`).join('')}
693
  </tbody>
694
  </table>`;
695
+
696
+ if (data.previous) {
697
+ renderDashChart('dda-chart-location-compare', {
698
+ type: 'bar',
699
+ data: {
700
+ labels: ['Confirmed', 'False Positive', 'Submitted', 'Unclassified'],
701
+ datasets: [
702
+ {
703
+ label: 'Previous',
704
+ data: [data.previous.confirmed, data.previous.falsePositive, data.previous.submitted, data.previous.unclassified],
705
+ backgroundColor: '#d7d3d3',
706
+ },
707
+ {
708
+ label: 'Recent',
709
+ data: [data.latest.confirmed, data.latest.falsePositive, data.latest.submitted, data.latest.unclassified],
710
+ backgroundColor: '#ec3013',
711
+ },
712
+ ],
713
+ },
714
+ options: {
715
+ responsive: true,
716
+ animation: { duration: 500, easing: 'easeOutQuart' },
717
+ plugins: { legend: { position: 'bottom' } },
718
+ scales: { y: { beginAtZero: true, ticks: { precision: 0 } } },
719
+ },
720
+ });
721
+ }
722
  }
723
 
724
  async function loadDashboardLocation(zone, village) {
 
735
  async function loadDashboardTrend() {
736
  try {
737
  const data = await ddaApi('GET', '/api/dda/dashboard/trend?months=6');
738
+ applyDashboardTrend(data.buckets || []);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
739
  } catch (_) {
740
  // Chart stays absent — non-critical widget, other sections still work.
741
  }
742
  }
743
 
744
+ function applyDashboardTrend(buckets) {
745
+ renderDashChart('dda-chart-trend', {
746
+ type: 'line',
747
+ data: {
748
+ labels: buckets.map((b) => b.label),
749
+ datasets: [{
750
+ label: 'Detection runs',
751
+ data: buckets.map((b) => b.runsCount),
752
+ borderColor: '#ec3013',
753
+ backgroundColor: 'rgba(236,48,19,0.12)',
754
+ tension: 0.3,
755
+ fill: true,
756
+ }],
757
+ },
758
+ options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { precision: 0 } } } },
759
+ onClick: (i) => {
760
+ const period = buckets[i]?.period; // "YYYY-MM"
761
+ if (!period) return;
762
+ const [y, m] = period.split('-').map(Number);
763
+ const lastDay = new Date(y, m, 0).getDate();
764
+ const from = document.getElementById('dash-filter-from');
765
+ const to = document.getElementById('dash-filter-to');
766
+ if (from) from.value = `${period}-01`;
767
+ if (to) to.value = `${period}-${String(lastDay).padStart(2, '0')}`;
768
+ loadDashboard();
769
+ },
770
+ });
771
+ }
772
+
773
+ let dashLiveJobTimer = null;
774
+
775
+ function dashElapsedLabel(startedAtIso) {
776
+ if (!startedAtIso) return '';
777
+ const started = new Date(startedAtIso).getTime();
778
+ if (Number.isNaN(started)) return '';
779
+ const secs = Math.max(0, Math.floor((Date.now() - started) / 1000));
780
+ const m = Math.floor(secs / 60);
781
+ const s = secs % 60;
782
+ return `started ${m}m ${s}s ago`;
783
+ }
784
+
785
+ /** Banner for a detection job that's actually running right now — real
786
+ * progress/stage/elapsed time from GET /api/dda/jobs, not a placeholder.
787
+ * Polls every 15s while a job is live so it doesn't look stuck, and stops
788
+ * polling itself once nothing is running. */
789
+ function renderDashLiveBanner(jobs) {
790
+ const el = document.getElementById('dda-dash-live-banner');
791
  if (!el) return;
792
+ const running = jobs.filter((j) => j.status === 'running');
793
+ const queued = jobs.filter((j) => j.status === 'queued');
794
+ if (!running.length) {
795
+ el.classList.add('hidden');
796
+ el.innerHTML = '';
797
+ if (dashLiveJobTimer) {
798
+ clearInterval(dashLiveJobTimer);
799
+ dashLiveJobTimer = null;
800
  }
801
+ return;
802
+ }
803
+ const job = running[0];
804
+ const loc = [job.zone, job.village].filter(Boolean).join(' · ');
805
+ const pct = Math.max(0, Math.min(100, Math.round(job.progressPct || 0)));
806
+ const meta = [job.method, job.progressStage, dashElapsedLabel(job.startedAt)].filter(Boolean).join(' · ');
807
+ el.classList.remove('hidden');
808
+ el.innerHTML = `
809
+ <span class="dda-dash-live-dot"></span>
810
+ <div class="dda-dash-live-info">
811
+ <div class="dda-dash-live-title">Detection job running${loc ? ` — ${escapeHtml(loc)}` : ''}</div>
812
+ <div class="dda-dash-live-meta">${escapeHtml(meta)}</div>
813
+ </div>
814
+ <div class="dda-dash-live-bar"><div class="dda-dash-live-bar-fill" style="width:${pct}%"></div></div>
815
+ ${queued.length ? `<span class="dda-dash-live-queued">${queued.length} queued</span>` : ''}
816
+ <a class="btn btn-ghost btn-sm" href="/detect">View progress</a>`;
817
+
818
+ if (!dashLiveJobTimer) {
819
+ dashLiveJobTimer = setInterval(loadDashboardLiveJob, 15000);
820
+ }
821
+ }
822
+
823
+ async function loadDashboardLiveJob() {
824
+ try {
825
+ const jobs = typeof getDdaNotifyJobs === 'function'
826
+ ? await getDdaNotifyJobs()
827
+ : ((await ddaApi('GET', '/api/dda/jobs?limit=20')).jobs || []);
828
+ renderDashLiveBanner((jobs || []).filter((j) => j.status === 'running' || j.status === 'queued'));
829
+ } catch (_) {
830
+ // Non-critical widget — leave banner as it was on a transient failure.
831
  }
832
  }
833
 
 
837
  el.textContent = `Updated ${formatHomeDate(new Date().toISOString())}`;
838
  }
839
 
840
+ async function loadDashboardLibraryStats() {
841
+ const kpiEl = document.getElementById('dda-dash-lib-kpis');
842
+ const locEl = document.getElementById('dda-dash-lib-locations');
843
+ try {
844
+ const lib = await ddaApi('GET', '/api/dda/dashboard/library-stats');
845
+
846
+ const tiles = [
847
+ ['Total Images', lib.totalImages],
848
+ ['Assigned to a Location', lib.assignedImages],
849
+ ['Unassigned', lib.unassignedImages],
850
+ ['Zones Covered', lib.zonesCovered],
851
+ ['Villages Covered', lib.villagesCovered],
852
+ ];
853
+ if (kpiEl) {
854
+ kpiEl.innerHTML = tiles.map(([label, value]) => `
855
+ <a class="stat-box dda-dash-kpi" href="/library" title="Open Image Library">
856
+ <span class="value">${escapeHtml(String(value ?? '—'))}</span>
857
+ <span class="label">${escapeHtml(label)}</span>
858
+ </a>`).join('');
859
+ }
860
+
861
+ renderDashChart('dda-chart-lib-format', {
862
+ type: 'doughnut',
863
+ data: {
864
+ labels: lib.byFormat.map((f) => f.format),
865
+ datasets: [{
866
+ data: lib.byFormat.map((f) => f.count),
867
+ backgroundColor: ['#2d2b2b', '#ec3013', '#d7d3d3', '#2563eb', '#16a34a', '#64748b'],
868
+ }],
869
+ },
870
+ options: { responsive: true, plugins: { legend: { position: 'bottom' } } },
871
+ });
872
+
873
+ if (locEl) {
874
+ const zoneRows = lib.byZone.length
875
+ ? lib.byZone.map((z) => `<tr><td>${escapeHtml(z.zone)}</td><td>${z.imageCount}</td></tr>`).join('')
876
+ : '<tr><td colspan="2" class="dim">No images assigned to a zone yet.</td></tr>';
877
+ const villageRows = lib.byVillage.length
878
+ ? lib.byVillage.slice(0, 20).map((v) => `<tr><td>${escapeHtml(v.zone)}</td><td>${escapeHtml(v.village)}</td><td>${v.imageCount}</td></tr>`).join('')
879
+ : '<tr><td colspan="3" class="dim">No images assigned to a village yet.</td></tr>';
880
+ locEl.innerHTML = `
881
+ <div class="dda-dash-overview-grid">
882
+ <table class="dda-reports-table">
883
+ <thead><tr><th>Zone</th><th>Images</th></tr></thead>
884
+ <tbody>${zoneRows}</tbody>
885
+ </table>
886
+ <table class="dda-reports-table">
887
+ <thead><tr><th>Zone</th><th>Village</th><th>Images</th></tr></thead>
888
+ <tbody>${villageRows}</tbody>
889
+ </table>
890
+ </div>
891
+ ${lib.unassignedImages ? `<p class="dim" style="margin-top:0.75rem">${lib.unassignedImages} image(s) have no zone/village tag yet — assign one from the Image Library upload form or an image's detail view.</p>` : ''}`;
892
+ }
893
+ } catch (err) {
894
+ if (kpiEl) kpiEl.innerHTML = `<p class="dim">Could not load library stats: ${escapeHtml(err.message || 'error')}</p>`;
895
+ if (locEl) locEl.innerHTML = '';
896
+ }
897
+ }
898
+
899
  async function loadDashboard() {
900
  dashSelectedZone = '';
901
+ dashSelectedVillageKey = '';
902
  document.getElementById('dda-dash-village-filter-label')?.classList.add('hidden');
903
  document.getElementById('btn-dash-village-clear')?.classList.add('hidden');
904
  document.getElementById('dda-dash-location').innerHTML =
905
  '<p class="dim">Select a village above to compare its latest and previous detection results.</p>';
906
 
907
+ // Independent of detection history — the library can (and usually does)
908
+ // have images even with zero runs yet, so this isn't gated behind
909
+ // summary.totalRuns like the sections below it.
910
+ loadDashboardLibraryStats();
911
+ loadDashboardLiveJob();
912
+
913
+ try {
914
+ const params = new URLSearchParams(dashDateParams());
915
+ params.set('months', '6');
916
+ const qs = params.toString();
917
+ const data = await ddaApi('GET', `/api/dda/dashboard/bootstrap${qs ? `?${qs}` : ''}`);
918
+ const summary = applyDashboardSummary(data.summary || {});
919
+ if (!summary || !summary.totalRuns) {
920
+ // Charts & Graphs / Breakdown stay reachable via tabs — clear placeholders.
921
+ renderDashRolesTable([]);
922
+ dashRegionsCache = [];
923
+ renderDashRegionsTable([]);
924
+ renderDashVillagesTable([]);
925
+ markDashUpdated();
926
+ return;
927
+ }
928
+ applyDashboardRoles(data.roles || []);
929
+ applyDashboardRegions(data.regions || []);
930
+ applyDashboardVillages(data.villages || []);
931
+ applyDashboardTrend((data.trend && data.trend.buckets) || []);
932
  markDashUpdated();
933
+ } catch (err) {
934
+ document.getElementById('dda-dash-kpis').innerHTML = `<p class="dim">Could not load overview: ${escapeHtml(err.message || 'error')}</p>`;
935
  }
 
 
 
 
 
 
 
 
936
  }
937
 
938
  document.getElementById('btn-dash-refresh')?.addEventListener('click', loadDashboard);
939
  document.getElementById('btn-dash-village-clear')?.addEventListener('click', () => selectDashRegion(''));
940
 
941
+ // Date filter applies on an explicit Search click, not on every date pick —
942
+ // picking From then To would otherwise fire two reloads back to back.
943
+ document.getElementById('btn-dash-filter-apply')?.addEventListener('click', loadDashboard);
944
+ document.getElementById('btn-dash-filter-clear')?.addEventListener('click', () => {
945
+ const from = document.getElementById('dash-filter-from');
946
+ const to = document.getElementById('dash-filter-to');
947
+ if (from) from.value = '';
948
+ if (to) to.value = '';
949
+ loadDashboard();
950
+ });
951
+
952
  document.addEventListener('DOMContentLoaded', () => {
953
  loadHomeGreeting();
954
  loadDashboard();
static/js/dda/library.js CHANGED
@@ -115,14 +115,17 @@ function openLibraryImageViewer(img) {
115
  revokeViewerObjectUrl();
116
  libraryViewerState.image = img;
117
  title.textContent = img.filename || img.imageName || 'Image';
118
- const parts = [
119
- img.breadcrumb || img.nodePath || '',
120
- img.imageType || '',
121
- typeof formatBytes === 'function' ? formatBytes(img.fileSizeBytes) : '',
122
- img.width && img.height ? `${img.width}×${img.height}` : '',
123
- img.hasGeoref ? 'georeferenced' : '',
124
- ].filter(Boolean);
125
- meta.textContent = parts.join(' · ');
 
 
 
126
  imageEl.alt = img.filename || 'Library image';
127
  imageEl.removeAttribute('src');
128
  setViewerStatus('Loading preview…', false);
@@ -274,22 +277,116 @@ function uploadWithProgress(url, formData, onProgress) {
274
  });
275
  }
276
 
277
- const LIBRARY_IMAGE_EXTS = ['.tif', '.tiff', '.png', '.jpg', '.jpeg'];
 
 
 
 
 
278
  let pendingUploadFiles = [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
 
280
  function isLibraryImageFile(file) {
281
  const name = (file.name || '').toLowerCase();
282
  if (!name || name.startsWith('.') || name === '.ds_store') return false;
283
  if ((file._relativePath || file.webkitRelativePath || '').includes('__macosx')) return false;
284
- return LIBRARY_IMAGE_EXTS.some((ext) => name.endsWith(ext));
285
- }
286
-
287
- function guessImageType(file) {
288
- const ext = (file.name.split('.').pop() || '').toLowerCase();
289
- if (ext === 'tif' || ext === 'tiff') return 'GeoTIFF';
290
- if (ext === 'png') return 'PNG';
291
- if (ext === 'jpg' || ext === 'jpeg') return 'JPEG';
292
- return document.getElementById('upload-image-type')?.value || 'GeoTIFF';
293
  }
294
 
295
  function relativeUploadPath(file) {
@@ -312,24 +409,237 @@ function fileTooLarge(file) {
312
  function updateUploadSummary() {
313
  const el = document.getElementById('upload-file-summary');
314
  if (!el) return;
315
- const images = pendingUploadFiles.filter(isLibraryImageFile);
316
- const skipped = pendingUploadFiles.length - images.length;
317
- if (!pendingUploadFiles.length) {
318
  el.textContent = 'No files selected.';
319
  return;
320
  }
321
- el.textContent = `${images.length} image${images.length === 1 ? '' : 's'} ready` +
322
- (skipped ? ` · ${skipped} skipped` : '');
323
  }
324
 
325
- function setPendingUploadFiles(files) {
326
- pendingUploadFiles = Array.from(files || []);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
327
  updateUploadSummary();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  }
329
 
330
  function setUploadTargetNode(nodeId) {
331
  const sel = document.getElementById('upload-node');
332
- if (sel && nodeId) sel.value = String(nodeId);
 
 
 
333
  document.getElementById('upload-card')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
334
  }
335
 
@@ -346,24 +656,80 @@ async function ensureFolderPath(parentId, segments) {
346
  return current;
347
  }
348
 
349
- async function uploadOneLibraryFile(nodeId, file, { onProgress } = {}) {
350
- const oversized = fileTooLarge(file);
351
- if (oversized) throw new Error(oversized);
352
- const form = new FormData();
353
- form.append('file', file);
354
- form.append('image_type', document.getElementById('upload-image-type')?.value || guessImageType(file));
355
  form.append('capture_date', document.getElementById('upload-capture-date')?.value || '');
356
  const manualBounds = document.getElementById('upload-manual-bounds')?.value?.trim();
357
  if (manualBounds) form.append('manual_bounds', manualBounds);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
358
  return uploadWithProgress(`/api/dda/tree/nodes/${nodeId}/images/upload`, form, onProgress);
359
  }
360
 
361
- async function uploadLibraryFiles(files, { nodeId, preserveFolders = true } = {}) {
 
 
 
 
 
 
 
 
 
 
362
  if (uploadLibraryFiles._busy) return 0;
363
- const list = Array.from(files || []).filter(isLibraryImageFile);
364
- if (!list.length) throw new Error('No GeoTIFF, PNG, or JPEG files to upload.');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
365
  const dest = nodeId || document.getElementById('upload-node')?.value;
366
- const needsFolders = preserveFolders && list.some((f) => relativeUploadPath(f).includes('/'));
367
  const canCreateFolders = typeof hasPermission === 'function'
368
  ? hasPermission(window.ddaPermissions, 'library', 'create')
369
  : window.ddaState?.userRole === 'admin';
@@ -383,10 +749,12 @@ async function uploadLibraryFiles(files, { nodeId, preserveFolders = true } = {}
383
  const folderCache = new Map();
384
  let ok = 0;
385
  const errors = [];
 
386
  try {
387
- for (let i = 0; i < list.length; i++) {
388
- const file = list[i];
389
- const rel = relativeUploadPath(file);
 
390
  const parts = rel.split('/').filter(Boolean);
391
  const dirParts = parts.length > 1 ? parts.slice(0, -1) : [];
392
  let targetId = dest ? Number(dest) : null;
@@ -398,33 +766,73 @@ async function uploadLibraryFiles(files, { nodeId, preserveFolders = true } = {}
398
  targetId = folderCache.get(key);
399
  }
400
  if (!targetId) throw new Error('Select a destination folder.');
401
- if (progLabel) {
402
- progLabel.textContent = `Uploading ${i + 1} of ${list.length}: ${file.name}`;
403
- }
404
- if (progFill) progFill.style.width = `${Math.round((i / list.length) * 100)}%`;
405
- try {
406
- await uploadOneLibraryFile(targetId, file, {
407
- onProgress: (loaded, total) => {
408
- const filePct = total ? loaded / total : 1;
409
- const overall = ((i + filePct) / list.length) * 100;
410
- if (progFill) progFill.style.width = `${Math.round(overall)}%`;
411
- },
412
- });
413
- ok += 1;
414
- } catch (err) {
415
- errors.push(`${file.name}: ${err.message || 'failed'}`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
416
  }
417
  }
418
  if (progFill) progFill.style.width = '100%';
419
- if (ok && typeof window.ddaState?.rescan === 'function') await window.ddaState.rescan();
420
  if (errors.length && ok) {
421
  showDdaError?.(`${ok} uploaded, ${errors.length} failed. ${errors[0]}`);
422
  } else if (errors.length) {
423
  throw new Error(errors[0]);
424
  } else {
425
- showDdaSuccess?.(`Uploaded ${ok} image${ok === 1 ? '' : 's'}.`);
 
 
 
 
 
 
 
 
 
 
 
 
426
  }
427
- setPendingUploadFiles([]);
 
 
 
428
  const fileInput = document.getElementById('upload-file');
429
  const folderInput = document.getElementById('upload-folder');
430
  if (fileInput) fileInput.value = '';
@@ -504,9 +912,14 @@ function initLibraryDropzone() {
504
  const files = await readDroppedEntries(e.dataTransfer);
505
  if (!files.length) return;
506
  setPendingUploadFiles(files);
 
507
  const dest = document.getElementById('upload-node')?.value;
508
  try {
509
- await uploadLibraryFiles(files, { nodeId: dest, preserveFolders: true });
 
 
 
 
510
  } catch (err) {
511
  showDdaError?.(err.message || 'Upload failed.');
512
  }
@@ -523,24 +936,343 @@ function initLibraryDropzone() {
523
  document.getElementById('form-tree-upload')?.addEventListener('submit', async (e) => {
524
  e.preventDefault();
525
  hideDdaError?.();
526
- const files = pendingUploadFiles.length
527
- ? pendingUploadFiles
528
- : [
529
- ...Array.from(document.getElementById('upload-file')?.files || []),
530
- ...Array.from(document.getElementById('upload-folder')?.files || []),
531
- ];
532
  try {
533
- await uploadLibraryFiles(files, {
534
  nodeId: document.getElementById('upload-node')?.value,
535
  preserveFolders: true,
 
536
  });
537
  } catch (err) {
538
  showDdaError?.(err.message || 'Upload failed.');
539
  }
540
  });
541
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
542
  initLibraryDropzone();
 
 
543
  window.uploadLibraryFiles = uploadLibraryFiles;
544
  window.setUploadTargetNode = setUploadTargetNode;
545
  window.setPendingUploadFiles = setPendingUploadFiles;
546
  window.readDroppedEntries = readDroppedEntries;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  revokeViewerObjectUrl();
116
  libraryViewerState.image = img;
117
  title.textContent = img.filename || img.imageName || 'Image';
118
+ meta.innerHTML = typeof buildImageMetaHtml === 'function'
119
+ ? buildImageMetaHtml(img)
120
+ : [
121
+ img.breadcrumb || img.nodePath || '',
122
+ [img.villageName, img.zoneName].filter(Boolean).join(', '),
123
+ img.captureDate ? `Capture ${String(img.captureDate).slice(0, 10)}` : '',
124
+ img.year != null ? `Year ${img.year}` : '',
125
+ img.imageType || '',
126
+ img.source || '',
127
+ typeof formatBytes === 'function' ? formatBytes(img.fileSizeBytes) : '',
128
+ ].filter(Boolean).join(' · ');
129
  imageEl.alt = img.filename || 'Library image';
130
  imageEl.removeAttribute('src');
131
  setViewerStatus('Loading preview…', false);
 
277
  });
278
  }
279
 
280
+ const IMAGE_TYPE_EXTENSIONS = {
281
+ GeoTIFF: ['.tif', '.tiff'],
282
+ PNG: ['.png'],
283
+ JPEG: ['.jpg', '.jpeg'],
284
+ };
285
+
286
  let pendingUploadFiles = [];
287
+ /** @type {{file: File, serverName: string, selected: boolean}[]} */
288
+ let pendingUploadEntries = [];
289
+
290
+ function currentUploadImageType() {
291
+ return document.getElementById('upload-image-type')?.value || 'GeoTIFF';
292
+ }
293
+
294
+ function allowedExtsForType(imageType) {
295
+ return IMAGE_TYPE_EXTENSIONS[imageType] || IMAGE_TYPE_EXTENSIONS.GeoTIFF;
296
+ }
297
+
298
+ function fileExtension(name) {
299
+ const n = String(name || '');
300
+ const i = n.lastIndexOf('.');
301
+ if (i < 0) return '';
302
+ return n.slice(i).toLowerCase();
303
+ }
304
+
305
+ function formatAllowedExts(exts) {
306
+ if (exts.length === 1) return exts[0];
307
+ if (exts.length === 2) return `${exts[0]} or ${exts[1]}`;
308
+ return `${exts.slice(0, -1).join(', ')} or ${exts[exts.length - 1]}`;
309
+ }
310
+
311
+ function formatRejectedNames(names) {
312
+ const list = names.slice(0, 3);
313
+ const more = names.length - list.length;
314
+ const joined = list.map((n) => `"${n}"`).join(', ');
315
+ return more > 0 ? `${joined}, and ${more} more` : joined;
316
+ }
317
+
318
+ function setUploadTypeError(msg) {
319
+ const el = document.getElementById('upload-type-error');
320
+ if (el) {
321
+ el.textContent = msg || '';
322
+ el.classList.toggle('hidden', !msg);
323
+ }
324
+ if (msg) showDdaError?.(msg);
325
+ else hideDdaError?.();
326
+ }
327
+
328
+ function clearUploadTypeError() {
329
+ setUploadTypeError('');
330
+ }
331
+
332
+ function splitFilesForImageType(files, imageType) {
333
+ const allowed = allowedExtsForType(imageType);
334
+ const accepted = [];
335
+ const rejected = [];
336
+ Array.from(files || []).forEach((file) => {
337
+ const name = file.name || '';
338
+ if (!name || name.startsWith('.') || name.toLowerCase() === '.ds_store') {
339
+ rejected.push(file);
340
+ return;
341
+ }
342
+ if ((file._relativePath || file.webkitRelativePath || '').toLowerCase().includes('__macosx')) {
343
+ rejected.push(file);
344
+ return;
345
+ }
346
+ const ext = fileExtension(name);
347
+ if (!ext || !allowed.includes(ext)) rejected.push(file);
348
+ else accepted.push(file);
349
+ });
350
+ return { accepted, rejected };
351
+ }
352
+
353
+ function rejectMessage(imageType, rejected, { typeChanged = false } = {}) {
354
+ const allowed = allowedExtsForType(imageType);
355
+ const names = rejected.map((f) => f.name || 'file');
356
+ const named = formatRejectedNames(names);
357
+ const allow = formatAllowedExts(allowed);
358
+ if (typeChanged) {
359
+ return `Image type changed to ${imageType}. ${rejected.length} selected file${rejected.length === 1 ? ' is' : 's are'} not ${allow} and ${rejected.length === 1 ? 'was' : 'were'} removed: ${named}.`;
360
+ }
361
+ if (rejected.length === 1) {
362
+ const ext = fileExtension(rejected[0].name);
363
+ if (!ext) {
364
+ return `Image type ${imageType} only allows ${allow}. "${rejected[0].name}" has no supported extension and was not added.`;
365
+ }
366
+ return `Image type ${imageType} only allows ${allow}. "${rejected[0].name}" was not added.`;
367
+ }
368
+ return `Image type ${imageType} only allows ${allow}. ${rejected.length} files were not added: ${named}.`;
369
+ }
370
+
371
+ function syncUploadAcceptAttribute() {
372
+ const type = currentUploadImageType();
373
+ const accept = allowedExtsForType(type).join(',');
374
+ const fileInput = document.getElementById('upload-file');
375
+ const folderInput = document.getElementById('upload-folder');
376
+ if (fileInput) fileInput.accept = accept;
377
+ if (folderInput) folderInput.accept = accept;
378
+ const hint = document.getElementById('upload-accept-hint');
379
+ if (hint) {
380
+ hint.textContent = `${type} only: ${formatAllowedExts(allowedExtsForType(type))}. Multiple files and nested folders are supported.`;
381
+ }
382
+ }
383
 
384
  function isLibraryImageFile(file) {
385
  const name = (file.name || '').toLowerCase();
386
  if (!name || name.startsWith('.') || name === '.ds_store') return false;
387
  if ((file._relativePath || file.webkitRelativePath || '').includes('__macosx')) return false;
388
+ const allowed = allowedExtsForType(currentUploadImageType());
389
+ return allowed.includes(fileExtension(name));
 
 
 
 
 
 
 
390
  }
391
 
392
  function relativeUploadPath(file) {
 
409
  function updateUploadSummary() {
410
  const el = document.getElementById('upload-file-summary');
411
  if (!el) return;
412
+ const n = pendingUploadEntries.length;
413
+ if (!n) {
 
414
  el.textContent = 'No files selected.';
415
  return;
416
  }
417
+ el.textContent = `${n} image${n === 1 ? '' : 's'} ready for ${currentUploadImageType()}.`;
 
418
  }
419
 
420
+ function renderUploadFileList() {
421
+ const wrap = document.getElementById('upload-file-list-wrap');
422
+ const tbody = document.getElementById('upload-file-tbody');
423
+ if (!wrap || !tbody) return;
424
+ if (!pendingUploadEntries.length) {
425
+ wrap.classList.add('hidden');
426
+ tbody.innerHTML = '';
427
+ return;
428
+ }
429
+ wrap.classList.remove('hidden');
430
+ const type = currentUploadImageType();
431
+ tbody.innerHTML = pendingUploadEntries.map((entry, idx) => `
432
+ <tr data-idx="${idx}">
433
+ <td><input type="checkbox" class="upload-file-check" ${entry.selected ? 'checked' : ''} /></td>
434
+ <td class="dda-upload-orig">${escapeHtml(entry.file.name)}</td>
435
+ <td><input type="text" class="upload-server-name" value="${escapeHtml(entry.serverName)}" aria-label="Server filename" /></td>
436
+ <td>${escapeHtml(type)}</td>
437
+ <td>${typeof formatBytes === 'function' ? formatBytes(entry.file.size) : entry.file.size}</td>
438
+ </tr>`).join('');
439
+
440
+ tbody.querySelectorAll('.upload-file-check').forEach((cb) => {
441
+ cb.addEventListener('change', () => {
442
+ const idx = parseInt(cb.closest('tr').dataset.idx, 10);
443
+ if (pendingUploadEntries[idx]) pendingUploadEntries[idx].selected = cb.checked;
444
+ });
445
+ });
446
+ tbody.querySelectorAll('.upload-server-name').forEach((input) => {
447
+ input.addEventListener('change', () => {
448
+ const idx = parseInt(input.closest('tr').dataset.idx, 10);
449
+ if (pendingUploadEntries[idx]) {
450
+ const name = (input.value || '').trim() || pendingUploadEntries[idx].file.name;
451
+ pendingUploadEntries[idx].serverName = name.split(/[/\\]/).pop();
452
+ input.value = pendingUploadEntries[idx].serverName;
453
+ }
454
+ });
455
+ });
456
+ }
457
+
458
+ function setPendingUploadFiles(files, { append = false } = {}) {
459
+ const type = currentUploadImageType();
460
+ const { accepted, rejected } = splitFilesForImageType(files, type);
461
+ if (rejected.length) setUploadTypeError(rejectMessage(type, rejected));
462
+ else clearUploadTypeError();
463
+
464
+ const entries = accepted.map((file) => ({
465
+ file,
466
+ serverName: (file.name || 'upload').split(/[/\\]/).pop(),
467
+ selected: true,
468
+ }));
469
+ pendingUploadEntries = append ? pendingUploadEntries.concat(entries) : entries;
470
+ pendingUploadFiles = pendingUploadEntries.map((e) => e.file);
471
  updateUploadSummary();
472
+ renderUploadFileList();
473
+ }
474
+
475
+ function revalidatePendingForImageType({ typeChanged = false } = {}) {
476
+ const type = currentUploadImageType();
477
+ syncUploadAcceptAttribute();
478
+ if (!pendingUploadEntries.length) {
479
+ clearUploadTypeError();
480
+ updateUploadSummary();
481
+ renderUploadFileList();
482
+ suggestSourceFromType();
483
+ return;
484
+ }
485
+ const files = pendingUploadEntries.map((e) => e.file);
486
+ const { accepted, rejected } = splitFilesForImageType(files, type);
487
+ if (rejected.length) {
488
+ const keepNames = new Set(accepted.map((f) => f));
489
+ pendingUploadEntries = pendingUploadEntries.filter((e) => keepNames.has(e.file));
490
+ pendingUploadFiles = pendingUploadEntries.map((e) => e.file);
491
+ setUploadTypeError(rejectMessage(type, rejected, { typeChanged: true }));
492
+ } else {
493
+ clearUploadTypeError();
494
+ }
495
+ updateUploadSummary();
496
+ renderUploadFileList();
497
+ suggestSourceFromType();
498
+ }
499
+
500
+ function suggestSourceFromType() {
501
+ const sourceSel = document.getElementById('upload-source');
502
+ if (!sourceSel || sourceSel.dataset.userPicked === '1') return;
503
+ const type = currentUploadImageType();
504
+ if (type === 'Satellite') sourceSel.value = 'satellite';
505
+ else if (type === 'Drone') sourceSel.value = 'drone';
506
+ }
507
+
508
+ function populateUploadYearOptions() {
509
+ const sel = document.getElementById('upload-year');
510
+ const filterSel = document.getElementById('lib-filter-year');
511
+ if (!sel) return;
512
+ const current = new Date().getFullYear();
513
+ const years = [];
514
+ for (let y = current; y >= current - 15; y--) years.push(y);
515
+ const prev = sel.value;
516
+ sel.innerHTML = '<option value="">— Select year —</option>' +
517
+ years.map((y) => `<option value="${y}">${y}</option>`).join('');
518
+ if (prev) sel.value = prev;
519
+ if (filterSel) {
520
+ const fprev = filterSel.value;
521
+ filterSel.innerHTML = '<option value="">All</option>' +
522
+ years.map((y) => `<option value="${y}">${y}</option>`).join('');
523
+ if (fprev) filterSel.value = fprev;
524
+ }
525
+ }
526
+
527
+ const uploadLocations = { zones: [], villages: [] };
528
+
529
+ function renderUploadVillageOptions() {
530
+ const villageSel = document.getElementById('upload-village');
531
+ if (!villageSel) return;
532
+ const zoneId = document.getElementById('upload-zone')?.value || '';
533
+ const previous = villageSel.value;
534
+ const list = zoneId
535
+ ? uploadLocations.villages.filter((v) => String(v.zoneId) === String(zoneId))
536
+ : uploadLocations.villages;
537
+ const empty = zoneId ? '— Select village —' : '— Select village (or pick a zone first) —';
538
+ villageSel.innerHTML = `<option value="">${empty}</option>` + list.map((v) => {
539
+ const label = zoneId ? v.name : `${v.name} · ${v.zoneName || ''}`;
540
+ return `<option value="${v.id}">${escapeHtml(label)}</option>`;
541
+ }).join('');
542
+ if (previous && list.some((v) => String(v.id) === String(previous))) villageSel.value = previous;
543
+ }
544
+
545
+ function renderLibFilterLocationOptions() {
546
+ const zoneSel = document.getElementById('lib-filter-zone');
547
+ const villageSel = document.getElementById('lib-filter-village');
548
+ if (zoneSel) {
549
+ const prev = zoneSel.value;
550
+ zoneSel.innerHTML = '<option value="">All</option>' + uploadLocations.zones
551
+ .map((z) => `<option value="${z.id}">${escapeHtml(z.name)}</option>`).join('');
552
+ if (prev) zoneSel.value = prev;
553
+ }
554
+ if (villageSel) {
555
+ const zoneId = zoneSel?.value || '';
556
+ const prev = villageSel.value;
557
+ const list = zoneId
558
+ ? uploadLocations.villages.filter((v) => String(v.zoneId) === String(zoneId))
559
+ : uploadLocations.villages;
560
+ villageSel.innerHTML = '<option value="">All</option>' + list
561
+ .map((v) => `<option value="${v.id}">${escapeHtml(v.name)}</option>`).join('');
562
+ if (prev) villageSel.value = prev;
563
+ }
564
+ }
565
+
566
+ function markUploadLocationPicked() {
567
+ const zoneSel = document.getElementById('upload-zone');
568
+ if (zoneSel) zoneSel.dataset.userPicked = '1';
569
+ }
570
+
571
+ function suggestUploadLocationFromFolder() {
572
+ const zoneSel = document.getElementById('upload-zone');
573
+ const villageSel = document.getElementById('upload-village');
574
+ if (!zoneSel || zoneSel.dataset.userPicked === '1') return;
575
+ const path = document.getElementById('upload-node')?.selectedOptions?.[0]?.textContent || '';
576
+ const segments = path.split(/[/\\]/).map((s) => s.trim().toLowerCase()).filter(Boolean);
577
+ if (!segments.length) return;
578
+ const zone = uploadLocations.zones.find((z) => segments.includes(String(z.name || '').toLowerCase()));
579
+ if (zone) zoneSel.value = String(zone.id);
580
+ renderUploadVillageOptions();
581
+ const matches = uploadLocations.villages.filter((v) => {
582
+ if (!segments.includes(String(v.name || '').toLowerCase())) return false;
583
+ if (zone && String(v.zoneId) !== String(zone.id)) return false;
584
+ return true;
585
+ });
586
+ if (matches.length === 1 && villageSel) {
587
+ if (!zoneSel.value) zoneSel.value = String(matches[0].zoneId);
588
+ renderUploadVillageOptions();
589
+ villageSel.value = String(matches[0].id);
590
+ }
591
+ // Suggest year from folder path segments
592
+ const yearSel = document.getElementById('upload-year');
593
+ if (yearSel && !yearSel.value) {
594
+ const yearSeg = segments.find((s) => /^\d{4}$/.test(s) && (s.startsWith('19') || s.startsWith('20')));
595
+ if (yearSeg) yearSel.value = yearSeg;
596
+ }
597
+ }
598
+
599
+ async function loadUploadLocations() {
600
+ const zoneSel = document.getElementById('upload-zone');
601
+ if (!zoneSel) return;
602
+ try {
603
+ const data = await ddaApi('GET', '/api/dda/masters/options');
604
+ const zoneNames = new Map((data.zones || []).map((z) => [z.id, z.name]));
605
+ uploadLocations.zones = data.zones || [];
606
+ uploadLocations.villages = (data.villages || []).map((v) => ({
607
+ ...v, zoneName: zoneNames.get(v.zoneId) || '',
608
+ }));
609
+ } catch (_) {
610
+ return;
611
+ }
612
+ const previousZone = zoneSel.value;
613
+ zoneSel.innerHTML = '<option value="">— Select zone —</option>' + uploadLocations.zones
614
+ .map((z) => `<option value="${z.id}">${escapeHtml(z.name)}</option>`).join('');
615
+ if (previousZone) zoneSel.value = previousZone;
616
+ renderUploadVillageOptions();
617
+ renderLibFilterLocationOptions();
618
+ fillBulkZoneOptions();
619
+
620
+ if (!zoneSel.dataset.bound) {
621
+ zoneSel.dataset.bound = '1';
622
+ zoneSel.addEventListener('change', () => {
623
+ markUploadLocationPicked();
624
+ renderUploadVillageOptions();
625
+ });
626
+ document.getElementById('upload-village')?.addEventListener('change', (e) => {
627
+ markUploadLocationPicked();
628
+ const village = uploadLocations.villages.find((v) => String(v.id) === String(e.target.value));
629
+ if (village) zoneSel.value = String(village.zoneId);
630
+ renderUploadVillageOptions();
631
+ if (village) document.getElementById('upload-village').value = String(village.id);
632
+ });
633
+ document.getElementById('upload-node')?.addEventListener('change', suggestUploadLocationFromFolder);
634
+ }
635
  }
636
 
637
  function setUploadTargetNode(nodeId) {
638
  const sel = document.getElementById('upload-node');
639
+ if (sel && nodeId) {
640
+ sel.value = String(nodeId);
641
+ sel.dispatchEvent(new Event('change'));
642
+ }
643
  document.getElementById('upload-card')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
644
  }
645
 
 
656
  return current;
657
  }
658
 
659
+ function appendSharedUploadFields(form) {
660
+ form.append('image_type', currentUploadImageType());
 
 
 
 
661
  form.append('capture_date', document.getElementById('upload-capture-date')?.value || '');
662
  const manualBounds = document.getElementById('upload-manual-bounds')?.value?.trim();
663
  if (manualBounds) form.append('manual_bounds', manualBounds);
664
+ const zoneId = document.getElementById('upload-zone')?.value || '';
665
+ const villageId = document.getElementById('upload-village')?.value || '';
666
+ if (zoneId) form.append('zone_id', zoneId);
667
+ if (villageId) form.append('village_id', villageId);
668
+ const year = document.getElementById('upload-year')?.value || '';
669
+ if (year) form.append('year', year);
670
+ const source = document.getElementById('upload-source')?.value || '';
671
+ if (source) form.append('source', source);
672
+ }
673
+
674
+ async function uploadOneLibraryFile(nodeId, file, serverName, { onProgress } = {}) {
675
+ const oversized = fileTooLarge(file);
676
+ if (oversized) throw new Error(oversized);
677
+ const form = new FormData();
678
+ form.append('file', file, serverName || file.name);
679
+ form.append('server_filename', serverName || file.name);
680
+ appendSharedUploadFields(form);
681
  return uploadWithProgress(`/api/dda/tree/nodes/${nodeId}/images/upload`, form, onProgress);
682
  }
683
 
684
+ async function uploadBulkLibraryFiles(nodeId, entries, { onProgress } = {}) {
685
+ const form = new FormData();
686
+ entries.forEach((entry) => {
687
+ form.append('files', entry.file, entry.serverName || entry.file.name);
688
+ form.append('filenames', entry.serverName || entry.file.name);
689
+ });
690
+ appendSharedUploadFields(form);
691
+ return uploadWithProgress(`/api/dda/tree/nodes/${nodeId}/images/upload-bulk`, form, onProgress);
692
+ }
693
+
694
+ async function uploadLibraryFiles(files, { nodeId, preserveFolders = true, entries = null } = {}) {
695
  if (uploadLibraryFiles._busy) return 0;
696
+ // Every upload path (drag-drop, file picker, form submit) funnels through
697
+ // here, so this is the one place to enforce it: a zone and village are
698
+ // required, full stop — no more images landing as "Unassigned".
699
+ const zoneId = document.getElementById('upload-zone')?.value || '';
700
+ const villageId = document.getElementById('upload-village')?.value || '';
701
+ if (!zoneId || !villageId) {
702
+ const msg = 'Select a zone and village before uploading — every image needs a location.';
703
+ showDdaError?.(msg);
704
+ throw new Error(msg);
705
+ }
706
+ const type = currentUploadImageType();
707
+ let workEntries = entries;
708
+ if (!workEntries) {
709
+ const { accepted, rejected } = splitFilesForImageType(files, type);
710
+ if (rejected.length) {
711
+ setUploadTypeError(rejectMessage(type, rejected));
712
+ if (!accepted.length) throw new Error(rejectMessage(type, rejected));
713
+ }
714
+ workEntries = accepted.map((file) => ({
715
+ file,
716
+ serverName: (file.name || 'upload').split(/[/\\]/).pop(),
717
+ selected: true,
718
+ }));
719
+ }
720
+ workEntries = workEntries.filter((e) => e.selected !== false);
721
+ if (!workEntries.length) throw new Error(`No ${type} files to upload.`);
722
+
723
+ // Final gate before POST
724
+ const recheck = splitFilesForImageType(workEntries.map((e) => e.file), type);
725
+ if (recheck.rejected.length) {
726
+ const msg = `Cannot upload. Image type ${type} only allows ${formatAllowedExts(allowedExtsForType(type))}.`;
727
+ setUploadTypeError(msg);
728
+ throw new Error(msg);
729
+ }
730
+
731
  const dest = nodeId || document.getElementById('upload-node')?.value;
732
+ const needsFolders = preserveFolders && workEntries.some((e) => relativeUploadPath(e.file).includes('/'));
733
  const canCreateFolders = typeof hasPermission === 'function'
734
  ? hasPermission(window.ddaPermissions, 'library', 'create')
735
  : window.ddaState?.userRole === 'admin';
 
749
  const folderCache = new Map();
750
  let ok = 0;
751
  const errors = [];
752
+ const storedNames = [];
753
  try {
754
+ // Group by destination folder for bulk when no nested folders, or multiple files same dest
755
+ const byTarget = new Map();
756
+ for (const entry of workEntries) {
757
+ const rel = relativeUploadPath(entry.file);
758
  const parts = rel.split('/').filter(Boolean);
759
  const dirParts = parts.length > 1 ? parts.slice(0, -1) : [];
760
  let targetId = dest ? Number(dest) : null;
 
766
  targetId = folderCache.get(key);
767
  }
768
  if (!targetId) throw new Error('Select a destination folder.');
769
+ if (!byTarget.has(targetId)) byTarget.set(targetId, []);
770
+ byTarget.get(targetId).push(entry);
771
+ }
772
+
773
+ let done = 0;
774
+ const total = workEntries.length;
775
+ for (const [targetId, group] of byTarget.entries()) {
776
+ if (group.length === 1) {
777
+ const entry = group[0];
778
+ if (progLabel) progLabel.textContent = `Uploading ${done + 1} of ${total}: ${entry.serverName}`;
779
+ try {
780
+ const res = await uploadOneLibraryFile(targetId, entry.file, entry.serverName, {
781
+ onProgress: (loaded, tot) => {
782
+ const filePct = tot ? loaded / tot : 1;
783
+ if (progFill) progFill.style.width = `${Math.round(((done + filePct) / total) * 100)}%`;
784
+ },
785
+ });
786
+ ok += 1;
787
+ if (res?.image?.filename) storedNames.push(res.image.filename);
788
+ } catch (err) {
789
+ errors.push(`${entry.serverName}: ${err.message || 'failed'}`);
790
+ }
791
+ done += 1;
792
+ } else {
793
+ if (progLabel) progLabel.textContent = `Uploading ${group.length} files…`;
794
+ try {
795
+ const res = await uploadBulkLibraryFiles(targetId, group, {
796
+ onProgress: (loaded, tot) => {
797
+ const filePct = tot ? loaded / tot : 1;
798
+ if (progFill) progFill.style.width = `${Math.round(((done + filePct * group.length) / total) * 100)}%`;
799
+ },
800
+ });
801
+ ok += (res.uploaded || []).length;
802
+ (res.uploaded || []).forEach((img) => {
803
+ if (img.filename) storedNames.push(img.filename);
804
+ });
805
+ (res.failed || []).forEach((f) => errors.push(`${f.filename}: ${f.error}`));
806
+ } catch (err) {
807
+ errors.push(err.message || 'Bulk upload failed');
808
+ }
809
+ done += group.length;
810
  }
811
  }
812
  if (progFill) progFill.style.width = '100%';
 
813
  if (errors.length && ok) {
814
  showDdaError?.(`${ok} uploaded, ${errors.length} failed. ${errors[0]}`);
815
  } else if (errors.length) {
816
  throw new Error(errors[0]);
817
  } else {
818
+ const nameHint = storedNames.length === 1 ? ` Saved as ${storedNames[0]}.` : '';
819
+ showDdaSuccess?.(`Uploaded ${ok} image${ok === 1 ? '' : 's'}.${nameHint}`);
820
+ clearUploadTypeError();
821
+ }
822
+ // Refresh after toast so a tree/grid failure cannot swallow upload success.
823
+ if (ok) {
824
+ try {
825
+ if (typeof loadTree === 'function') await loadTree();
826
+ if (typeof loadLibraryImages === 'function') await loadLibraryImages({ resetPage: true });
827
+ else if (typeof window.ddaState?.refreshImages === 'function') await window.ddaState.refreshImages();
828
+ } catch (_) {
829
+ /* upload already succeeded — leave the success toast visible */
830
+ }
831
  }
832
+ pendingUploadEntries = [];
833
+ pendingUploadFiles = [];
834
+ updateUploadSummary();
835
+ renderUploadFileList();
836
  const fileInput = document.getElementById('upload-file');
837
  const folderInput = document.getElementById('upload-folder');
838
  if (fileInput) fileInput.value = '';
 
912
  const files = await readDroppedEntries(e.dataTransfer);
913
  if (!files.length) return;
914
  setPendingUploadFiles(files);
915
+ if (!pendingUploadEntries.length) return;
916
  const dest = document.getElementById('upload-node')?.value;
917
  try {
918
+ await uploadLibraryFiles(null, {
919
+ nodeId: dest,
920
+ preserveFolders: true,
921
+ entries: pendingUploadEntries.slice(),
922
+ });
923
  } catch (err) {
924
  showDdaError?.(err.message || 'Upload failed.');
925
  }
 
936
  document.getElementById('form-tree-upload')?.addEventListener('submit', async (e) => {
937
  e.preventDefault();
938
  hideDdaError?.();
 
 
 
 
 
 
939
  try {
940
+ await uploadLibraryFiles(null, {
941
  nodeId: document.getElementById('upload-node')?.value,
942
  preserveFolders: true,
943
+ entries: pendingUploadEntries.length ? pendingUploadEntries.slice() : null,
944
  });
945
  } catch (err) {
946
  showDdaError?.(err.message || 'Upload failed.');
947
  }
948
  });
949
 
950
+ document.getElementById('upload-image-type')?.addEventListener('change', () => {
951
+ revalidatePendingForImageType({ typeChanged: true });
952
+ });
953
+ document.getElementById('upload-source')?.addEventListener('change', (e) => {
954
+ e.target.dataset.userPicked = '1';
955
+ });
956
+
957
+ function libFilterParams() {
958
+ const params = new URLSearchParams();
959
+ const year = document.getElementById('lib-filter-year')?.value || '';
960
+ const type = document.getElementById('lib-filter-type')?.value || '';
961
+ const zone = document.getElementById('lib-filter-zone')?.value || '';
962
+ const village = document.getElementById('lib-filter-village')?.value || '';
963
+ const from = document.getElementById('lib-filter-from')?.value || '';
964
+ const to = document.getElementById('lib-filter-to')?.value || '';
965
+ if (year) params.set('year', year);
966
+ if (type) params.set('image_type', type);
967
+ if (zone) params.set('zone_id', zone);
968
+ if (village) params.set('village_id', village);
969
+ if (from) params.set('capture_date_from', from);
970
+ if (to) params.set('capture_date_to', to);
971
+ return params;
972
+ }
973
+
974
+ function exportLibrary(format) {
975
+ const params = libFilterParams();
976
+ const q = document.getElementById('lib-filter')?.value?.trim() || '';
977
+ if (typeof selectedNode !== 'undefined' && selectedNode?.id) {
978
+ params.set('node_id', String(selectedNode.id));
979
+ }
980
+ if (q) params.set('q', q);
981
+ params.set('format', format);
982
+ window.location.href = `/api/dda/tree/images/export?${params.toString()}`;
983
+ }
984
+
985
+ document.getElementById('btn-lib-export-csv')?.addEventListener('click', () => exportLibrary('csv'));
986
+ document.getElementById('btn-lib-export-xlsx')?.addEventListener('click', () => exportLibrary('xlsx'));
987
+
988
+ document.getElementById('btn-lib-filter-clear')?.addEventListener('click', () => {
989
+ ['lib-filter-year', 'lib-filter-type', 'lib-filter-zone', 'lib-filter-village', 'lib-filter-from', 'lib-filter-to'].forEach((id) => {
990
+ const el = document.getElementById(id);
991
+ if (el) el.value = '';
992
+ });
993
+ if (typeof loadLibraryImages === 'function') loadLibraryImages({ resetPage: true });
994
+ });
995
+
996
+ ['lib-filter-year', 'lib-filter-type', 'lib-filter-zone', 'lib-filter-village', 'lib-filter-from', 'lib-filter-to'].forEach((id) => {
997
+ document.getElementById(id)?.addEventListener('change', () => {
998
+ if (id === 'lib-filter-zone') renderLibFilterLocationOptions();
999
+ if (typeof loadLibraryImages === 'function') loadLibraryImages({ resetPage: true });
1000
+ });
1001
+ });
1002
+
1003
+ document.getElementById('btn-lib-select-all')?.addEventListener('click', () => {
1004
+ const checks = document.querySelectorAll('.dda-card-select');
1005
+ const allOn = [...checks].every((c) => c.checked);
1006
+ checks.forEach((c) => { c.checked = !allOn; });
1007
+ updateLibDeleteSelectedBtn();
1008
+ });
1009
+
1010
+ function updateLibDeleteSelectedBtn() {
1011
+ const btn = document.getElementById('btn-lib-delete-selected');
1012
+ if (!btn) return;
1013
+ const n = document.querySelectorAll('.dda-card-select:checked').length;
1014
+ btn.disabled = n === 0;
1015
+ btn.textContent = n ? `Delete selected (${n})` : 'Delete selected';
1016
+ }
1017
+
1018
+ document.getElementById('btn-lib-delete-selected')?.addEventListener('click', async () => {
1019
+ const ids = [...document.querySelectorAll('.dda-card-select:checked')].map((c) => c.dataset.imageId).filter(Boolean);
1020
+ if (!ids.length) return;
1021
+ if (!confirm(`Delete ${ids.length} selected image${ids.length === 1 ? '' : 's'}?`)) return;
1022
+ let ok = 0;
1023
+ const errors = [];
1024
+ for (const id of ids) {
1025
+ try {
1026
+ await ddaApi('DELETE', `/api/dda/tree/images/${id}`);
1027
+ ok += 1;
1028
+ } catch (err) {
1029
+ errors.push(err.message || 'failed');
1030
+ }
1031
+ }
1032
+ if (ok && typeof loadLibraryImages === 'function') await loadLibraryImages();
1033
+ if (errors.length) showDdaError?.(`${ok} deleted, ${errors.length} failed. ${errors[0]}`);
1034
+ else showDdaSuccess?.(`Deleted ${ok} image${ok === 1 ? '' : 's'}.`);
1035
+ });
1036
+
1037
+ populateUploadYearOptions();
1038
+ syncUploadAcceptAttribute();
1039
+ suggestSourceFromType();
1040
  initLibraryDropzone();
1041
+ loadUploadLocations();
1042
+ window.loadUploadLocations = loadUploadLocations;
1043
  window.uploadLibraryFiles = uploadLibraryFiles;
1044
  window.setUploadTargetNode = setUploadTargetNode;
1045
  window.setPendingUploadFiles = setPendingUploadFiles;
1046
  window.readDroppedEntries = readDroppedEntries;
1047
+ window.splitFilesForImageType = splitFilesForImageType;
1048
+ window.libFilterParams = libFilterParams;
1049
+ window.updateLibDeleteSelectedBtn = updateLibDeleteSelectedBtn;
1050
+
1051
+ // --- Bulk upload (Excel manifest + files) ---------------------------------
1052
+
1053
+ let bulkPendingEntries = [];
1054
+
1055
+ function populateBulkYearOptions() {
1056
+ const sel = document.getElementById('bulk-year');
1057
+ if (!sel || sel.options.length > 1) return;
1058
+ const now = new Date().getFullYear();
1059
+ for (let y = now + 1; y >= 1990; y--) {
1060
+ const opt = document.createElement('option');
1061
+ opt.value = String(y);
1062
+ opt.textContent = String(y);
1063
+ sel.appendChild(opt);
1064
+ }
1065
+ }
1066
+
1067
+ function renderBulkVillageOptions() {
1068
+ const villageSel = document.getElementById('bulk-village');
1069
+ if (!villageSel) return;
1070
+ const zoneId = document.getElementById('bulk-zone')?.value || '';
1071
+ const previous = villageSel.value;
1072
+ const list = zoneId
1073
+ ? uploadLocations.villages.filter((v) => String(v.zoneId) === String(zoneId))
1074
+ : uploadLocations.villages;
1075
+ villageSel.innerHTML = '<option value="">— From Excel —</option>' + list.map((v) => {
1076
+ const label = zoneId ? v.name : `${v.name} · ${v.zoneName || ''}`;
1077
+ return `<option value="${v.id}">${escapeHtml(label)}</option>`;
1078
+ }).join('');
1079
+ if (previous && list.some((v) => String(v.id) === String(previous))) villageSel.value = previous;
1080
+ }
1081
+
1082
+ function fillBulkZoneOptions() {
1083
+ const zoneSel = document.getElementById('bulk-zone');
1084
+ if (!zoneSel) return;
1085
+ const previous = zoneSel.value;
1086
+ zoneSel.innerHTML = '<option value="">— From Excel —</option>' + uploadLocations.zones
1087
+ .map((z) => `<option value="${z.id}">${escapeHtml(z.name)}</option>`).join('');
1088
+ if (previous) zoneSel.value = previous;
1089
+ renderBulkVillageOptions();
1090
+ }
1091
+
1092
+ async function loadBulkUploadConfig() {
1093
+ const el = document.getElementById('bulk-main-folder');
1094
+ try {
1095
+ const data = await ddaApi('GET', '/api/dda/tree/bulk-upload/config');
1096
+ if (el) el.textContent = data.mainFolder || '(not set)';
1097
+ } catch (_) {
1098
+ if (el) el.textContent = '(could not load Main folder)';
1099
+ }
1100
+ }
1101
+
1102
+ function renderBulkFileList() {
1103
+ const wrap = document.getElementById('bulk-upload-file-list-wrap');
1104
+ const tbody = document.getElementById('bulk-upload-file-tbody');
1105
+ const nameEl = document.getElementById('bulk-upload-files-name');
1106
+ if (!wrap || !tbody) return;
1107
+ const n = bulkPendingEntries.length;
1108
+ if (nameEl) {
1109
+ nameEl.textContent = n ? `${n} file${n === 1 ? '' : 's'} selected.` : 'No files selected.';
1110
+ }
1111
+ if (!n) {
1112
+ wrap.classList.add('hidden');
1113
+ tbody.innerHTML = '';
1114
+ return;
1115
+ }
1116
+ wrap.classList.remove('hidden');
1117
+ tbody.innerHTML = bulkPendingEntries.map((entry, idx) => `
1118
+ <tr data-idx="${idx}">
1119
+ <td class="dda-upload-orig">${escapeHtml(entry.file.name)}</td>
1120
+ <td><input type="text" class="upload-server-name bulk-server-name" value="${escapeHtml(entry.serverName)}" aria-label="Server filename" /></td>
1121
+ <td>${typeof formatBytes === 'function' ? formatBytes(entry.file.size) : entry.file.size}</td>
1122
+ </tr>`).join('');
1123
+ tbody.querySelectorAll('.bulk-server-name').forEach((input) => {
1124
+ input.addEventListener('change', () => {
1125
+ const idx = parseInt(input.closest('tr').dataset.idx, 10);
1126
+ if (bulkPendingEntries[idx]) {
1127
+ bulkPendingEntries[idx].serverName = (input.value || '').trim() || bulkPendingEntries[idx].file.name;
1128
+ }
1129
+ });
1130
+ });
1131
+ }
1132
+
1133
+ function setBulkPendingFiles(files) {
1134
+ const type = document.getElementById('bulk-image-type')?.value || '';
1135
+ const errEl = document.getElementById('bulk-upload-type-error');
1136
+ let list = Array.from(files || []);
1137
+ if (type && typeof splitFilesForImageType === 'function') {
1138
+ const { accepted, rejected } = splitFilesForImageType(list, type);
1139
+ list = accepted;
1140
+ if (errEl) {
1141
+ if (rejected.length) {
1142
+ errEl.textContent = `Skipped ${rejected.length} file(s) that do not match ${type}: ${rejected.map((f) => f.name).slice(0, 5).join(', ')}${rejected.length > 5 ? '…' : ''}`;
1143
+ errEl.classList.remove('hidden');
1144
+ } else {
1145
+ errEl.classList.add('hidden');
1146
+ errEl.textContent = '';
1147
+ }
1148
+ }
1149
+ } else if (errEl) {
1150
+ errEl.classList.add('hidden');
1151
+ }
1152
+ bulkPendingEntries = list.map((file) => ({
1153
+ file,
1154
+ serverName: file.name,
1155
+ }));
1156
+ renderBulkFileList();
1157
+ }
1158
+
1159
+ function openBulkUploadModal() {
1160
+ document.getElementById('bulk-upload-result')?.classList.add('hidden');
1161
+ document.getElementById('dda-bulk-upload-modal')?.classList.remove('hidden');
1162
+ populateBulkYearOptions();
1163
+ fillBulkZoneOptions();
1164
+ loadBulkUploadConfig();
1165
+ }
1166
+ function closeBulkUploadModal() {
1167
+ document.getElementById('dda-bulk-upload-modal')?.classList.add('hidden');
1168
+ }
1169
+
1170
+ document.getElementById('btn-open-bulk-upload')?.addEventListener('click', openBulkUploadModal);
1171
+ document.getElementById('dda-bulk-upload-close')?.addEventListener('click', closeBulkUploadModal);
1172
+ document.getElementById('dda-bulk-upload-modal')?.addEventListener('click', (e) => {
1173
+ if (e.target.id === 'dda-bulk-upload-modal') closeBulkUploadModal();
1174
+ });
1175
+
1176
+ document.getElementById('bulk-zone')?.addEventListener('change', renderBulkVillageOptions);
1177
+ document.getElementById('bulk-image-type')?.addEventListener('change', () => {
1178
+ const input = document.getElementById('bulk-upload-files');
1179
+ if (input?.files?.length) setBulkPendingFiles(input.files);
1180
+ });
1181
+
1182
+ document.getElementById('bulk-upload-excel')?.addEventListener('change', (e) => {
1183
+ const f = e.target.files?.[0];
1184
+ document.getElementById('bulk-upload-excel-name').textContent = f ? f.name : 'No file selected.';
1185
+ });
1186
+ document.getElementById('bulk-upload-files')?.addEventListener('change', (e) => {
1187
+ setBulkPendingFiles(e.target.files);
1188
+ });
1189
+
1190
+ function renderBulkUploadResult(data) {
1191
+ const wrap = document.getElementById('bulk-upload-result');
1192
+ const summaryEl = document.getElementById('bulk-upload-summary');
1193
+ const errorsEl = document.getElementById('bulk-upload-errors');
1194
+ if (!wrap || !summaryEl || !errorsEl) return;
1195
+ const s = data.summary || {};
1196
+ summaryEl.innerHTML = `
1197
+ <p><strong>${s.created || 0}</strong> created,
1198
+ <strong>${s.duplicates || 0}</strong> already existed (skipped),
1199
+ <strong>${s.errors || 0}</strong> error${(s.errors || 0) === 1 ? '' : 's'},
1200
+ <strong>${s.unmatchedFiles || 0}</strong> file${(s.unmatchedFiles || 0) === 1 ? '' : 's'} not listed in the sheet.</p>`;
1201
+ const rows = [
1202
+ ...(data.errors || []).map((e) => ({ row: e.row, file: e.file, reason: e.reason })),
1203
+ ...(data.unmatchedFiles || []).map((u) => ({ row: '—', file: u.file, reason: 'No matching row in the Excel sheet' })),
1204
+ ];
1205
+ errorsEl.innerHTML = rows.length
1206
+ ? `<table class="dda-logs-table"><thead><tr><th>Row</th><th>File</th><th>Reason</th></tr></thead><tbody>
1207
+ ${rows.map((r) => `<tr><td>${escapeHtml(String(r.row))}</td><td>${escapeHtml(r.file || '')}</td><td>${escapeHtml(r.reason || '')}</td></tr>`).join('')}
1208
+ </tbody></table>`
1209
+ : '';
1210
+ wrap.classList.remove('hidden');
1211
+ }
1212
+
1213
+ document.getElementById('btn-bulk-upload-submit')?.addEventListener('click', async () => {
1214
+ const excelInput = document.getElementById('bulk-upload-excel');
1215
+ const excelFile = excelInput?.files?.[0];
1216
+ const imageFiles = bulkPendingEntries.map((e) => e.file);
1217
+ if (!excelFile) { showDdaError?.('Choose the filled-in Excel sheet first.'); return; }
1218
+ if (!imageFiles.length) { showDdaError?.('Choose the image files listed in the sheet.'); return; }
1219
+
1220
+ const btn = document.getElementById('btn-bulk-upload-submit');
1221
+ const progWrap = document.getElementById('bulk-upload-progress');
1222
+ const progFill = document.getElementById('bulk-upload-progress-fill');
1223
+ const progLabel = document.getElementById('bulk-upload-progress-label');
1224
+ if (btn) btn.disabled = true;
1225
+ progWrap?.classList.remove('hidden');
1226
+ if (progLabel) progLabel.textContent = `Uploading ${imageFiles.length} file${imageFiles.length === 1 ? '' : 's'}…`;
1227
+ if (progFill) progFill.style.width = '0%';
1228
+
1229
+ const filenames = {};
1230
+ bulkPendingEntries.forEach((e) => {
1231
+ if (e.serverName && e.serverName !== e.file.name) {
1232
+ filenames[e.file.name] = e.serverName;
1233
+ } else if (e.serverName) {
1234
+ filenames[e.file.name] = e.serverName;
1235
+ }
1236
+ });
1237
+
1238
+ const form = new FormData();
1239
+ form.append('excel', excelFile);
1240
+ imageFiles.forEach((f) => form.append('files', f));
1241
+ const year = document.getElementById('bulk-year')?.value || '';
1242
+ const source = document.getElementById('bulk-source')?.value || '';
1243
+ const imageType = document.getElementById('bulk-image-type')?.value || '';
1244
+ const zoneId = document.getElementById('bulk-zone')?.value || '';
1245
+ const villageId = document.getElementById('bulk-village')?.value || '';
1246
+ if (year) form.append('year', year);
1247
+ if (source) form.append('source', source);
1248
+ if (imageType) form.append('image_type', imageType);
1249
+ if (zoneId) form.append('zone_id', zoneId);
1250
+ if (villageId) form.append('village_id', villageId);
1251
+ if (Object.keys(filenames).length) form.append('filenames', JSON.stringify(filenames));
1252
+
1253
+ try {
1254
+ const data = await uploadWithProgress('/api/dda/tree/bulk-upload', form, (loaded, total) => {
1255
+ if (progFill && total) progFill.style.width = `${Math.round((loaded / total) * 100)}%`;
1256
+ });
1257
+ if (progFill) progFill.style.width = '100%';
1258
+ renderBulkUploadResult(data);
1259
+ const created = data.summary?.created || 0;
1260
+ if (created) {
1261
+ showDdaSuccess?.(`Bulk upload complete — ${created} image${created === 1 ? '' : 's'} added.`);
1262
+ try {
1263
+ if (typeof loadTree === 'function') await loadTree();
1264
+ if (typeof loadLibraryImages === 'function') await loadLibraryImages({ resetPage: true });
1265
+ else if (typeof window.ddaState?.refreshImages === 'function') await window.ddaState.refreshImages();
1266
+ } catch (_) {
1267
+ /* bulk upload already succeeded — leave the success toast visible */
1268
+ }
1269
+ } else {
1270
+ showDdaError?.('No images were created — see the results below.');
1271
+ }
1272
+ } catch (err) {
1273
+ showDdaError?.(err.message || 'Bulk upload failed.');
1274
+ } finally {
1275
+ if (btn) btn.disabled = false;
1276
+ setTimeout(() => progWrap?.classList.add('hidden'), 1500);
1277
+ }
1278
+ });
static/js/dda/notifications.js CHANGED
@@ -36,12 +36,23 @@ function formatNotifyTime(iso) {
36
  async function fetchNotifyJobs() {
37
  try {
38
  const data = await ddaApi('GET', '/api/dda/jobs?limit=25');
39
- return data.jobs || [];
 
 
 
40
  } catch (_) {
41
- return [];
42
  }
43
  }
44
 
 
 
 
 
 
 
 
 
45
  function updateNotifyBadge(jobs) {
46
  const badge = document.getElementById('dda-notify-badge');
47
  if (!badge) return;
@@ -179,3 +190,4 @@ function initNotifications() {
179
  document.addEventListener('DOMContentLoaded', initNotifications);
180
  window.markDdaJobSeen = markJobSeen;
181
  window.refreshDdaNotifications = refreshNotifications;
 
 
36
  async function fetchNotifyJobs() {
37
  try {
38
  const data = await ddaApi('GET', '/api/dda/jobs?limit=25');
39
+ const jobs = data.jobs || [];
40
+ window.ddaNotifyJobs = jobs;
41
+ window._ddaNotifyJobsLoaded = true;
42
+ return jobs;
43
  } catch (_) {
44
+ return window.ddaNotifyJobs || [];
45
  }
46
  }
47
 
48
+ /** Shared jobs list for Detect resume / home live banner — avoids a second fetch. */
49
+ async function getDdaNotifyJobs({ force } = {}) {
50
+ if (!force && window._ddaNotifyJobsLoaded) {
51
+ return window.ddaNotifyJobs || [];
52
+ }
53
+ return fetchNotifyJobs();
54
+ }
55
+
56
  function updateNotifyBadge(jobs) {
57
  const badge = document.getElementById('dda-notify-badge');
58
  if (!badge) return;
 
190
  document.addEventListener('DOMContentLoaded', initNotifications);
191
  window.markDdaJobSeen = markJobSeen;
192
  window.refreshDdaNotifications = refreshNotifications;
193
+ window.getDdaNotifyJobs = getDdaNotifyJobs;