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:
@@ -634,6 +634,30 @@ async def upload_nrl_data(
|
||||
if not file_entries:
|
||||
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 ---
|
||||
rnh_meta = {}
|
||||
for fname, fbytes in file_entries:
|
||||
|
||||
@@ -3379,12 +3379,17 @@ async def generate_combined_from_preview(
|
||||
data: dict,
|
||||
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:
|
||||
import openpyxl
|
||||
from openpyxl.chart import LineChart, Reference
|
||||
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
|
||||
from openpyxl.utils import get_column_letter
|
||||
from openpyxl.worksheet.properties import PageSetupProperties
|
||||
except ImportError:
|
||||
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:
|
||||
raise HTTPException(status_code=400, detail="No location data provided")
|
||||
|
||||
# Styles
|
||||
# Shared styles
|
||||
f_title = Font(name='Arial', bold=True, size=12)
|
||||
f_bold = Font(name='Arial', bold=True, size=10)
|
||||
f_data = Font(name='Arial', size=10)
|
||||
thin = Side(style='thin')
|
||||
dbl = Side(style='double')
|
||||
med = Side(style='medium')
|
||||
hdr_inner = Border(left=thin, 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)
|
||||
@@ -3415,34 +3421,28 @@ async def generate_combined_from_preview(
|
||||
data_inner = Border(left=thin, 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)
|
||||
hdr_fill = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid")
|
||||
center_a = Alignment(horizontal='center', vertical='center', wrap_text=True)
|
||||
left_a = Alignment(horizontal='left', vertical='center')
|
||||
right_a = Alignment(horizontal='right', vertical='center')
|
||||
hdr_fill = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid")
|
||||
center_a = Alignment(horizontal='center', vertical='center', wrap_text=True)
|
||||
left_a = Alignment(horizontal='left', 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()
|
||||
wb.remove(wb.active)
|
||||
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]
|
||||
|
||||
all_location_summaries = []
|
||||
|
||||
for loc_info in locations:
|
||||
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]):
|
||||
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 col_i, col_w in zip(range(1, 17), col_widths):
|
||||
ws.column_dimensions[get_column_letter(col_i)].width = col_w
|
||||
|
||||
final_title = f"{report_title} - {project_name}"
|
||||
ws.merge_cells('A1:G1')
|
||||
ws['A1'] = final_title
|
||||
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)
|
||||
ws.row_dimensions[6].height = 39
|
||||
|
||||
# Data rows starting at row 7
|
||||
data_start_row = 7
|
||||
parsed_rows_p = []
|
||||
lmax_vals = []
|
||||
ln1_vals = []
|
||||
ln2_vals = []
|
||||
parsed_rows = []
|
||||
lmax_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
|
||||
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_inner = last_inner if is_last else data_inner
|
||||
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)):
|
||||
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)):
|
||||
try:
|
||||
try:
|
||||
row_dt = datetime.strptime(str(time_val), '%H:%M')
|
||||
except ValueError:
|
||||
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):
|
||||
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.title = f"{loc_name} - {final_title}"
|
||||
chart.style = 2
|
||||
@@ -3545,7 +3540,6 @@ async def generate_combined_from_preview(
|
||||
|
||||
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.font = f_data; note1.alignment = left_a
|
||||
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
|
||||
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")
|
||||
|
||||
# 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=10, value="Evening (7PM to 10PM)")
|
||||
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.row_dimensions[31].height = 15
|
||||
|
||||
evening_p = [(lmax, ln1, ln2) for dt, lmax, ln1, ln2 in parsed_rows_p 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]
|
||||
evening = [(lmx, l1, l2) for dt, lmx, l1, l2 in parsed_rows if 19 <= dt.hour < 22]
|
||||
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 _max_p(vals): return round(max(vals), 1) if vals else None
|
||||
def _avg(vals): return round(sum(vals) / len(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
|
||||
bm = tbl_bot_mid if is_last else tbl_mid_mid
|
||||
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')
|
||||
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_p(33, "LA01 Average",_avg_p([v[1] for v in evening_p]), _avg_p([v[1] for v in nighttime_p]))
|
||||
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(32, "LAmax", _max([v[0] for v in evening]), _max([v[0] for v in nighttime]))
|
||||
write_stat(33, "LA01 Average", _avg([v[1] for v in evening]), _avg([v[1] for v in nighttime]))
|
||||
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.page_setup.orientation = 'portrait'
|
||||
ws.page_setup.paperSize = 1
|
||||
@@ -3618,50 +3597,94 @@ async def generate_combined_from_preview(
|
||||
ws.page_margins.header = 0.5
|
||||
ws.page_margins.footer = 0.5
|
||||
|
||||
all_location_summaries.append({
|
||||
return {
|
||||
'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,
|
||||
'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,
|
||||
})
|
||||
'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,
|
||||
}
|
||||
|
||||
# Summary sheet
|
||||
thin_border = Border(left=Side(style='thin'), right=Side(style='thin'),
|
||||
top=Side(style='thin'), bottom=Side(style='thin'))
|
||||
summary_ws = wb.create_sheet(title="Summary", index=0)
|
||||
summary_ws['A1'] = f"{report_title} - {project_name} - Summary"
|
||||
summary_ws['A1'].font = f_title
|
||||
summary_ws.merge_cells('A1:E1')
|
||||
def _build_summary_sheet(wb, day_label, project_name, loc_summaries):
|
||||
summary_ws = wb.create_sheet(title="Summary", index=0)
|
||||
summary_ws['A1'] = f"{report_title} - {project_name} - {day_label}"
|
||||
summary_ws['A1'].font = f_title
|
||||
summary_ws.merge_cells('A1:E1')
|
||||
summary_headers = ['Location', 'Samples', 'LAmax Avg', 'LA01 Avg', 'LA10 Avg']
|
||||
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):
|
||||
cell = summary_ws.cell(row=3, column=col, value=header)
|
||||
cell.font = f_bold
|
||||
cell.fill = hdr_fill
|
||||
cell.border = thin_border
|
||||
# ----------------------------------------------------------------
|
||||
# Split each location's rows by date, collect all unique dates
|
||||
# ----------------------------------------------------------------
|
||||
# Structure: dates_map[date_str][loc_name] = [row, ...]
|
||||
dates_map: dict = {}
|
||||
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):
|
||||
summary_ws.column_dimensions[get_column_letter(i)].width = width
|
||||
if not dates_map:
|
||||
raise HTTPException(status_code=400, detail="No data rows found in provided location data")
|
||||
|
||||
for idx, loc_summary in enumerate(all_location_summaries, 4):
|
||||
summary_ws.cell(row=idx, column=1, value=loc_summary['location']).border = thin_border
|
||||
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
|
||||
sorted_dates = sorted(dates_map.keys())
|
||||
project_name_clean = "".join(c for c in project_name if c.isalnum() or c in ('_', '-', ' ')).strip().replace(' ', '_')
|
||||
|
||||
output = io.BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
# ----------------------------------------------------------------
|
||||
# Build one workbook per day, zip them
|
||||
# ----------------------------------------------------------------
|
||||
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()
|
||||
filename = f"{project_name_clean}_combined_report.xlsx".replace(' ', '_')
|
||||
wb = openpyxl.Workbook()
|
||||
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(
|
||||
output,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'}
|
||||
zip_buffer,
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{zip_filename}"'}
|
||||
)
|
||||
|
||||
|
||||
|
||||
12
rebuild-prod.sh
Normal file
12
rebuild-prod.sh
Normal 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"
|
||||
@@ -26,7 +26,7 @@
|
||||
<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>
|
||||
</svg>
|
||||
Generate Excel
|
||||
Generate Reports (ZIP)
|
||||
</button>
|
||||
<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">
|
||||
@@ -238,7 +238,7 @@ async function downloadCombinedReport() {
|
||||
const btn = document.getElementById('download-btn');
|
||||
const originalText = btn.innerHTML;
|
||||
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 {
|
||||
const locations = allLocationData.map(function(loc) {
|
||||
@@ -268,7 +268,7 @@ async function downloadCombinedReport() {
|
||||
a.href = url;
|
||||
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
let filename = 'combined_report.xlsx';
|
||||
let filename = 'combined_reports.zip';
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename="(.+)"/);
|
||||
if (match) filename = match[1];
|
||||
|
||||
@@ -385,16 +385,27 @@
|
||||
file:text-sm file:font-medium file:bg-seismo-orange file:text-white
|
||||
hover:file:bg-seismo-navy file:cursor-pointer" />
|
||||
<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">
|
||||
Import Files
|
||||
</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">
|
||||
Cancel
|
||||
</button>
|
||||
<span id="upload-status" class="text-sm hidden"></span>
|
||||
</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 id="data-files-list"
|
||||
@@ -629,57 +640,105 @@ function toggleUploadPanel() {
|
||||
const panel = document.getElementById('upload-panel');
|
||||
const status = document.getElementById('upload-status');
|
||||
panel.classList.toggle('hidden');
|
||||
// Reset status when reopening
|
||||
// Reset state when reopening
|
||||
if (!panel.classList.contains('hidden')) {
|
||||
status.textContent = '';
|
||||
status.className = 'text-sm hidden';
|
||||
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 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) {
|
||||
alert('Please select files to upload.');
|
||||
return;
|
||||
}
|
||||
|
||||
const fileCount = input.files.length;
|
||||
const formData = new FormData();
|
||||
for (const file of input.files) {
|
||||
formData.append('files', file);
|
||||
}
|
||||
|
||||
status.textContent = 'Uploading\u2026';
|
||||
status.className = 'text-sm text-gray-500';
|
||||
// Disable controls and show progress bar
|
||||
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 response = await fetch(
|
||||
`/api/projects/${projectId}/nrl/${locationId}/upload-data`,
|
||||
{ method: 'POST', body: formData }
|
||||
);
|
||||
const data = await response.json();
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
if (response.ok) {
|
||||
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)`);
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const pct = Math.round((e.loaded / e.total) * 100);
|
||||
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}`);
|
||||
status.textContent = parts.join(' ');
|
||||
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'}`;
|
||||
} catch {
|
||||
status.textContent = 'Error: Unexpected server response';
|
||||
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';
|
||||
}
|
||||
});
|
||||
|
||||
xhr.open('POST', `/api/projects/${projectId}/nrl/${locationId}/upload-data`);
|
||||
xhr.send(formData);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -264,16 +264,28 @@
|
||||
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()"
|
||||
<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">
|
||||
Import
|
||||
</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">
|
||||
Cancel
|
||||
</button>
|
||||
<span id="upload-all-status" class="text-sm hidden"></span>
|
||||
</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 -->
|
||||
<div id="upload-all-results" class="hidden mt-3 text-sm space-y-1"></div>
|
||||
</div>
|
||||
@@ -1642,75 +1654,148 @@ function toggleUploadAll() {
|
||||
document.getElementById('upload-all-results').classList.add('hidden');
|
||||
document.getElementById('upload-all-results').innerHTML = '';
|
||||
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 status = document.getElementById('upload-all-status');
|
||||
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) {
|
||||
alert('Please select a folder to upload.');
|
||||
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();
|
||||
for (const f of input.files) {
|
||||
// webkitRelativePath gives the path relative to the selected folder root
|
||||
for (const f of filesToSend) {
|
||||
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';
|
||||
// Disable controls and show progress
|
||||
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');
|
||||
progressWrap.classList.remove('hidden');
|
||||
progressBar.style.width = '0%';
|
||||
progressLabel.textContent = `Uploading ${filesToSend.length} files\u2026`;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/projects/{{ project_id }}/upload-all`,
|
||||
{ method: 'POST', body: formData }
|
||||
);
|
||||
const data = await response.json();
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
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 = '';
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const pct = Math.round((e.loaded / e.total) * 100);
|
||||
progressBar.style.width = pct + '%';
|
||||
progressLabel.textContent = `Uploading ${filesToSend.length} files\u2026 ${pct}%`;
|
||||
}
|
||||
});
|
||||
|
||||
// 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>';
|
||||
xhr.upload.addEventListener('load', () => {
|
||||
progressBar.style.width = '100%';
|
||||
progressLabel.textContent = 'Processing files on server\u2026';
|
||||
});
|
||||
|
||||
function _resetControls() {
|
||||
progressWrap.classList.add('hidden');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Import';
|
||||
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> — ${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>';
|
||||
}
|
||||
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) {
|
||||
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'}`;
|
||||
} catch {
|
||||
status.textContent = 'Error: Unexpected server response';
|
||||
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';
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user