Update to v0.7.0 #30
@@ -364,6 +364,15 @@ async def nrl_detail_page(
|
|||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
|
# Parse connection_mode from location_metadata JSON
|
||||||
|
import json as _json
|
||||||
|
connection_mode = "connected"
|
||||||
|
try:
|
||||||
|
meta = _json.loads(location.location_metadata or "{}")
|
||||||
|
connection_mode = meta.get("connection_mode", "connected")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
template = "vibration_location_detail.html" if location.location_type == "vibration" else "nrl_detail.html"
|
template = "vibration_location_detail.html" if location.location_type == "vibration" else "nrl_detail.html"
|
||||||
return templates.TemplateResponse(template, {
|
return templates.TemplateResponse(template, {
|
||||||
"request": request,
|
"request": request,
|
||||||
@@ -376,6 +385,7 @@ async def nrl_detail_page(
|
|||||||
"session_count": session_count,
|
"session_count": session_count,
|
||||||
"file_count": file_count,
|
"file_count": file_count,
|
||||||
"active_session": active_session,
|
"active_session": active_session,
|
||||||
|
"connection_mode": connection_mode,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -736,3 +736,67 @@ async def upload_nrl_data(
|
|||||||
"started_at": started_at.isoformat() if started_at else None,
|
"started_at": started_at.isoformat() if started_at else None,
|
||||||
"stopped_at": stopped_at.isoformat() if stopped_at else None,
|
"stopped_at": stopped_at.isoformat() if stopped_at else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# NRL Live Status (connected NRLs only)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.get("/nrl/{location_id}/live-status", response_class=HTMLResponse)
|
||||||
|
async def get_nrl_live_status(
|
||||||
|
project_id: str,
|
||||||
|
location_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Fetch cached status from SLMM for the unit assigned to this NRL and
|
||||||
|
return a compact HTML status card. Used in the NRL overview tab for
|
||||||
|
connected NRLs. Gracefully shows an offline message if SLMM is unreachable.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
# Find the assigned unit
|
||||||
|
assignment = db.query(UnitAssignment).filter(
|
||||||
|
and_(
|
||||||
|
UnitAssignment.location_id == location_id,
|
||||||
|
UnitAssignment.status == "active",
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not assignment:
|
||||||
|
return templates.TemplateResponse("partials/projects/nrl_live_status.html", {
|
||||||
|
"request": request,
|
||||||
|
"status": None,
|
||||||
|
"error": "No unit assigned",
|
||||||
|
})
|
||||||
|
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
||||||
|
if not unit:
|
||||||
|
return templates.TemplateResponse("partials/projects/nrl_live_status.html", {
|
||||||
|
"request": request,
|
||||||
|
"status": None,
|
||||||
|
"error": "Assigned unit not found",
|
||||||
|
})
|
||||||
|
|
||||||
|
slmm_base = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
||||||
|
status_data = None
|
||||||
|
error_msg = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
resp = await client.get(f"{slmm_base}/api/nl43/{unit.id}/status")
|
||||||
|
if resp.status_code == 200:
|
||||||
|
status_data = resp.json()
|
||||||
|
else:
|
||||||
|
error_msg = f"SLMM returned {resp.status_code}"
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = "SLMM unreachable"
|
||||||
|
|
||||||
|
return templates.TemplateResponse("partials/projects/nrl_live_status.html", {
|
||||||
|
"request": request,
|
||||||
|
"unit": unit,
|
||||||
|
"status": status_data,
|
||||||
|
"error": error_msg,
|
||||||
|
})
|
||||||
|
|||||||
@@ -23,12 +23,18 @@ import io
|
|||||||
from backend.utils.timezone import utc_to_local, format_local_datetime
|
from backend.utils.timezone import utc_to_local, format_local_datetime
|
||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
|
from fastapi import UploadFile, File
|
||||||
|
import zipfile
|
||||||
|
import hashlib
|
||||||
|
import pathlib as _pathlib
|
||||||
|
|
||||||
from backend.models import (
|
from backend.models import (
|
||||||
Project,
|
Project,
|
||||||
ProjectType,
|
ProjectType,
|
||||||
MonitoringLocation,
|
MonitoringLocation,
|
||||||
UnitAssignment,
|
UnitAssignment,
|
||||||
MonitoringSession,
|
MonitoringSession,
|
||||||
|
DataFile,
|
||||||
ScheduledAction,
|
ScheduledAction,
|
||||||
RecurringSchedule,
|
RecurringSchedule,
|
||||||
RosterUnit,
|
RosterUnit,
|
||||||
@@ -2697,6 +2703,301 @@ async def generate_combined_excel_report(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Project-level bulk upload (entire date-folder structure)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def _bulk_parse_rnh(content: bytes) -> dict:
|
||||||
|
"""Parse a Rion .rnh metadata file for session start/stop times and device info."""
|
||||||
|
result = {}
|
||||||
|
try:
|
||||||
|
text = content.decode("utf-8", errors="replace")
|
||||||
|
for line in text.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("["):
|
||||||
|
continue
|
||||||
|
if "," in line:
|
||||||
|
key, _, value = line.partition(",")
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip()
|
||||||
|
mapping = {
|
||||||
|
"Serial Number": "serial_number",
|
||||||
|
"Store Name": "store_name",
|
||||||
|
"Index Number": "index_number",
|
||||||
|
"Measurement Start Time": "start_time_str",
|
||||||
|
"Measurement Stop Time": "stop_time_str",
|
||||||
|
"Total Measurement Time": "total_time_str",
|
||||||
|
}
|
||||||
|
if key in mapping:
|
||||||
|
result[mapping[key]] = value
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _bulk_parse_datetime(s: str):
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.strptime(s.strip(), "%Y/%m/%d %H:%M:%S")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _bulk_classify_file(filename: str) -> str:
|
||||||
|
name = filename.lower()
|
||||||
|
if name.endswith(".rnh"):
|
||||||
|
return "log"
|
||||||
|
if name.endswith(".rnd"):
|
||||||
|
return "measurement"
|
||||||
|
if name.endswith(".mp3") or name.endswith(".wav") or name.endswith(".m4a"):
|
||||||
|
return "audio"
|
||||||
|
if name.endswith(".xlsx") or name.endswith(".xls") or name.endswith(".csv"):
|
||||||
|
return "data"
|
||||||
|
return "data"
|
||||||
|
|
||||||
|
|
||||||
|
# Files we skip entirely — already-converted outputs that don't need re-importing
|
||||||
|
_BULK_SKIP_EXTENSIONS = {".xlsx", ".xls"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{project_id}/upload-all")
|
||||||
|
async def upload_all_project_data(
|
||||||
|
project_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Bulk-import an entire structured data folder selected via webkitdirectory.
|
||||||
|
|
||||||
|
Expected folder structure (flexible depth):
|
||||||
|
[date_folder]/[NRL_name]/[Auto_####]/ ← files here
|
||||||
|
-- OR --
|
||||||
|
[NRL_name]/[Auto_####]/ ← files here (no date wrapper)
|
||||||
|
-- OR --
|
||||||
|
[date_folder]/[NRL_name]/ ← files directly in NRL folder
|
||||||
|
|
||||||
|
Each leaf folder group of .rnd/.rnh files becomes one MonitoringSession.
|
||||||
|
NRL folder names are matched case-insensitively to MonitoringLocation.name.
|
||||||
|
.mp3 files are stored as audio. .xlsx/.xls are skipped (already-converted).
|
||||||
|
Unmatched folders are reported but don't cause failure.
|
||||||
|
"""
|
||||||
|
form = await request.form()
|
||||||
|
|
||||||
|
# Collect (relative_path, filename, bytes) for every uploaded file.
|
||||||
|
# The JS sends each file as "files" and its webkitRelativePath as "paths".
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
uploaded_files = form.getlist("files")
|
||||||
|
uploaded_paths = form.getlist("paths")
|
||||||
|
|
||||||
|
if not uploaded_files:
|
||||||
|
raise HTTPException(status_code=400, detail="No files received.")
|
||||||
|
|
||||||
|
if len(uploaded_paths) != len(uploaded_files):
|
||||||
|
# Fallback: use bare filename if paths weren't sent
|
||||||
|
uploaded_paths = [f.filename for f in uploaded_files]
|
||||||
|
|
||||||
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
# Load all sound monitoring locations for this project
|
||||||
|
locations = db.query(MonitoringLocation).filter_by(
|
||||||
|
project_id=project_id,
|
||||||
|
location_type="sound",
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Build a case-insensitive name → location map
|
||||||
|
loc_by_name: dict[str, MonitoringLocation] = {
|
||||||
|
loc.name.strip().lower(): loc for loc in locations
|
||||||
|
}
|
||||||
|
|
||||||
|
def _normalize(s: str) -> str:
|
||||||
|
"""Lowercase, strip spaces/hyphens/underscores for fuzzy comparison."""
|
||||||
|
return s.lower().replace(" ", "").replace("-", "").replace("_", "")
|
||||||
|
|
||||||
|
# Pre-build normalized keys for fuzzy matching
|
||||||
|
loc_by_normalized: dict[str, MonitoringLocation] = {
|
||||||
|
_normalize(loc.name): loc for loc in locations
|
||||||
|
}
|
||||||
|
|
||||||
|
def _find_location_for_path(path: str):
|
||||||
|
"""
|
||||||
|
Walk path components from right and return first matching location.
|
||||||
|
Tries exact match first, then normalized (strips spaces/hyphens/underscores),
|
||||||
|
then checks if the location name *starts with* the normalized folder name.
|
||||||
|
e.g. folder "NRL 1" matches location "NRL1 - Test Location"
|
||||||
|
"""
|
||||||
|
components = path.replace("\\", "/").split("/")
|
||||||
|
for comp in reversed(components):
|
||||||
|
# Exact match
|
||||||
|
key = comp.strip().lower()
|
||||||
|
if key in loc_by_name:
|
||||||
|
return loc_by_name[key]
|
||||||
|
# Normalized match ("NRL 1" == "NRL1")
|
||||||
|
norm = _normalize(comp)
|
||||||
|
if norm in loc_by_normalized:
|
||||||
|
return loc_by_normalized[norm]
|
||||||
|
# Prefix match: location name starts with the folder component
|
||||||
|
# e.g. "NRL1" matches "NRL1 - Test Location"
|
||||||
|
for loc_norm, loc in loc_by_normalized.items():
|
||||||
|
if loc_norm.startswith(norm) or norm.startswith(loc_norm):
|
||||||
|
return loc
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _session_group_key(parts: tuple) -> str:
|
||||||
|
"""
|
||||||
|
Determine the grouping key for a file path.
|
||||||
|
Files inside Auto_####/Auto_Leq/ or Auto_####/Auto_Lp_01/ are collapsed
|
||||||
|
up to their Auto_#### parent so they all land in the same session.
|
||||||
|
"""
|
||||||
|
# Find the deepest Auto_#### component (case-insensitive)
|
||||||
|
auto_idx = None
|
||||||
|
for i, p in enumerate(parts):
|
||||||
|
if p.lower().startswith("auto_") and not p.lower().startswith("auto_leq") and not p.lower().startswith("auto_lp"):
|
||||||
|
auto_idx = i
|
||||||
|
if auto_idx is not None:
|
||||||
|
# Group key = everything up to and including Auto_####
|
||||||
|
return "/".join(parts[:auto_idx + 1])
|
||||||
|
# Fallback: use the immediate parent folder
|
||||||
|
return "/".join(parts[:-1]) if len(parts) > 1 else ""
|
||||||
|
|
||||||
|
# --- Group files by session key ---
|
||||||
|
groups: dict[str, list[tuple[str, bytes]]] = defaultdict(list)
|
||||||
|
|
||||||
|
for rel_path, uf in zip(uploaded_paths, uploaded_files):
|
||||||
|
rel_path = rel_path.replace("\\", "/").strip("/")
|
||||||
|
parts = _pathlib.PurePosixPath(rel_path).parts
|
||||||
|
if not parts:
|
||||||
|
continue
|
||||||
|
fname = parts[-1]
|
||||||
|
# Skip already-converted Excel exports
|
||||||
|
if _pathlib.PurePosixPath(fname).suffix.lower() in _BULK_SKIP_EXTENSIONS:
|
||||||
|
continue
|
||||||
|
group_key = _session_group_key(parts)
|
||||||
|
data = await uf.read()
|
||||||
|
groups[group_key].append((fname, data))
|
||||||
|
|
||||||
|
# Aggregate by (location_id, date_label) so each Auto_#### group is one session
|
||||||
|
# key: (location_id or None, group_path)
|
||||||
|
session_results = []
|
||||||
|
unmatched_paths = []
|
||||||
|
total_files = 0
|
||||||
|
total_sessions = 0
|
||||||
|
|
||||||
|
for group_path, file_list in sorted(groups.items()):
|
||||||
|
matched_loc = _find_location_for_path(group_path)
|
||||||
|
|
||||||
|
if matched_loc is None:
|
||||||
|
unmatched_paths.append(group_path)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parse .rnh if present in this group
|
||||||
|
rnh_meta = {}
|
||||||
|
for fname, fbytes in file_list:
|
||||||
|
if fname.lower().endswith(".rnh"):
|
||||||
|
rnh_meta = _bulk_parse_rnh(fbytes)
|
||||||
|
break
|
||||||
|
|
||||||
|
started_at = _bulk_parse_datetime(rnh_meta.get("start_time_str")) or datetime.utcnow()
|
||||||
|
stopped_at = _bulk_parse_datetime(rnh_meta.get("stop_time_str"))
|
||||||
|
duration_seconds = None
|
||||||
|
if started_at and stopped_at:
|
||||||
|
duration_seconds = int((stopped_at - started_at).total_seconds())
|
||||||
|
|
||||||
|
store_name = rnh_meta.get("store_name", "")
|
||||||
|
serial_number = rnh_meta.get("serial_number", "")
|
||||||
|
index_number = rnh_meta.get("index_number", "")
|
||||||
|
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
monitoring_session = MonitoringSession(
|
||||||
|
id=session_id,
|
||||||
|
project_id=project_id,
|
||||||
|
location_id=matched_loc.id,
|
||||||
|
unit_id=None,
|
||||||
|
session_type="sound",
|
||||||
|
started_at=started_at,
|
||||||
|
stopped_at=stopped_at,
|
||||||
|
duration_seconds=duration_seconds,
|
||||||
|
status="completed",
|
||||||
|
session_metadata=json.dumps({
|
||||||
|
"source": "bulk_upload",
|
||||||
|
"group_path": group_path,
|
||||||
|
"store_name": store_name,
|
||||||
|
"serial_number": serial_number,
|
||||||
|
"index_number": index_number,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
db.add(monitoring_session)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(monitoring_session)
|
||||||
|
|
||||||
|
# Write files
|
||||||
|
output_dir = _pathlib.Path("data/Projects") / project_id / session_id
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
leq_count = 0
|
||||||
|
lp_count = 0
|
||||||
|
group_file_count = 0
|
||||||
|
|
||||||
|
for fname, fbytes in file_list:
|
||||||
|
file_type = _bulk_classify_file(fname)
|
||||||
|
fname_lower = fname.lower()
|
||||||
|
if fname_lower.endswith(".rnd"):
|
||||||
|
if "_leq_" in fname_lower:
|
||||||
|
leq_count += 1
|
||||||
|
elif "_lp" in fname_lower:
|
||||||
|
lp_count += 1
|
||||||
|
|
||||||
|
dest = output_dir / fname
|
||||||
|
dest.write_bytes(fbytes)
|
||||||
|
checksum = hashlib.sha256(fbytes).hexdigest()
|
||||||
|
rel_path = str(dest.relative_to("data"))
|
||||||
|
|
||||||
|
data_file = DataFile(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
session_id=session_id,
|
||||||
|
file_path=rel_path,
|
||||||
|
file_type=file_type,
|
||||||
|
file_size_bytes=len(fbytes),
|
||||||
|
downloaded_at=datetime.utcnow(),
|
||||||
|
checksum=checksum,
|
||||||
|
file_metadata=json.dumps({
|
||||||
|
"source": "bulk_upload",
|
||||||
|
"original_filename": fname,
|
||||||
|
"group_path": group_path,
|
||||||
|
"store_name": store_name,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
db.add(data_file)
|
||||||
|
group_file_count += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
total_files += group_file_count
|
||||||
|
total_sessions += 1
|
||||||
|
|
||||||
|
session_results.append({
|
||||||
|
"location_name": matched_loc.name,
|
||||||
|
"location_id": matched_loc.id,
|
||||||
|
"session_id": session_id,
|
||||||
|
"group_path": group_path,
|
||||||
|
"files": group_file_count,
|
||||||
|
"leq_files": leq_count,
|
||||||
|
"lp_files": lp_count,
|
||||||
|
"store_name": store_name,
|
||||||
|
"started_at": started_at.isoformat() if started_at else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"sessions_created": total_sessions,
|
||||||
|
"files_imported": total_files,
|
||||||
|
"unmatched_folders": unmatched_paths,
|
||||||
|
"sessions": session_results,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/types/list", response_class=HTMLResponse)
|
@router.get("/types/list", response_class=HTMLResponse)
|
||||||
async def get_project_types(request: Request, db: Session = Depends(get_db)):
|
async def get_project_types(request: Request, db: Session = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
||||||
Settings
|
Settings
|
||||||
</button>
|
</button>
|
||||||
{% if assigned_unit %}
|
{% if assigned_unit and connection_mode == 'connected' %}
|
||||||
<button onclick="switchTab('command')"
|
<button onclick="switchTab('command')"
|
||||||
data-tab="command"
|
data-tab="command"
|
||||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
||||||
@@ -214,23 +214,54 @@
|
|||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
{% if connection_mode == 'connected' %}
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">Active Session</p>
|
<p class="text-sm text-gray-600 dark:text-gray-400">Active Session</p>
|
||||||
<p class="text-lg font-semibold text-gray-900 dark:text-white mt-2">
|
<p class="text-lg font-semibold text-gray-900 dark:text-white mt-2">
|
||||||
{% if active_session %}
|
{% if active_session %}
|
||||||
<span class="text-green-600 dark:text-green-400">Recording</span>
|
<span class="text-green-600 dark:text-green-400">Monitoring</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-gray-500">Idle</span>
|
<span class="text-gray-500">Idle</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">Mode</p>
|
||||||
|
<p class="text-lg font-semibold mt-2">
|
||||||
|
<span class="text-amber-600 dark:text-amber-400">Offline / Manual</span>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="w-12 h-12 bg-purple-100 dark:bg-purple-900/30 rounded-lg flex items-center justify-center">
|
<div class="w-12 h-12 bg-purple-100 dark:bg-purple-900/30 rounded-lg flex items-center justify-center">
|
||||||
|
{% if connection_mode == 'connected' %}
|
||||||
<svg class="w-6 h-6 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
|
{% else %}
|
||||||
|
<svg class="w-6 h-6 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
|
||||||
|
</svg>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if connection_mode == 'connected' and assigned_unit %}
|
||||||
|
<!-- Live Status Row (connected NRLs only) -->
|
||||||
|
<div class="mt-6">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Live Status</h3>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{ assigned_unit.id }}</span>
|
||||||
|
</div>
|
||||||
|
<div id="nrl-live-status"
|
||||||
|
hx-get="/api/projects/{{ project_id }}/nrl/{{ location_id }}/live-status"
|
||||||
|
hx-trigger="load, every 30s"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="text-center py-4 text-gray-500 text-sm">Loading status…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Settings Tab -->
|
<!-- Settings Tab -->
|
||||||
@@ -281,8 +312,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Command Center Tab -->
|
<!-- Command Center Tab (connected NRLs only) -->
|
||||||
{% if assigned_unit %}
|
{% if assigned_unit and connection_mode == 'connected' %}
|
||||||
<div id="command-tab" class="tab-panel hidden">
|
<div id="command-tab" class="tab-panel hidden">
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||||
|
|||||||
89
templates/partials/projects/nrl_live_status.html
Normal file
89
templates/partials/projects/nrl_live_status.html
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<!-- Live Status Card content for connected NRLs -->
|
||||||
|
{% if error and not status %}
|
||||||
|
<div class="flex items-center gap-3 text-gray-500 dark:text-gray-400">
|
||||||
|
<svg class="w-5 h-5 text-amber-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm">{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
{% elif status %}
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
|
|
||||||
|
<!-- Measurement State -->
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 mb-1">State</span>
|
||||||
|
{% set state = status.get('measurement_state', 'unknown') if status is mapping else 'unknown' %}
|
||||||
|
{% if state in ('measuring', 'recording') %}
|
||||||
|
<span class="inline-flex items-center gap-1.5 text-sm font-semibold text-green-600 dark:text-green-400">
|
||||||
|
<span class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
|
||||||
|
Measuring
|
||||||
|
</span>
|
||||||
|
{% elif state == 'paused' %}
|
||||||
|
<span class="inline-flex items-center gap-1.5 text-sm font-semibold text-yellow-600 dark:text-yellow-400">
|
||||||
|
<span class="w-2 h-2 bg-yellow-500 rounded-full"></span>
|
||||||
|
Paused
|
||||||
|
</span>
|
||||||
|
{% elif state == 'stopped' %}
|
||||||
|
<span class="inline-flex items-center gap-1.5 text-sm font-semibold text-gray-600 dark:text-gray-400">
|
||||||
|
<span class="w-2 h-2 bg-gray-400 rounded-full"></span>
|
||||||
|
Stopped
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400 capitalize">{{ state }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lp (instantaneous) -->
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 mb-1">Lp (dB)</span>
|
||||||
|
{% set lp = status.get('lp') if status is mapping else None %}
|
||||||
|
<span class="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{% if lp is not none %}{{ "%.1f"|format(lp) }}{% else %}—{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Battery -->
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 mb-1">Battery</span>
|
||||||
|
{% set batt = status.get('battery_level') if status is mapping else None %}
|
||||||
|
{% if batt is not none %}
|
||||||
|
<span class="text-sm font-semibold
|
||||||
|
{% if batt >= 60 %}text-green-600 dark:text-green-400
|
||||||
|
{% elif batt >= 30 %}text-yellow-600 dark:text-yellow-400
|
||||||
|
{% else %}text-red-600 dark:text-red-400{% endif %}">
|
||||||
|
{{ batt }}%
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-sm text-gray-500">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Last Seen -->
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 mb-1">Last Seen</span>
|
||||||
|
{% set last_seen = status.get('last_seen') if status is mapping else None %}
|
||||||
|
{% if last_seen %}
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">{{ last_seen|local_datetime }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-sm text-gray-500">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if unit %}
|
||||||
|
<div class="mt-3 pt-3 border-t border-gray-100 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
Unit: {{ unit.id }}
|
||||||
|
{% if unit.slm_model %} • {{ unit.slm_model }}{% endif %}
|
||||||
|
</span>
|
||||||
|
<a href="/slm/{{ unit.id }}"
|
||||||
|
class="text-xs text-seismo-orange hover:text-seismo-navy transition-colors">
|
||||||
|
Open Unit →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">No status data available.</div>
|
||||||
|
{% endif %}
|
||||||
@@ -230,6 +230,13 @@
|
|||||||
Project Files
|
Project Files
|
||||||
</h2>
|
</h2>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
|
<button onclick="toggleUploadAll()"
|
||||||
|
class="px-3 py-2 text-sm bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors flex items-center gap-1.5">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
|
||||||
|
</svg>
|
||||||
|
Upload All
|
||||||
|
</button>
|
||||||
<button onclick="htmx.trigger('#unified-files', 'refresh')"
|
<button onclick="htmx.trigger('#unified-files', 'refresh')"
|
||||||
class="px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
|
class="px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
|
||||||
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -241,6 +248,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload All Panel -->
|
||||||
|
<div id="upload-all-panel" class="hidden border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-800/50">
|
||||||
|
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Bulk Import — Select Folder</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||||
|
Select your data folder directly — no zipping needed. Expected structure:
|
||||||
|
<code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">[date]/[NRL name]/[Auto_####]/</code>.
|
||||||
|
NRL folders are matched to locations by name. MP3s are stored; Excel exports are skipped.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<input type="file" id="upload-all-input"
|
||||||
|
webkitdirectory directory multiple
|
||||||
|
class="block text-sm text-gray-500 dark:text-gray-400
|
||||||
|
file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0
|
||||||
|
file:text-sm file:font-medium file:bg-seismo-orange file:text-white
|
||||||
|
hover:file:bg-seismo-navy file:cursor-pointer" />
|
||||||
|
<button onclick="submitUploadAll()"
|
||||||
|
class="px-4 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
<button onclick="toggleUploadAll()"
|
||||||
|
class="px-4 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<span id="upload-all-status" class="text-sm hidden"></span>
|
||||||
|
</div>
|
||||||
|
<!-- Result summary -->
|
||||||
|
<div id="upload-all-results" class="hidden mt-3 text-sm space-y-1"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="unified-files"
|
<div id="unified-files"
|
||||||
hx-get="/api/projects/{{ project_id }}/files-unified"
|
hx-get="/api/projects/{{ project_id }}/files-unified"
|
||||||
hx-trigger="load, refresh from:#unified-files"
|
hx-trigger="load, refresh from:#unified-files"
|
||||||
@@ -402,7 +440,7 @@
|
|||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Type</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Type</label>
|
||||||
<select name="location_type" id="location-type"
|
<select name="location_type" id="location-type" onchange="updateConnectionModeVisibility()"
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
<option value="sound">Sound</option>
|
<option value="sound">Sound</option>
|
||||||
<option value="vibration">Vibration</option>
|
<option value="vibration">Vibration</option>
|
||||||
@@ -415,6 +453,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Connection Mode — sound locations only -->
|
||||||
|
<div id="connection-mode-field">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Connection Mode</label>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<label class="flex items-start gap-3 p-3 border-2 border-seismo-orange rounded-lg cursor-pointer bg-orange-50 dark:bg-orange-900/10" id="mode-connected-label">
|
||||||
|
<input type="radio" name="connection_mode" value="connected" checked
|
||||||
|
class="mt-0.5 text-seismo-orange" onchange="updateModeLabels()">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-gray-900 dark:text-white text-sm">Connected</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Remote unit accessible via modem. Supports live control and FTP download.</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-start gap-3 p-3 border-2 border-gray-300 dark:border-gray-600 rounded-lg cursor-pointer" id="mode-offline-label">
|
||||||
|
<input type="radio" name="connection_mode" value="offline"
|
||||||
|
class="mt-0.5 text-seismo-orange" onchange="updateModeLabels()">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-gray-900 dark:text-white text-sm">Offline / Manual Upload</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">No network access. Data collected from SD card and uploaded manually.</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
|
||||||
<input type="text" name="address" id="location-address"
|
<input type="text" name="address" id="location-address"
|
||||||
@@ -794,6 +855,33 @@ function refreshProjectDashboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Location modal functions
|
// Location modal functions
|
||||||
|
function updateConnectionModeVisibility() {
|
||||||
|
const locType = document.getElementById('location-type').value;
|
||||||
|
const field = document.getElementById('connection-mode-field');
|
||||||
|
if (field) field.classList.toggle('hidden', locType !== 'sound');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateModeLabels() {
|
||||||
|
const connected = document.querySelector('input[name="connection_mode"][value="connected"]');
|
||||||
|
const offline = document.querySelector('input[name="connection_mode"][value="offline"]');
|
||||||
|
const connLabel = document.getElementById('mode-connected-label');
|
||||||
|
const offLabel = document.getElementById('mode-offline-label');
|
||||||
|
if (!connected || !connLabel || !offLabel) return;
|
||||||
|
const activeClasses = ['border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/10'];
|
||||||
|
const inactiveClasses = ['border-gray-300', 'dark:border-gray-600'];
|
||||||
|
if (connected.checked) {
|
||||||
|
connLabel.classList.add(...activeClasses);
|
||||||
|
connLabel.classList.remove(...inactiveClasses);
|
||||||
|
offLabel.classList.remove(...activeClasses);
|
||||||
|
offLabel.classList.add(...inactiveClasses);
|
||||||
|
} else {
|
||||||
|
offLabel.classList.add(...activeClasses);
|
||||||
|
offLabel.classList.remove(...inactiveClasses);
|
||||||
|
connLabel.classList.remove(...activeClasses);
|
||||||
|
connLabel.classList.add(...inactiveClasses);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openLocationModal(defaultType) {
|
function openLocationModal(defaultType) {
|
||||||
editingLocationId = null;
|
editingLocationId = null;
|
||||||
document.getElementById('location-modal-title').textContent = 'Add Location';
|
document.getElementById('location-modal-title').textContent = 'Add Location';
|
||||||
@@ -802,6 +890,9 @@ function openLocationModal(defaultType) {
|
|||||||
document.getElementById('location-description').value = '';
|
document.getElementById('location-description').value = '';
|
||||||
document.getElementById('location-address').value = '';
|
document.getElementById('location-address').value = '';
|
||||||
document.getElementById('location-coordinates').value = '';
|
document.getElementById('location-coordinates').value = '';
|
||||||
|
// Reset connection mode to connected
|
||||||
|
const connectedRadio = document.querySelector('input[name="connection_mode"][value="connected"]');
|
||||||
|
if (connectedRadio) { connectedRadio.checked = true; updateModeLabels(); }
|
||||||
const locationTypeSelect = document.getElementById('location-type');
|
const locationTypeSelect = document.getElementById('location-type');
|
||||||
const locationTypeWrapper = locationTypeSelect.closest('div');
|
const locationTypeWrapper = locationTypeSelect.closest('div');
|
||||||
if (projectTypeId === 'sound_monitoring') {
|
if (projectTypeId === 'sound_monitoring') {
|
||||||
@@ -817,6 +908,7 @@ function openLocationModal(defaultType) {
|
|||||||
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
|
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
|
||||||
locationTypeSelect.value = defaultType || 'sound';
|
locationTypeSelect.value = defaultType || 'sound';
|
||||||
}
|
}
|
||||||
|
updateConnectionModeVisibility();
|
||||||
document.getElementById('location-error').classList.add('hidden');
|
document.getElementById('location-error').classList.add('hidden');
|
||||||
document.getElementById('location-modal').classList.remove('hidden');
|
document.getElementById('location-modal').classList.remove('hidden');
|
||||||
}
|
}
|
||||||
@@ -830,6 +922,11 @@ function openEditLocationModal(button) {
|
|||||||
document.getElementById('location-description').value = data.description || '';
|
document.getElementById('location-description').value = data.description || '';
|
||||||
document.getElementById('location-address').value = data.address || '';
|
document.getElementById('location-address').value = data.address || '';
|
||||||
document.getElementById('location-coordinates').value = data.coordinates || '';
|
document.getElementById('location-coordinates').value = data.coordinates || '';
|
||||||
|
// Restore connection mode from metadata
|
||||||
|
let savedMode = 'connected';
|
||||||
|
try { savedMode = JSON.parse(data.location_metadata || '{}').connection_mode || 'connected'; } catch(e) {}
|
||||||
|
const modeRadio = document.querySelector(`input[name="connection_mode"][value="${savedMode}"]`);
|
||||||
|
if (modeRadio) { modeRadio.checked = true; updateModeLabels(); }
|
||||||
const locationTypeSelect = document.getElementById('location-type');
|
const locationTypeSelect = document.getElementById('location-type');
|
||||||
const locationTypeWrapper = locationTypeSelect.closest('div');
|
const locationTypeWrapper = locationTypeSelect.closest('div');
|
||||||
if (projectTypeId === 'sound_monitoring') {
|
if (projectTypeId === 'sound_monitoring') {
|
||||||
@@ -845,6 +942,7 @@ function openEditLocationModal(button) {
|
|||||||
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
|
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
|
||||||
locationTypeSelect.value = data.location_type || 'sound';
|
locationTypeSelect.value = data.location_type || 'sound';
|
||||||
}
|
}
|
||||||
|
updateConnectionModeVisibility();
|
||||||
document.getElementById('location-error').classList.add('hidden');
|
document.getElementById('location-error').classList.add('hidden');
|
||||||
document.getElementById('location-modal').classList.remove('hidden');
|
document.getElementById('location-modal').classList.remove('hidden');
|
||||||
}
|
}
|
||||||
@@ -867,6 +965,8 @@ document.getElementById('location-form').addEventListener('submit', async functi
|
|||||||
locationType = 'vibration';
|
locationType = 'vibration';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const connectionMode = document.querySelector('input[name="connection_mode"]:checked')?.value || 'connected';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (editingLocationId) {
|
if (editingLocationId) {
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -874,7 +974,8 @@ document.getElementById('location-form').addEventListener('submit', async functi
|
|||||||
description: description || null,
|
description: description || null,
|
||||||
address: address || null,
|
address: address || null,
|
||||||
coordinates: coordinates || null,
|
coordinates: coordinates || null,
|
||||||
location_type: locationType
|
location_type: locationType,
|
||||||
|
location_metadata: JSON.stringify({ connection_mode: connectionMode }),
|
||||||
};
|
};
|
||||||
const response = await fetch(`/api/projects/${projectId}/locations/${editingLocationId}`, {
|
const response = await fetch(`/api/projects/${projectId}/locations/${editingLocationId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -892,6 +993,7 @@ document.getElementById('location-form').addEventListener('submit', async functi
|
|||||||
formData.append('address', address);
|
formData.append('address', address);
|
||||||
formData.append('coordinates', coordinates);
|
formData.append('coordinates', coordinates);
|
||||||
formData.append('location_type', locationType);
|
formData.append('location_type', locationType);
|
||||||
|
formData.append('location_metadata', JSON.stringify({ connection_mode: connectionMode }));
|
||||||
const response = await fetch(`/api/projects/${projectId}/locations/create`, {
|
const response = await fetch(`/api/projects/${projectId}/locations/create`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
@@ -1473,6 +1575,88 @@ document.getElementById('schedule-modal')?.addEventListener('click', function(e)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Upload All ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function toggleUploadAll() {
|
||||||
|
const panel = document.getElementById('upload-all-panel');
|
||||||
|
panel.classList.toggle('hidden');
|
||||||
|
if (!panel.classList.contains('hidden')) {
|
||||||
|
document.getElementById('upload-all-status').textContent = '';
|
||||||
|
document.getElementById('upload-all-status').className = 'text-sm hidden';
|
||||||
|
document.getElementById('upload-all-results').classList.add('hidden');
|
||||||
|
document.getElementById('upload-all-results').innerHTML = '';
|
||||||
|
document.getElementById('upload-all-input').value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitUploadAll() {
|
||||||
|
const input = document.getElementById('upload-all-input');
|
||||||
|
const status = document.getElementById('upload-all-status');
|
||||||
|
const resultsEl = document.getElementById('upload-all-results');
|
||||||
|
|
||||||
|
if (!input.files.length) {
|
||||||
|
alert('Please select a folder to upload.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
for (const f of input.files) {
|
||||||
|
// webkitRelativePath gives the path relative to the selected folder root
|
||||||
|
formData.append('files', f);
|
||||||
|
formData.append('paths', f.webkitRelativePath || f.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
status.textContent = `Uploading ${input.files.length} files\u2026`;
|
||||||
|
status.className = 'text-sm text-gray-500';
|
||||||
|
resultsEl.classList.add('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/projects/{{ project_id }}/upload-all`,
|
||||||
|
{ method: 'POST', body: formData }
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const s = data.sessions_created;
|
||||||
|
const f = data.files_imported;
|
||||||
|
status.textContent = `\u2713 Imported ${f} file${f !== 1 ? 's' : ''} across ${s} session${s !== 1 ? 's' : ''}`;
|
||||||
|
status.className = 'text-sm text-green-600 dark:text-green-400';
|
||||||
|
input.value = '';
|
||||||
|
|
||||||
|
// Build results summary
|
||||||
|
let html = '';
|
||||||
|
if (data.sessions && data.sessions.length) {
|
||||||
|
html += '<div class="font-medium text-gray-700 dark:text-gray-300 mb-1">Sessions created:</div>';
|
||||||
|
html += '<ul class="space-y-0.5 ml-2">';
|
||||||
|
for (const sess of data.sessions) {
|
||||||
|
html += `<li class="text-xs text-gray-600 dark:text-gray-400">\u2022 <span class="font-medium">${sess.location_name}</span> — ${sess.files} files`;
|
||||||
|
if (sess.leq_files || sess.lp_files) html += ` (${sess.leq_files} Leq, ${sess.lp_files} Lp)`;
|
||||||
|
if (sess.store_name) html += ` — ${sess.store_name}`;
|
||||||
|
html += '</li>';
|
||||||
|
}
|
||||||
|
html += '</ul>';
|
||||||
|
}
|
||||||
|
if (data.unmatched_folders && data.unmatched_folders.length) {
|
||||||
|
html += `<div class="mt-2 text-xs text-amber-600 dark:text-amber-400">\u26a0 Unmatched folders (no NRL location found): ${data.unmatched_folders.join(', ')}</div>`;
|
||||||
|
}
|
||||||
|
if (html) {
|
||||||
|
resultsEl.innerHTML = html;
|
||||||
|
resultsEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the unified files view
|
||||||
|
htmx.trigger(document.getElementById('unified-files'), 'refresh');
|
||||||
|
} else {
|
||||||
|
status.textContent = `Error: ${data.detail || 'Upload failed'}`;
|
||||||
|
status.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
status.textContent = `Error: ${err.message}`;
|
||||||
|
status.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Load project details on page load and restore active tab from URL hash
|
// Load project details on page load and restore active tab from URL hash
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
loadProjectDetails();
|
loadProjectDetails();
|
||||||
|
|||||||
Reference in New Issue
Block a user