b1c2a1d778
Operator-facing tool for cleaning up duplicate projects. Common after
the metadata-backfill parser auto-creates near-duplicates from operator
name variations ("SR81" vs "SR 81", "Swank-Karns Crossing" vs
"Swank-Karns Crossings", "Trumbull-Bryman Mont.Dam" vs
"Trumbull-Brayman-Mont Dam", etc.).
Workflow: visit the duplicate project's detail page, click "Merge into…"
in the header, search for the canonical target project from a typeahead,
review the preview (what assignments / locations / sessions will move,
any conflicts), confirm. Source is soft-deleted; everything else
re-points to the target. Smart consolidation: same-named locations in
both projects merge into one (source's assignments move to target's
existing location with the same name; source's empty location is then
deleted). Different-named locations move as-is.
Backend:
- backend/services/project_merge.py (new): preview() and execute()
functions. Transaction-safe. Per-assignment UnitHistory audit row
with change_type='assignment_merged' so the deployment timeline shows
the merge. Source modules disabled; missing modules added to target.
Handles edge cases: same project_id rejected, deleted projects rejected,
orphan project-direct assignments (no location) re-pointed defensively.
- backend/routers/projects.py: new endpoints
GET /api/projects/{source_id}/merge_preview?target_id=...
POST /api/projects/{source_id}/merge_into?target_id=...
Frontend (templates/partials/projects/project_header.html):
- "Merge into…" button in Project Actions area.
- Modal with typeahead (reuses /api/admin/metadata_backfill/projects_search)
scoped to existing projects only (no create-new option). Filters out
the source project from candidates so operator can't accidentally pick
it as target.
- Preview pane shows totals + per-location plan (consolidate vs move) +
warnings (mismatched client names, location consolidation note).
- Red "Merge (permanent)" confirm button only enables after a target is
picked and preview loads.
- On success, browser redirects to target project page.
Smoke verified: "Swank-Karns Crossing" (1 assignment) merged into
"Swank-Karns Crossings"; target now has 2 locations + 2 assignments,
source has 0 dangling rows, 1 project_merge audit entry written.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
403 lines
23 KiB
HTML
403 lines
23 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 -->
|
|
<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">
|
|
<!-- 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">
|
|
<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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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"
|
|
onmousedown="event.preventDefault()"
|
|
onclick="onMergePickTarget('${_mergeEsc(m.id)}', ${JSON.stringify(m.name)})"
|
|
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');
|
|
}
|
|
|
|
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>
|