Files
terra-view/templates/partials/projects/project_header.html
T
serversdown 295f9637b3 fix(merge-project): dropdown unclickable + modal too short to show it
Two bugs in the project-merge modal:

1. Dropdown options had the same JSON.stringify quote-collision in
   their inline onclick that broke the location Remove button and the
   metadata-backfill typeahead earlier this week:

     onclick="onMergePickTarget('${id}', ${JSON.stringify(m.name)})"

   For 'I-80 Area 1' that renders as onclick="...(\"I-80 Area 1\")" —
   the inner double quotes terminate the onclick attribute early,
   and the browser never binds the click handler.  Operator clicked
   items in the dropdown and nothing happened.

   Fixed via data-target-id / data-target-name attributes and a
   _mergePickFromButton(btn) trampoline.

2. Modal body had `flex-1 overflow-y-auto` with no min-height, so the
   container shrunk tight around the input.  When the typeahead
   dropdown appeared below the input it got clipped by the body's
   overflow and the operator had to scroll inside the modal to see
   the options.

   Fixed by adding min-height: 480px to the modal container + min-h-
   [320px] on the body so there's always room for the dropdown + the
   preview pane that appears below after a target is picked.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 04:54:33 +00:00

421 lines
24 KiB
HTML

<div class="mb-8">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">{{ project.name }}</h1>
<div class="flex items-center gap-4">
<div class="relative inline-block">
<select onchange="quickUpdateStatus(this.value)"
class="appearance-none cursor-pointer inline-flex items-center pl-3 pr-7 py-1 rounded-full text-sm font-medium border-0 focus:ring-2 focus:ring-offset-1 focus:ring-blue-500
{% if project.status == 'upcoming' %}bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200
{% elif project.status == 'active' %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
{% elif project.status == 'on_hold' %}bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200
{% elif project.status == 'completed' %}bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200{% endif %}">
<option value="upcoming" {% if project.status == 'upcoming' %}selected{% endif %}>Upcoming</option>
<option value="active" {% if project.status == 'active' %}selected{% endif %}>Active</option>
<option value="on_hold" {% if project.status == 'on_hold' %}selected{% endif %}>On Hold</option>
<option value="completed" {% if project.status == 'completed' %}selected{% endif %}>Completed</option>
<option value="archived" {% if project.status == 'archived' %}selected{% endif %}>Archived</option>
</select>
<span class="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-current opacity-60">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</span>
</div>
<!-- Module badges -->
<div id="module-badges" class="flex items-center gap-1.5 flex-wrap">
{% for m in modules %}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium
{% if m == 'sound_monitoring' %}bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300
{% elif m == 'vibration_monitoring' %}bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300
{% else %}bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300{% endif %}">
{% if m == 'sound_monitoring' %}
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072M12 6v12M9 8.464a5 5 0 000 7.072"/></svg>
Sound Monitoring
{% elif m == 'vibration_monitoring' %}
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
Vibration Monitoring
{% else %}{{ m }}{% endif %}
<button onclick="removeModule('{{ m }}')" class="ml-0.5 hover:text-red-500 transition-colors" title="Remove module">
<svg class="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</span>
{% endfor %}
<button onclick="openAddModuleModal()" class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border border-dashed border-gray-400 dark:border-gray-600 text-gray-500 dark:text-gray-400 hover:border-orange-400 hover:text-orange-500 transition-colors">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Add Module
</button>
</div>
{% if project.data_collection_mode == 'remote' %}
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
</svg>
Remote
</span>
{% else %}
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"></path>
</svg>
Manual
</span>
{% endif %}
</div>
</div>
<!-- Project Actions -->
<div class="flex items-center gap-3">
{% if 'sound_monitoring' in modules %}
<a href="/api/projects/{{ project.id }}/combined-report-wizard"
class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2 text-sm">
<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="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
Generate Combined Report
</a>
{% endif %}
<button onclick="openMergeModal()"
title="Merge this project into another (consolidates duplicates)"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2 text-sm">
<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="M17 8l4 4m0 0l-4 4m4-4H3"></path>
</svg>
Merge into…
</button>
</div>
</div>
</div>
<!-- Merge Modal —
min-h on the body ensures the typeahead dropdown has room to render
below the input without forcing the operator to scroll inside the
modal. overflow-visible on the body lets the dropdown extend
beyond the body's natural height when needed. -->
<div id="merge-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl mx-4 max-h-[90vh] flex flex-col"
style="min-height: 480px;">
<!-- Header -->
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Merge "{{ project.name }}" into another project</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
Source project gets soft-deleted. All its locations, assignments, sessions, and files move to the target.
</p>
</div>
<button onclick="closeMergeModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 shrink-0 ml-3">
<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="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<!-- Body -->
<div class="px-6 py-4 overflow-y-auto flex-1 min-h-[320px]">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Target project
</label>
<div class="relative">
<input type="text"
id="merge-target-input"
placeholder="Type to search for the project to merge INTO…"
autocomplete="off"
oninput="onMergeTargetInput()"
onfocus="onMergeTargetInput()"
onblur="setTimeout(() => document.getElementById('merge-target-dropdown').classList.add('hidden'), 150)"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm">
<input type="hidden" id="merge-target-id" value="">
<div id="merge-target-dropdown"
class="hidden absolute z-20 left-0 right-0 mt-1 bg-white dark:bg-slate-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-64 overflow-y-auto"></div>
</div>
<!-- Preview pane -->
<div id="merge-preview" class="mt-4 hidden">
<h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">What will move</h4>
<div id="merge-preview-body" class="space-y-3"></div>
</div>
<div id="merge-error" class="hidden mt-3 text-sm text-red-600"></div>
</div>
<!-- Footer -->
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
<button onclick="closeMergeModal()"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-sm">
Cancel
</button>
<button id="merge-confirm-btn"
onclick="confirmMerge()"
disabled
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium disabled:opacity-40 disabled:cursor-not-allowed">
Merge (permanent)
</button>
</div>
</div>
</div>
<script>
const _SOURCE_PROJECT_ID = "{{ project.id }}";
const _SOURCE_PROJECT_NAME = {{ project.name|tojson }};
function _mergeEsc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function openMergeModal() {
document.getElementById('merge-target-input').value = '';
document.getElementById('merge-target-id').value = '';
document.getElementById('merge-preview').classList.add('hidden');
document.getElementById('merge-error').classList.add('hidden');
document.getElementById('merge-confirm-btn').disabled = true;
document.getElementById('merge-modal').classList.remove('hidden');
setTimeout(() => document.getElementById('merge-target-input').focus(), 50);
}
function closeMergeModal() {
document.getElementById('merge-modal').classList.add('hidden');
}
let _mergeTargetDebounce = null;
async function onMergeTargetInput() {
if (_mergeTargetDebounce) clearTimeout(_mergeTargetDebounce);
_mergeTargetDebounce = setTimeout(_mergeFetchTargets, 150);
}
async function _mergeFetchTargets() {
const q = document.getElementById('merge-target-input').value.trim();
const dropdown = document.getElementById('merge-target-dropdown');
let data;
try {
// Reuse the metadata-backfill projects_search endpoint — works for
// any caller, returns existing-only (no create-new option needed here).
const r = await fetch(`/api/admin/metadata_backfill/projects_search?q=${encodeURIComponent(q)}&limit=12`);
if (!r.ok) throw new Error('HTTP ' + r.status);
data = await r.json();
} catch (e) {
dropdown.innerHTML = `<div class="px-3 py-2 text-sm text-red-500">Search failed: ${_mergeEsc(e.message)}</div>`;
dropdown.classList.remove('hidden');
return;
}
// Filter out self.
const candidates = (data.matches || []).filter(m => m.id !== _SOURCE_PROJECT_ID);
if (candidates.length === 0) {
dropdown.innerHTML = `<div class="px-3 py-2 text-sm text-gray-500 dark:text-gray-400 italic">No matches.</div>`;
dropdown.classList.remove('hidden');
return;
}
// Stash target id + name in data-* attributes (NOT inline JS args)
// to avoid the quote-collision that breaks click binding when the
// project name contains characters JSON.stringify quotes. Same
// pattern as the backfill typeahead dropdown.
dropdown.innerHTML = candidates.map(m => {
const scoreBadge = m.score >= 0.99
? '<span class="text-xs text-green-600 dark:text-green-400 ml-2">exact</span>'
: `<span class="text-xs text-gray-500 dark:text-gray-400 ml-2">${(m.score*100).toFixed(0)}%</span>`;
const meta = [];
if (m.project_number) meta.push(_mergeEsc(m.project_number));
if (m.client_name) meta.push(_mergeEsc(m.client_name));
if (m.location_count > 0) meta.push(`${m.location_count} location${m.location_count === 1 ? '' : 's'}`);
const metaLine = meta.length ? `<div class="text-xs text-gray-500 dark:text-gray-400">${meta.join(' · ')}</div>` : '';
return `<button type="button"
data-target-id="${_mergeEsc(m.id)}"
data-target-name="${_mergeEsc(m.name)}"
onmousedown="event.preventDefault()"
onclick="_mergePickFromButton(this)"
class="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-slate-700 border-b border-gray-100 dark:border-gray-700 last:border-b-0">
<div class="text-sm font-medium text-gray-900 dark:text-white">${_mergeEsc(m.name)}${scoreBadge}</div>
${metaLine}
</button>`;
}).join('');
dropdown.classList.remove('hidden');
}
// Trampoline — reads the button's data attributes and forwards. Keeps
// the inline onclick free of any string interpolation that could break
// HTML quoting (see notes on the same pattern in metadata_backfill.html).
function _mergePickFromButton(btn) {
onMergePickTarget(btn.dataset.targetId, btn.dataset.targetName);
}
async function onMergePickTarget(targetId, targetName) {
document.getElementById('merge-target-input').value = targetName;
document.getElementById('merge-target-id').value = targetId;
document.getElementById('merge-target-dropdown').classList.add('hidden');
await _loadMergePreview(targetId);
}
async function _loadMergePreview(targetId) {
const previewEl = document.getElementById('merge-preview');
const bodyEl = document.getElementById('merge-preview-body');
const errorEl = document.getElementById('merge-error');
const confirmBtn = document.getElementById('merge-confirm-btn');
previewEl.classList.add('hidden');
errorEl.classList.add('hidden');
confirmBtn.disabled = true;
bodyEl.innerHTML = '<div class="text-center py-3 text-sm text-gray-500"><div class="animate-spin rounded-full h-5 w-5 border-b-2 border-seismo-orange mx-auto mb-2"></div>Loading preview…</div>';
previewEl.classList.remove('hidden');
let d;
try {
const r = await fetch(`/api/projects/${_SOURCE_PROJECT_ID}/merge_preview?target_id=${encodeURIComponent(targetId)}`);
if (!r.ok) {
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
throw new Error(err.detail || ('HTTP ' + r.status));
}
d = await r.json();
} catch (e) {
errorEl.textContent = e.message;
errorEl.classList.remove('hidden');
previewEl.classList.add('hidden');
return;
}
let html = `<div class="text-sm text-gray-700 dark:text-gray-300">
Merging <strong>"${_mergeEsc(d.source_project_name)}"</strong> into <strong>"${_mergeEsc(d.target_project_name)}"</strong>:
</div>
<div class="grid grid-cols-3 gap-2 mt-2">
<div class="bg-gray-50 dark:bg-slate-900/50 rounded p-2"><div class="text-xs text-gray-500 dark:text-gray-400">Assignments</div><div class="text-lg font-bold">${d.total_assignments_moving}</div></div>
<div class="bg-gray-50 dark:bg-slate-900/50 rounded p-2"><div class="text-xs text-gray-500 dark:text-gray-400">Sessions</div><div class="text-lg font-bold">${d.total_sessions_moving}</div></div>
<div class="bg-gray-50 dark:bg-slate-900/50 rounded p-2"><div class="text-xs text-gray-500 dark:text-gray-400">Data files</div><div class="text-lg font-bold">${d.total_data_files_moving}</div></div>
</div>`;
if (d.location_plans.length > 0) {
html += `<div class="mt-3">
<h5 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-1">Locations</h5>
<div class="space-y-1 text-sm">`;
for (const p of d.location_plans) {
if (p.action === 'consolidate') {
html += `<div class="text-gray-700 dark:text-gray-300">
🔀 <strong>${_mergeEsc(p.source_name)}</strong> → consolidates into existing target <strong>"${_mergeEsc(p.target_name)}"</strong>
<span class="text-xs text-gray-500 dark:text-gray-400 ml-1">(${p.assignments_moving} assignments + ${p.sessions_moving} sessions move)</span>
</div>`;
} else {
html += `<div class="text-gray-700 dark:text-gray-300">
→ <strong>${_mergeEsc(p.source_name)}</strong> moves to target as-is
<span class="text-xs text-gray-500 dark:text-gray-400 ml-1">(${p.assignments_moving} assignments + ${p.sessions_moving} sessions)</span>
</div>`;
}
}
html += '</div></div>';
}
if (d.modules_to_add.length > 0) {
html += `<div class="mt-3 text-sm text-gray-700 dark:text-gray-300">
Modules to add to target: ${d.modules_to_add.map(m => `<code class="px-1 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-xs">${_mergeEsc(m)}</code>`).join(' ')}
</div>`;
}
if (d.warnings.length > 0) {
html += '<div class="mt-3 space-y-2">';
for (const w of d.warnings) {
html += `<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded p-2 text-xs text-amber-800 dark:text-amber-300">⚠ ${_mergeEsc(w)}</div>`;
}
html += '</div>';
}
html += `<div class="mt-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded p-2 text-xs text-red-800 dark:text-red-300">
<strong>⚠ This action is destructive.</strong> Source project will be soft-deleted (status='deleted').
Audit rows will be written to unit_history for every moved assignment.
</div>`;
bodyEl.innerHTML = html;
confirmBtn.disabled = false;
}
async function confirmMerge() {
const targetId = document.getElementById('merge-target-id').value;
if (!targetId) return;
const confirmBtn = document.getElementById('merge-confirm-btn');
confirmBtn.disabled = true;
confirmBtn.textContent = 'Merging…';
try {
const r = await fetch(`/api/projects/${_SOURCE_PROJECT_ID}/merge_into?target_id=${encodeURIComponent(targetId)}`, {
method: 'POST',
});
if (!r.ok) {
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
throw new Error(err.detail || ('HTTP ' + r.status));
}
const d = await r.json();
// Redirect to the target project — source no longer exists in active state.
window.location.href = `/projects/${d.target_project_id}`;
} catch (e) {
const errorEl = document.getElementById('merge-error');
errorEl.textContent = 'Merge failed: ' + e.message;
errorEl.classList.remove('hidden');
confirmBtn.disabled = false;
confirmBtn.textContent = 'Merge (permanent)';
}
}
</script>
<!-- Add Module Modal -->
<div id="add-module-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-sm mx-4 p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Add Module</h3>
<button onclick="closeAddModuleModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
<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="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<div id="add-module-options" class="space-y-2">
<!-- Populated by JS -->
</div>
<p id="add-module-none" class="hidden text-sm text-gray-500 dark:text-gray-400 text-center py-4">All available modules are already enabled.</p>
</div>
</div>
<script>
const _MODULE_META = {
sound_monitoring: { name: "Sound Monitoring", color: "orange", icon: "M15.536 8.464a5 5 0 010 7.072M12 6v12M9 8.464a5 5 0 000 7.072" },
vibration_monitoring: { name: "Vibration Monitoring", color: "blue", icon: "M22 12h-4l-3 9L9 3l-3 9H2" },
};
async function openAddModuleModal() {
const resp = await fetch(`/api/projects/${projectId}/modules`);
const data = await resp.json();
const container = document.getElementById('add-module-options');
const none = document.getElementById('add-module-none');
container.innerHTML = '';
if (!data.available || data.available.length === 0) {
none.classList.remove('hidden');
} else {
none.classList.add('hidden');
data.available.forEach(m => {
const meta = _MODULE_META[m.module_type] || { name: m.module_type, color: 'gray' };
const btn = document.createElement('button');
btn.className = `w-full text-left px-4 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-${meta.color}-400 hover:bg-${meta.color}-50 dark:hover:bg-${meta.color}-900/20 transition-colors flex items-center gap-3`;
btn.innerHTML = `<span class="flex-1 font-medium text-gray-900 dark:text-white">${meta.name}</span>`;
btn.onclick = () => addModule(m.module_type);
container.appendChild(btn);
});
}
document.getElementById('add-module-modal').classList.remove('hidden');
}
function closeAddModuleModal() {
document.getElementById('add-module-modal').classList.add('hidden');
}
async function addModule(moduleType) {
await fetch(`/api/projects/${projectId}/modules`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ module_type: moduleType }),
});
closeAddModuleModal();
window.location.reload();
}
async function removeModule(moduleType) {
const meta = _MODULE_META[moduleType] || { name: moduleType };
if (!confirm(`Remove the ${meta.name} module? The data will not be deleted, but the related tabs will be hidden.`)) return;
await fetch(`/api/projects/${projectId}/modules/${moduleType}`, { method: 'DELETE' });
window.location.reload();
}
</script>