fix: separate days now are in separate .xlsx files, NRLs still 1 per sheet.

add: rebuild script for prod.

fix: Improved data parsing, now filters out unneeded Lp files and .xlsx files.
This commit is contained in:
2026-03-06 23:37:24 +00:00
parent 14856e61ef
commit 67a2faa2d3
6 changed files with 372 additions and 169 deletions

View File

@@ -634,6 +634,30 @@ async def upload_nrl_data(
if not file_entries: if not file_entries:
raise HTTPException(status_code=400, detail="No usable files found in upload.") raise HTTPException(status_code=400, detail="No usable files found in upload.")
# --- Step 1b: Filter to only relevant files ---
# Keep: .rnh (metadata) and measurement .rnd files
# NL-43 generates two .rnd types: _Leq_ (15-min averages, wanted) and _Lp_ (1-sec granular, skip)
# AU2 (NL-23/older Rion) generates a single Au2_####.rnd per session — always keep those
# Drop: _Lp_ .rnd, .xlsx, .mp3, and anything else
def _is_wanted(fname: str) -> bool:
n = fname.lower()
if n.endswith(".rnh"):
return True
if n.endswith(".rnd"):
if "_leq_" in n: # NL-43 Leq file
return True
if n.startswith("au2_"): # AU2 format (NL-23) — always Leq equivalent
return True
if "_lp" not in n and "_leq_" not in n:
# Unknown .rnd format — include it so we don't silently drop data
return True
return False
file_entries = [(fname, fbytes) for fname, fbytes in file_entries if _is_wanted(fname)]
if not file_entries:
raise HTTPException(status_code=400, detail="No usable .rnd or .rnh files found. Expected NL-43 _Leq_ files or AU2 format .rnd files.")
# --- Step 2: Find and parse .rnh metadata --- # --- Step 2: Find and parse .rnh metadata ---
rnh_meta = {} rnh_meta = {}
for fname, fbytes in file_entries: for fname, fbytes in file_entries:

View File

@@ -3379,12 +3379,17 @@ async def generate_combined_from_preview(
data: dict, data: dict,
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Generate combined Excel report from wizard-edited spreadsheet data.""" """Generate combined Excel report from wizard-edited spreadsheet data.
Produces one .xlsx per day (each with one sheet per location) packaged
into a single .zip file for download.
"""
try: try:
import openpyxl import openpyxl
from openpyxl.chart import LineChart, Reference from openpyxl.chart import LineChart, Reference
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
from openpyxl.utils import get_column_letter from openpyxl.utils import get_column_letter
from openpyxl.worksheet.properties import PageSetupProperties
except ImportError: except ImportError:
raise HTTPException(status_code=500, detail="openpyxl is not installed. Run: pip install openpyxl") raise HTTPException(status_code=500, detail="openpyxl is not installed. Run: pip install openpyxl")
@@ -3400,12 +3405,13 @@ async def generate_combined_from_preview(
if not locations: if not locations:
raise HTTPException(status_code=400, detail="No location data provided") raise HTTPException(status_code=400, detail="No location data provided")
# Styles # Shared styles
f_title = Font(name='Arial', bold=True, size=12) f_title = Font(name='Arial', bold=True, size=12)
f_bold = Font(name='Arial', bold=True, size=10) f_bold = Font(name='Arial', bold=True, size=10)
f_data = Font(name='Arial', size=10) f_data = Font(name='Arial', size=10)
thin = Side(style='thin') thin = Side(style='thin')
dbl = Side(style='double') dbl = Side(style='double')
med = Side(style='medium')
hdr_inner = Border(left=thin, right=thin, top=dbl, bottom=thin) hdr_inner = Border(left=thin, right=thin, top=dbl, bottom=thin)
hdr_left = Border(left=dbl, right=thin, top=dbl, bottom=thin) hdr_left = Border(left=dbl, right=thin, top=dbl, bottom=thin)
hdr_right = Border(left=thin, right=dbl, top=dbl, bottom=thin) hdr_right = Border(left=thin, right=dbl, top=dbl, bottom=thin)
@@ -3415,34 +3421,28 @@ async def generate_combined_from_preview(
data_inner = Border(left=thin, right=thin, top=thin, bottom=thin) data_inner = Border(left=thin, right=thin, top=thin, bottom=thin)
data_left = Border(left=dbl, right=thin, top=thin, bottom=thin) data_left = Border(left=dbl, right=thin, top=thin, bottom=thin)
data_right = Border(left=thin, right=dbl, top=thin, bottom=thin) data_right = Border(left=thin, right=dbl, top=thin, bottom=thin)
hdr_fill = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid") hdr_fill = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid")
center_a = Alignment(horizontal='center', vertical='center', wrap_text=True) center_a = Alignment(horizontal='center', vertical='center', wrap_text=True)
left_a = Alignment(horizontal='left', vertical='center') left_a = Alignment(horizontal='left', vertical='center')
right_a = Alignment(horizontal='right', vertical='center') thin_border = Border(left=thin, right=thin, top=thin, bottom=thin)
from openpyxl.worksheet.properties import PageSetupProperties tbl_top_left = Border(left=med, right=thin, top=med, bottom=thin)
tbl_top_mid = Border(left=thin, right=thin, top=med, bottom=thin)
tbl_top_right = Border(left=thin, right=med, top=med, bottom=thin)
tbl_mid_left = Border(left=med, right=thin, top=thin, bottom=thin)
tbl_mid_mid = Border(left=thin, right=thin, top=thin, bottom=thin)
tbl_mid_right = Border(left=thin, right=med, top=thin, bottom=thin)
tbl_bot_left = Border(left=med, right=thin, top=thin, bottom=med)
tbl_bot_mid = Border(left=thin, right=thin, top=thin, bottom=med)
tbl_bot_right = Border(left=thin, right=med, top=thin, bottom=med)
wb = openpyxl.Workbook() col_widths = [9.43, 10.14, 8.14, 12.86, 10.86, 10.86, 25.0, 6.43, 12.43, 12.43, 10.0, 14.71, 8.0, 6.43, 6.43, 6.43]
wb.remove(wb.active)
all_location_summaries = [] def _build_location_sheet(ws, loc_name, day_rows, final_title):
"""Write one location's data onto ws. day_rows is a list of spreadsheet row arrays."""
for loc_info in locations: for col_i, col_w in zip(range(1, 17), col_widths):
loc_name = loc_info.get("location_name", "Unknown")
rows = loc_info.get("spreadsheet_data", [])
if not rows:
continue
safe_sheet_name = "".join(c for c in loc_name if c.isalnum() or c in (' ', '-', '_'))[:31]
ws = wb.create_sheet(title=safe_sheet_name)
# Column widths from Soundstudyexample.xlsx NRL_1 (sheet2)
# A B C D E F G H I J K L M N O P
for col_i, col_w in zip(range(1, 17), [9.43, 10.14, 8.14, 12.86, 10.86, 10.86, 25.0, 6.43, 12.43, 12.43, 10.0, 14.71, 8.0, 6.43, 6.43, 6.43]):
ws.column_dimensions[get_column_letter(col_i)].width = col_w ws.column_dimensions[get_column_letter(col_i)].width = col_w
final_title = f"{report_title} - {project_name}"
ws.merge_cells('A1:G1') ws.merge_cells('A1:G1')
ws['A1'] = final_title ws['A1'] = final_title
ws['A1'].font = f_title; ws['A1'].alignment = center_a ws['A1'].font = f_title; ws['A1'].alignment = center_a
@@ -3463,16 +3463,13 @@ async def generate_combined_from_preview(
cell.border = hdr_left if col == 1 else (hdr_right if col == 7 else hdr_inner) cell.border = hdr_left if col == 1 else (hdr_right if col == 7 else hdr_inner)
ws.row_dimensions[6].height = 39 ws.row_dimensions[6].height = 39
# Data rows starting at row 7
data_start_row = 7 data_start_row = 7
parsed_rows_p = [] parsed_rows = []
lmax_vals = [] lmax_vals, ln1_vals, ln2_vals = [], [], []
ln1_vals = []
ln2_vals = []
for row_idx, row in enumerate(rows): for row_idx, row in enumerate(day_rows):
dr = data_start_row + row_idx dr = data_start_row + row_idx
is_last = (row_idx == len(rows) - 1) is_last = (row_idx == len(day_rows) - 1)
b_left = last_left if is_last else data_left b_left = last_left if is_last else data_left
b_inner = last_inner if is_last else data_inner b_inner = last_inner if is_last else data_inner
b_right = last_right if is_last else data_right b_right = last_right if is_last else data_right
@@ -3508,20 +3505,18 @@ async def generate_combined_from_preview(
if isinstance(ln2, (int, float)): if isinstance(ln2, (int, float)):
ln2_vals.append(ln2) ln2_vals.append(ln2)
# Parse time for evening/nighttime stats
if time_val and isinstance(lmax, (int, float)) and isinstance(ln1, (int, float)) and isinstance(ln2, (int, float)): if time_val and isinstance(lmax, (int, float)) and isinstance(ln1, (int, float)) and isinstance(ln2, (int, float)):
try: try:
try: try:
row_dt = datetime.strptime(str(time_val), '%H:%M') row_dt = datetime.strptime(str(time_val), '%H:%M')
except ValueError: except ValueError:
row_dt = datetime.strptime(str(time_val), '%H:%M:%S') row_dt = datetime.strptime(str(time_val), '%H:%M:%S')
parsed_rows_p.append((row_dt, float(lmax), float(ln1), float(ln2))) parsed_rows.append((row_dt, float(lmax), float(ln1), float(ln2)))
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
data_end_row = data_start_row + len(rows) - 1 data_end_row = data_start_row + len(day_rows) - 1
# Chart anchored at H4
chart = LineChart() chart = LineChart()
chart.title = f"{loc_name} - {final_title}" chart.title = f"{loc_name} - {final_title}"
chart.style = 2 chart.style = 2
@@ -3545,7 +3540,6 @@ async def generate_combined_from_preview(
ws.add_chart(chart, "H4") ws.add_chart(chart, "H4")
# Stats table: note at I28-I29, headers at I31, data rows 32-34, border row 35
note1 = ws.cell(row=28, column=9, value="Note: Averages are calculated by determining the arithmetic average ") note1 = ws.cell(row=28, column=9, value="Note: Averages are calculated by determining the arithmetic average ")
note1.font = f_data; note1.alignment = left_a note1.font = f_data; note1.alignment = left_a
ws.merge_cells(start_row=28, start_column=9, end_row=28, end_column=14) ws.merge_cells(start_row=28, start_column=9, end_row=28, end_column=14)
@@ -3553,21 +3547,7 @@ async def generate_combined_from_preview(
note2.font = f_data; note2.alignment = left_a note2.font = f_data; note2.alignment = left_a
ws.merge_cells(start_row=29, start_column=9, end_row=29, end_column=14) ws.merge_cells(start_row=29, start_column=9, end_row=29, end_column=14)
# Table header row 31
med = Side(style='medium')
tbl_top_left = Border(left=med, right=Side(style='thin'), top=med, bottom=Side(style='thin'))
tbl_top_mid = Border(left=Side(style='thin'), right=Side(style='thin'), top=med, bottom=Side(style='thin'))
tbl_top_right = Border(left=Side(style='thin'), right=med, top=med, bottom=Side(style='thin'))
tbl_mid_left = Border(left=med, right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
tbl_mid_mid = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
tbl_mid_right = Border(left=Side(style='thin'), right=med, top=Side(style='thin'), bottom=Side(style='thin'))
tbl_bot_left = Border(left=med, right=Side(style='thin'), top=Side(style='thin'), bottom=med)
tbl_bot_mid = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=med)
tbl_bot_right = Border(left=Side(style='thin'), right=med, top=Side(style='thin'), bottom=med)
hdr_fill_tbl = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid") hdr_fill_tbl = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid")
# Header row: blank | Evening | Nighttime
c = ws.cell(row=31, column=9, value=""); c.border = tbl_top_left; c.font = f_bold c = ws.cell(row=31, column=9, value=""); c.border = tbl_top_left; c.font = f_bold
c = ws.cell(row=31, column=10, value="Evening (7PM to 10PM)") c = ws.cell(row=31, column=10, value="Evening (7PM to 10PM)")
c.font = f_bold; c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True) c.font = f_bold; c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
@@ -3579,13 +3559,13 @@ async def generate_combined_from_preview(
ws.merge_cells(start_row=31, start_column=12, end_row=31, end_column=13) ws.merge_cells(start_row=31, start_column=12, end_row=31, end_column=13)
ws.row_dimensions[31].height = 15 ws.row_dimensions[31].height = 15
evening_p = [(lmax, ln1, ln2) for dt, lmax, ln1, ln2 in parsed_rows_p if 19 <= dt.hour < 22] evening = [(lmx, l1, l2) for dt, lmx, l1, l2 in parsed_rows if 19 <= dt.hour < 22]
nighttime_p = [(lmax, ln1, ln2) for dt, lmax, ln1, ln2 in parsed_rows_p if dt.hour >= 22 or dt.hour < 7] nighttime = [(lmx, l1, l2) for dt, lmx, l1, l2 in parsed_rows if dt.hour >= 22 or dt.hour < 7]
def _avg_p(vals): return round(sum(vals) / len(vals), 1) if vals else None def _avg(vals): return round(sum(vals) / len(vals), 1) if vals else None
def _max_p(vals): return round(max(vals), 1) if vals else None def _max(vals): return round(max(vals), 1) if vals else None
def write_stat_p(row_num, label, eve_val, night_val, is_last=False): def write_stat(row_num, label, eve_val, night_val, is_last=False):
bl = tbl_bot_left if is_last else tbl_mid_left bl = tbl_bot_left if is_last else tbl_mid_left
bm = tbl_bot_mid if is_last else tbl_mid_mid bm = tbl_bot_mid if is_last else tbl_mid_mid
br = tbl_bot_right if is_last else tbl_mid_right br = tbl_bot_right if is_last else tbl_mid_right
@@ -3603,11 +3583,10 @@ async def generate_combined_from_preview(
ni.alignment = Alignment(horizontal='center', vertical='center') ni.alignment = Alignment(horizontal='center', vertical='center')
ws.merge_cells(start_row=row_num, start_column=12, end_row=row_num, end_column=13) ws.merge_cells(start_row=row_num, start_column=12, end_row=row_num, end_column=13)
write_stat_p(32, "LAmax", _max_p([v[0] for v in evening_p]), _max_p([v[0] for v in nighttime_p])) write_stat(32, "LAmax", _max([v[0] for v in evening]), _max([v[0] for v in nighttime]))
write_stat_p(33, "LA01 Average",_avg_p([v[1] for v in evening_p]), _avg_p([v[1] for v in nighttime_p])) write_stat(33, "LA01 Average", _avg([v[1] for v in evening]), _avg([v[1] for v in nighttime]))
write_stat_p(34, "LA10 Average",_avg_p([v[2] for v in evening_p]), _avg_p([v[2] for v in nighttime_p]), is_last=True) write_stat(34, "LA10 Average", _avg([v[2] for v in evening]), _avg([v[2] for v in nighttime]), is_last=True)
# Page setup: portrait, letter
ws.sheet_properties.pageSetUpPr = PageSetupProperties(fitToPage=False) ws.sheet_properties.pageSetUpPr = PageSetupProperties(fitToPage=False)
ws.page_setup.orientation = 'portrait' ws.page_setup.orientation = 'portrait'
ws.page_setup.paperSize = 1 ws.page_setup.paperSize = 1
@@ -3618,50 +3597,94 @@ async def generate_combined_from_preview(
ws.page_margins.header = 0.5 ws.page_margins.header = 0.5
ws.page_margins.footer = 0.5 ws.page_margins.footer = 0.5
all_location_summaries.append({ return {
'location': loc_name, 'location': loc_name,
'samples': len(rows), 'samples': len(day_rows),
'lmax_avg': round(sum(lmax_vals) / len(lmax_vals), 1) if lmax_vals else None, 'lmax_avg': round(sum(lmax_vals) / len(lmax_vals), 1) if lmax_vals else None,
'ln1_avg': round(sum(ln1_vals) / len(ln1_vals), 1) if ln1_vals else None, 'ln1_avg': round(sum(ln1_vals) / len(ln1_vals), 1) if ln1_vals else None,
'ln2_avg': round(sum(ln2_vals) / len(ln2_vals), 1) if ln2_vals else None, 'ln2_avg': round(sum(ln2_vals) / len(ln2_vals), 1) if ln2_vals else None,
}) }
# Summary sheet def _build_summary_sheet(wb, day_label, project_name, loc_summaries):
thin_border = Border(left=Side(style='thin'), right=Side(style='thin'), summary_ws = wb.create_sheet(title="Summary", index=0)
top=Side(style='thin'), bottom=Side(style='thin')) summary_ws['A1'] = f"{report_title} - {project_name} - {day_label}"
summary_ws = wb.create_sheet(title="Summary", index=0) summary_ws['A1'].font = f_title
summary_ws['A1'] = f"{report_title} - {project_name} - Summary" summary_ws.merge_cells('A1:E1')
summary_ws['A1'].font = f_title summary_headers = ['Location', 'Samples', 'LAmax Avg', 'LA01 Avg', 'LA10 Avg']
summary_ws.merge_cells('A1:E1') for col, header in enumerate(summary_headers, 1):
cell = summary_ws.cell(row=3, column=col, value=header)
cell.font = f_bold; cell.fill = hdr_fill; cell.border = thin_border
for i, width in enumerate([30, 10, 12, 12, 12], 1):
summary_ws.column_dimensions[get_column_letter(i)].width = width
for idx, s in enumerate(loc_summaries, 4):
summary_ws.cell(row=idx, column=1, value=s['location']).border = thin_border
summary_ws.cell(row=idx, column=2, value=s['samples']).border = thin_border
summary_ws.cell(row=idx, column=3, value=s['lmax_avg'] or '-').border = thin_border
summary_ws.cell(row=idx, column=4, value=s['ln1_avg'] or '-').border = thin_border
summary_ws.cell(row=idx, column=5, value=s['ln2_avg'] or '-').border = thin_border
summary_headers = ['Location', 'Samples', 'LAmax Avg', 'LA01 Avg', 'LA10 Avg'] # ----------------------------------------------------------------
for col, header in enumerate(summary_headers, 1): # Split each location's rows by date, collect all unique dates
cell = summary_ws.cell(row=3, column=col, value=header) # ----------------------------------------------------------------
cell.font = f_bold # Structure: dates_map[date_str][loc_name] = [row, ...]
cell.fill = hdr_fill dates_map: dict = {}
cell.border = thin_border for loc_info in locations:
loc_name = loc_info.get("location_name", "Unknown")
rows = loc_info.get("spreadsheet_data", [])
for row in rows:
date_val = str(row[1]).strip() if len(row) > 1 else ''
if not date_val:
date_val = "Unknown Date"
dates_map.setdefault(date_val, {}).setdefault(loc_name, []).append(row)
for i, width in enumerate([30, 10, 12, 12, 12], 1): if not dates_map:
summary_ws.column_dimensions[get_column_letter(i)].width = width raise HTTPException(status_code=400, detail="No data rows found in provided location data")
for idx, loc_summary in enumerate(all_location_summaries, 4): sorted_dates = sorted(dates_map.keys())
summary_ws.cell(row=idx, column=1, value=loc_summary['location']).border = thin_border project_name_clean = "".join(c for c in project_name if c.isalnum() or c in ('_', '-', ' ')).strip().replace(' ', '_')
summary_ws.cell(row=idx, column=2, value=loc_summary['samples']).border = thin_border
summary_ws.cell(row=idx, column=3, value=loc_summary['lmax_avg'] or '-').border = thin_border
summary_ws.cell(row=idx, column=4, value=loc_summary['ln1_avg'] or '-').border = thin_border
summary_ws.cell(row=idx, column=5, value=loc_summary['ln2_avg'] or '-').border = thin_border
output = io.BytesIO() # ----------------------------------------------------------------
wb.save(output) # Build one workbook per day, zip them
output.seek(0) # ----------------------------------------------------------------
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
for date_str in sorted_dates:
loc_data_for_day = dates_map[date_str]
final_title = f"{report_title} - {project_name}"
project_name_clean = "".join(c for c in project_name if c.isalnum() or c in ('_', '-', ' ')).strip() wb = openpyxl.Workbook()
filename = f"{project_name_clean}_combined_report.xlsx".replace(' ', '_') wb.remove(wb.active)
loc_summaries = []
for loc_name in sorted(loc_data_for_day.keys()):
day_rows = loc_data_for_day[loc_name]
# Re-number interval # sequentially for this day
for i, row in enumerate(day_rows):
if len(row) > 0:
row[0] = i + 1
safe_name = "".join(c for c in loc_name if c.isalnum() or c in (' ', '-', '_'))[:31]
ws = wb.create_sheet(title=safe_name)
summary = _build_location_sheet(ws, loc_name, day_rows, final_title)
loc_summaries.append(summary)
_build_summary_sheet(wb, date_str, project_name, loc_summaries)
xlsx_buf = io.BytesIO()
wb.save(xlsx_buf)
xlsx_buf.seek(0)
date_clean = date_str.replace('/', '-').replace(' ', '_')
xlsx_name = f"{project_name_clean}_{date_clean}_report.xlsx"
zf.writestr(xlsx_name, xlsx_buf.read())
zip_buffer.seek(0)
zip_filename = f"{project_name_clean}_reports.zip"
return StreamingResponse( return StreamingResponse(
output, zip_buffer,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{filename}"'} headers={"Content-Disposition": f'attachment; filename="{zip_filename}"'}
) )

12
rebuild-prod.sh Normal file
View File

@@ -0,0 +1,12 @@
#!/bin/bash
# Production rebuild script — rebuilds and restarts terra-view on :8001
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
echo "Building terra-view production..."
docker compose -f docker-compose.yml build terra-view
docker compose -f docker-compose.yml up -d terra-view
echo "Done — terra-view production is running on :8001"

View File

@@ -26,7 +26,7 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" 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-4l-4 4m0 0l-4-4m4 4V4"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg> </svg>
Generate Excel Generate Reports (ZIP)
</button> </button>
<a href="/api/projects/{{ project_id }}/combined-report-wizard" <a href="/api/projects/{{ project_id }}/combined-report-wizard"
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm"> class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm">
@@ -238,7 +238,7 @@ async function downloadCombinedReport() {
const btn = document.getElementById('download-btn'); const btn = document.getElementById('download-btn');
const originalText = btn.innerHTML; const originalText = btn.innerHTML;
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> Generating...'; btn.innerHTML = '<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> Generating ZIP...';
try { try {
const locations = allLocationData.map(function(loc) { const locations = allLocationData.map(function(loc) {
@@ -268,7 +268,7 @@ async function downloadCombinedReport() {
a.href = url; a.href = url;
const contentDisposition = response.headers.get('Content-Disposition'); const contentDisposition = response.headers.get('Content-Disposition');
let filename = 'combined_report.xlsx'; let filename = 'combined_reports.zip';
if (contentDisposition) { if (contentDisposition) {
const match = contentDisposition.match(/filename="(.+)"/); const match = contentDisposition.match(/filename="(.+)"/);
if (match) filename = match[1]; if (match) filename = match[1];

View File

@@ -385,16 +385,27 @@
file:text-sm file:font-medium file:bg-seismo-orange file:text-white file:text-sm file:font-medium file:bg-seismo-orange file:text-white
hover:file:bg-seismo-navy file:cursor-pointer" /> hover:file:bg-seismo-navy file:cursor-pointer" />
<div class="flex items-center gap-3 mt-3"> <div class="flex items-center gap-3 mt-3">
<button onclick="submitUpload()" <button id="upload-btn" onclick="submitUpload()"
class="px-4 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"> class="px-4 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
Import Files Import Files
</button> </button>
<button onclick="toggleUploadPanel()" <button id="upload-cancel-btn" onclick="toggleUploadPanel()"
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"> 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 Cancel
</button> </button>
<span id="upload-status" class="text-sm hidden"></span> <span id="upload-status" class="text-sm hidden"></span>
</div> </div>
<!-- Progress bar (hidden until upload starts) -->
<div id="upload-progress-wrap" class="hidden mt-3">
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mb-1">
<span id="upload-progress-label">Uploading…</span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div id="upload-progress-bar"
class="bg-green-500 h-2 rounded-full transition-all duration-300"
style="width: 0%"></div>
</div>
</div>
</div> </div>
<div id="data-files-list" <div id="data-files-list"
@@ -629,57 +640,105 @@ function toggleUploadPanel() {
const panel = document.getElementById('upload-panel'); const panel = document.getElementById('upload-panel');
const status = document.getElementById('upload-status'); const status = document.getElementById('upload-status');
panel.classList.toggle('hidden'); panel.classList.toggle('hidden');
// Reset status when reopening // Reset state when reopening
if (!panel.classList.contains('hidden')) { if (!panel.classList.contains('hidden')) {
status.textContent = ''; status.textContent = '';
status.className = 'text-sm hidden'; status.className = 'text-sm hidden';
document.getElementById('upload-input').value = ''; document.getElementById('upload-input').value = '';
document.getElementById('upload-progress-wrap').classList.add('hidden');
document.getElementById('upload-progress-bar').style.width = '0%';
} }
} }
async function submitUpload() { function submitUpload() {
const input = document.getElementById('upload-input'); const input = document.getElementById('upload-input');
const status = document.getElementById('upload-status'); const status = document.getElementById('upload-status');
const btn = document.getElementById('upload-btn');
const cancelBtn = document.getElementById('upload-cancel-btn');
const progressWrap = document.getElementById('upload-progress-wrap');
const progressBar = document.getElementById('upload-progress-bar');
const progressLabel = document.getElementById('upload-progress-label');
if (!input.files.length) { if (!input.files.length) {
alert('Please select files to upload.'); alert('Please select files to upload.');
return; return;
} }
const fileCount = input.files.length;
const formData = new FormData(); const formData = new FormData();
for (const file of input.files) { for (const file of input.files) {
formData.append('files', file); formData.append('files', file);
} }
status.textContent = 'Uploading\u2026'; // Disable controls and show progress bar
status.className = 'text-sm text-gray-500'; btn.disabled = true;
btn.textContent = 'Uploading\u2026';
btn.classList.add('opacity-60', 'cursor-not-allowed');
cancelBtn.disabled = true;
cancelBtn.classList.add('opacity-40', 'cursor-not-allowed');
status.className = 'text-sm hidden';
progressWrap.classList.remove('hidden');
progressBar.style.width = '0%';
progressLabel.textContent = `Uploading ${fileCount} file${fileCount !== 1 ? 's' : ''}\u2026`;
try { const xhr = new XMLHttpRequest();
const response = await fetch(
`/api/projects/${projectId}/nrl/${locationId}/upload-data`,
{ method: 'POST', body: formData }
);
const data = await response.json();
if (response.ok) { xhr.upload.addEventListener('progress', (e) => {
const parts = [`Imported ${data.files_imported} file${data.files_imported !== 1 ? 's' : ''}`]; if (e.lengthComputable) {
if (data.leq_files || data.lp_files) { const pct = Math.round((e.loaded / e.total) * 100);
parts.push(`(${data.leq_files} Leq, ${data.lp_files} Lp)`); progressBar.style.width = pct + '%';
progressLabel.textContent = `Uploading ${fileCount} file${fileCount !== 1 ? 's' : ''}\u2026 ${pct}%`;
}
});
xhr.upload.addEventListener('load', () => {
progressBar.style.width = '100%';
progressLabel.textContent = 'Processing files on server\u2026';
});
xhr.addEventListener('load', () => {
progressWrap.classList.add('hidden');
btn.disabled = false;
btn.textContent = 'Import Files';
btn.classList.remove('opacity-60', 'cursor-not-allowed');
cancelBtn.disabled = false;
cancelBtn.classList.remove('opacity-40', 'cursor-not-allowed');
try {
const data = JSON.parse(xhr.responseText);
if (xhr.status >= 200 && xhr.status < 300) {
const parts = [`Imported ${data.files_imported} file${data.files_imported !== 1 ? 's' : ''}`];
if (data.leq_files || data.lp_files) {
parts.push(`(${data.leq_files} Leq, ${data.lp_files} Lp)`);
}
if (data.store_name) parts.push(`\u2014 ${data.store_name}`);
status.textContent = parts.join(' ');
status.className = 'text-sm text-green-600 dark:text-green-400';
input.value = '';
htmx.trigger(document.getElementById('data-files-list'), 'load');
} else {
status.textContent = `Error: ${data.detail || 'Upload failed'}`;
status.className = 'text-sm text-red-600 dark:text-red-400';
} }
if (data.store_name) parts.push(`\u2014 ${data.store_name}`); } catch {
status.textContent = parts.join(' '); status.textContent = 'Error: Unexpected server response';
status.className = 'text-sm text-green-600 dark:text-green-400';
input.value = '';
// Refresh the file list
htmx.trigger(document.getElementById('data-files-list'), 'load');
} else {
status.textContent = `Error: ${data.detail || 'Upload failed'}`;
status.className = 'text-sm text-red-600 dark:text-red-400'; status.className = 'text-sm text-red-600 dark:text-red-400';
} }
} catch (err) { });
status.textContent = `Error: ${err.message}`;
xhr.addEventListener('error', () => {
progressWrap.classList.add('hidden');
btn.disabled = false;
btn.textContent = 'Import Files';
btn.classList.remove('opacity-60', 'cursor-not-allowed');
cancelBtn.disabled = false;
cancelBtn.classList.remove('opacity-40', 'cursor-not-allowed');
status.textContent = 'Error: Network error during upload';
status.className = 'text-sm text-red-600 dark:text-red-400'; status.className = 'text-sm text-red-600 dark:text-red-400';
} });
xhr.open('POST', `/api/projects/${projectId}/nrl/${locationId}/upload-data`);
xhr.send(formData);
} }
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -264,16 +264,28 @@
file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0 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 file:text-sm file:font-medium file:bg-seismo-orange file:text-white
hover:file:bg-seismo-navy file:cursor-pointer" /> hover:file:bg-seismo-navy file:cursor-pointer" />
<button onclick="submitUploadAll()" <span id="upload-all-file-count" class="text-xs text-gray-500 dark:text-gray-400 hidden"></span>
<button id="upload-all-btn" onclick="submitUploadAll()"
class="px-4 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"> class="px-4 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
Import Import
</button> </button>
<button onclick="toggleUploadAll()" <button id="upload-all-cancel-btn" 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"> 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 Cancel
</button> </button>
<span id="upload-all-status" class="text-sm hidden"></span> <span id="upload-all-status" class="text-sm hidden"></span>
</div> </div>
<!-- Progress bar -->
<div id="upload-all-progress-wrap" class="hidden mt-3">
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mb-1">
<span id="upload-all-progress-label">Uploading…</span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div id="upload-all-progress-bar"
class="bg-green-500 h-2 rounded-full transition-all duration-300"
style="width: 0%"></div>
</div>
</div>
<!-- Result summary --> <!-- Result summary -->
<div id="upload-all-results" class="hidden mt-3 text-sm space-y-1"></div> <div id="upload-all-results" class="hidden mt-3 text-sm space-y-1"></div>
</div> </div>
@@ -1642,75 +1654,148 @@ function toggleUploadAll() {
document.getElementById('upload-all-results').classList.add('hidden'); document.getElementById('upload-all-results').classList.add('hidden');
document.getElementById('upload-all-results').innerHTML = ''; document.getElementById('upload-all-results').innerHTML = '';
document.getElementById('upload-all-input').value = ''; document.getElementById('upload-all-input').value = '';
document.getElementById('upload-all-file-count').classList.add('hidden');
document.getElementById('upload-all-progress-wrap').classList.add('hidden');
document.getElementById('upload-all-progress-bar').style.width = '0%';
} }
} }
async function submitUploadAll() { // Show file count and filter info when folder is selected
document.getElementById('upload-all-input').addEventListener('change', function() {
const countEl = document.getElementById('upload-all-file-count');
const total = this.files.length;
if (!total) { countEl.classList.add('hidden'); return; }
const wanted = Array.from(this.files).filter(_isWantedFile).length;
countEl.textContent = `${wanted} of ${total} files will be uploaded (Leq + .rnh only)`;
countEl.classList.remove('hidden');
});
function _isWantedFile(f) {
const n = (f.webkitRelativePath || f.name).toLowerCase();
const base = n.split('/').pop();
if (base.endsWith('.rnh')) return true;
if (base.endsWith('.rnd')) {
if (base.includes('_leq_')) return true; // NL-43 Leq
if (base.startsWith('au2_')) return true; // AU2/NL-23 format
if (!base.includes('_lp')) return true; // unknown format — keep
}
return false;
}
function submitUploadAll() {
const input = document.getElementById('upload-all-input'); const input = document.getElementById('upload-all-input');
const status = document.getElementById('upload-all-status'); const status = document.getElementById('upload-all-status');
const resultsEl = document.getElementById('upload-all-results'); const resultsEl = document.getElementById('upload-all-results');
const btn = document.getElementById('upload-all-btn');
const cancelBtn = document.getElementById('upload-all-cancel-btn');
const progressWrap = document.getElementById('upload-all-progress-wrap');
const progressBar = document.getElementById('upload-all-progress-bar');
const progressLabel = document.getElementById('upload-all-progress-label');
if (!input.files.length) { if (!input.files.length) {
alert('Please select a folder to upload.'); alert('Please select a folder to upload.');
return; return;
} }
// Filter client-side — only send Leq .rnd and .rnh files
const filesToSend = Array.from(input.files).filter(_isWantedFile);
if (!filesToSend.length) {
alert('No Leq .rnd or .rnh files found in selected folder.');
return;
}
const formData = new FormData(); const formData = new FormData();
for (const f of input.files) { for (const f of filesToSend) {
// webkitRelativePath gives the path relative to the selected folder root
formData.append('files', f); formData.append('files', f);
formData.append('paths', f.webkitRelativePath || f.name); formData.append('paths', f.webkitRelativePath || f.name);
} }
status.textContent = `Uploading ${input.files.length} files\u2026`; // Disable controls and show progress
status.className = 'text-sm text-gray-500'; btn.disabled = true;
btn.textContent = 'Uploading\u2026';
btn.classList.add('opacity-60', 'cursor-not-allowed');
cancelBtn.disabled = true;
cancelBtn.classList.add('opacity-40', 'cursor-not-allowed');
status.className = 'text-sm hidden';
resultsEl.classList.add('hidden'); resultsEl.classList.add('hidden');
progressWrap.classList.remove('hidden');
progressBar.style.width = '0%';
progressLabel.textContent = `Uploading ${filesToSend.length} files\u2026`;
try { const xhr = new XMLHttpRequest();
const response = await fetch(
`/api/projects/{{ project_id }}/upload-all`,
{ method: 'POST', body: formData }
);
const data = await response.json();
if (response.ok) { xhr.upload.addEventListener('progress', (e) => {
const s = data.sessions_created; if (e.lengthComputable) {
const f = data.files_imported; const pct = Math.round((e.loaded / e.total) * 100);
status.textContent = `\u2713 Imported ${f} file${f !== 1 ? 's' : ''} across ${s} session${s !== 1 ? 's' : ''}`; progressBar.style.width = pct + '%';
status.className = 'text-sm text-green-600 dark:text-green-400'; progressLabel.textContent = `Uploading ${filesToSend.length} files\u2026 ${pct}%`;
input.value = ''; }
});
// Build results summary xhr.upload.addEventListener('load', () => {
let html = ''; progressBar.style.width = '100%';
if (data.sessions && data.sessions.length) { progressLabel.textContent = 'Processing files on server\u2026';
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) { function _resetControls() {
html += `<li class="text-xs text-gray-600 dark:text-gray-400">\u2022 <span class="font-medium">${sess.location_name}</span> &mdash; ${sess.files} files`; progressWrap.classList.add('hidden');
if (sess.leq_files || sess.lp_files) html += ` (${sess.leq_files} Leq, ${sess.lp_files} Lp)`; btn.disabled = false;
if (sess.store_name) html += ` &mdash; ${sess.store_name}`; btn.textContent = 'Import';
html += '</li>'; btn.classList.remove('opacity-60', 'cursor-not-allowed');
cancelBtn.disabled = false;
cancelBtn.classList.remove('opacity-40', 'cursor-not-allowed');
}
xhr.addEventListener('load', () => {
_resetControls();
try {
const data = JSON.parse(xhr.responseText);
if (xhr.status >= 200 && xhr.status < 300) {
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 = '';
document.getElementById('upload-all-file-count').classList.add('hidden');
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> &mdash; ${sess.files} files`;
if (sess.leq_files || sess.lp_files) html += ` (${sess.leq_files} Leq, ${sess.lp_files} Lp)`;
if (sess.store_name) html += ` &mdash; ${sess.store_name}`;
html += '</li>';
}
html += '</ul>';
} }
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');
}
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';
} }
if (data.unmatched_folders && data.unmatched_folders.length) { } catch {
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>`; status.textContent = 'Error: Unexpected server response';
}
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'; status.className = 'text-sm text-red-600 dark:text-red-400';
} }
} catch (err) { });
status.textContent = `Error: ${err.message}`;
xhr.addEventListener('error', () => {
_resetControls();
status.textContent = 'Error: Network error during upload';
status.className = 'text-sm text-red-600 dark:text-red-400'; status.className = 'text-sm text-red-600 dark:text-red-400';
} });
xhr.open('POST', `/api/projects/{{ project_id }}/upload-all`);
xhr.send(formData);
} }
// 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