feat: Refactor project creation and management to support modular project types

- Updated project creation modal to allow selection of optional modules (Sound and Vibration Monitoring).
- Modified project dashboard and header to display active modules and provide options to add/remove them.
- Enhanced project detail view to dynamically adjust UI based on enabled modules.
- Implemented a new migration script to create a `project_modules` table and seed it based on existing project types.
- Adjusted form submissions to handle module selections and ensure proper API interactions for module management.
This commit is contained in:
2026-03-30 21:44:15 +00:00
parent 184f0ddd13
commit 73a6ff4d20
9 changed files with 493 additions and 175 deletions

View File

@@ -770,7 +770,7 @@
<script>
const projectId = "{{ project_id }}";
let editingLocationId = null;
let projectTypeId = null;
let projectModules = []; // list of enabled module_type strings, e.g. ['sound_monitoring']
async function quickUpdateStatus(newStatus) {
try {
@@ -828,7 +828,7 @@ async function loadProjectDetails() {
throw new Error('Failed to load project details');
}
const data = await response.json();
projectTypeId = data.project_type_id || null;
projectModules = data.modules || [];
// Update breadcrumb
document.getElementById('project-name-breadcrumb').textContent = data.name || 'Project';
@@ -849,10 +849,10 @@ async function loadProjectDetails() {
if (modeRadio) modeRadio.checked = true;
settingsUpdateModeStyles();
// Update tab labels and visibility based on project type
const isSoundProject = projectTypeId === 'sound_monitoring';
const isVibrationProject = projectTypeId === 'vibration_monitoring';
if (isSoundProject) {
// Update tab labels and visibility based on active modules
const hasSoundModule = projectModules.includes('sound_monitoring');
const hasVibrationModule = projectModules.includes('vibration_monitoring');
if (hasSoundModule && !hasVibrationModule) {
document.getElementById('locations-tab-label').textContent = 'NRLs';
document.getElementById('locations-header').textContent = 'Noise Recording Locations';
document.getElementById('add-location-label').textContent = 'Add NRL';
@@ -860,11 +860,11 @@ async function loadProjectDetails() {
// Monitoring Sessions and Data Files tabs are sound-only
// Data Files also hides the FTP browser section for manual projects
const isRemote = mode === 'remote';
document.getElementById('sessions-tab-btn').classList.toggle('hidden', !isSoundProject);
document.getElementById('data-tab-btn').classList.toggle('hidden', !isSoundProject);
// Schedules and Assigned Units: hidden for vibration; for sound, only show if remote
document.getElementById('schedules-tab-btn')?.classList.toggle('hidden', isVibrationProject || (isSoundProject && !isRemote));
document.getElementById('units-tab-btn')?.classList.toggle('hidden', isVibrationProject || (isSoundProject && !isRemote));
document.getElementById('sessions-tab-btn').classList.toggle('hidden', !hasSoundModule);
document.getElementById('data-tab-btn').classList.toggle('hidden', !hasSoundModule);
// Schedules and Assigned Units: hidden when no sound module; for sound, only show if remote
document.getElementById('schedules-tab-btn')?.classList.toggle('hidden', !hasSoundModule || !isRemote);
document.getElementById('units-tab-btn')?.classList.toggle('hidden', !hasSoundModule || !isRemote);
// FTP browser within Data Files tab
document.getElementById('ftp-browser')?.classList.toggle('hidden', !isRemote);
@@ -996,11 +996,13 @@ function openLocationModal(defaultType) {
if (connectedRadio) { connectedRadio.checked = true; updateModeLabels(); }
const locationTypeSelect = document.getElementById('location-type');
const locationTypeWrapper = locationTypeSelect.closest('div');
if (projectTypeId === 'sound_monitoring') {
const hasSoundMod = projectModules.includes('sound_monitoring');
const hasVibMod = projectModules.includes('vibration_monitoring');
if (hasSoundMod && !hasVibMod) {
locationTypeSelect.value = 'sound';
locationTypeSelect.disabled = true;
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
} else if (projectTypeId === 'vibration_monitoring') {
} else if (hasVibMod && !hasSoundMod) {
locationTypeSelect.value = 'vibration';
locationTypeSelect.disabled = true;
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
@@ -1030,11 +1032,13 @@ function openEditLocationModal(button) {
if (modeRadio) { modeRadio.checked = true; updateModeLabels(); }
const locationTypeSelect = document.getElementById('location-type');
const locationTypeWrapper = locationTypeSelect.closest('div');
if (projectTypeId === 'sound_monitoring') {
const hasSoundModE = projectModules.includes('sound_monitoring');
const hasVibModE = projectModules.includes('vibration_monitoring');
if (hasSoundModE && !hasVibModE) {
locationTypeSelect.value = 'sound';
locationTypeSelect.disabled = true;
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
} else if (projectTypeId === 'vibration_monitoring') {
} else if (hasVibModE && !hasSoundModE) {
locationTypeSelect.value = 'vibration';
locationTypeSelect.disabled = true;
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
@@ -1060,9 +1064,9 @@ document.getElementById('location-form').addEventListener('submit', async functi
const address = document.getElementById('location-address').value.trim();
const coordinates = document.getElementById('location-coordinates').value.trim();
let locationType = document.getElementById('location-type').value;
if (projectTypeId === 'sound_monitoring') {
if (projectModules.includes('sound_monitoring') && !projectModules.includes('vibration_monitoring')) {
locationType = 'sound';
} else if (projectTypeId === 'vibration_monitoring') {
} else if (projectModules.includes('vibration_monitoring') && !projectModules.includes('sound_monitoring')) {
locationType = 'vibration';
}

View File

@@ -96,124 +96,122 @@
</div>
<div class="p-6" id="createProjectContent">
<!-- Step 1: Project Type Selection (initially shown) -->
<div id="projectTypeSelection">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Choose Project Type</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"
hx-get="/api/projects/types/list"
hx-trigger="load"
hx-target="this"
hx-swap="innerHTML">
<!-- Project type cards will be loaded here -->
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
</div>
<div class="mt-6 flex justify-end">
<button type="button" onclick="hideCreateProjectModal()"
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Cancel
</button>
</div>
</div>
<form id="createProjectFormElement">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Project Name <span class="text-red-500">*</span>
</label>
<input type="text"
name="name"
required
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
</div>
<!-- Step 2: Project Details Form (hidden initially) -->
<div id="projectDetailsForm" class="hidden">
<button onclick="backToTypeSelection()"
class="mb-4 text-seismo-orange hover:text-seismo-navy">
← Back to project types
</button>
<form id="createProjectFormElement"
hx-post="/api/projects/create"
hx-swap="none">
<input type="hidden" id="project_type_id" name="project_type_id">
<div class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Project Name *
Project Number
<span class="text-gray-400 font-normal">(xxxx-YY)</span>
</label>
<input type="text"
name="name"
required
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
name="project_number"
pattern="\d{4}-\d{2}"
placeholder="2567-23"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Description
Client Name
</label>
<textarea name="description"
rows="3"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"></textarea>
<input type="text"
name="client_name"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Client Name
</label>
<input type="text"
name="client_name"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Site Address
</label>
<input type="text"
name="site_address"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Start Date
</label>
<input type="date"
name="start_date"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
End Date (Optional)
</label>
<input type="date"
name="end_date"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Description
</label>
<textarea name="description"
rows="2"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"></textarea>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Site Coordinates (Optional)
Site Address
</label>
<input type="text"
name="site_address"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Site Coordinates
<span class="text-gray-400 font-normal">(optional)</span>
</label>
<input type="text"
name="site_coordinates"
placeholder="40.7128,-74.0060"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<p class="text-xs text-gray-500 mt-1">Format: latitude,longitude</p>
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
</div>
</div>
<div class="mt-6 flex justify-end space-x-3">
<button type="button"
onclick="hideCreateProjectModal()"
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Cancel
</button>
<button type="submit"
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
Create Project
</button>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Start Date</label>
<input type="date" name="start_date"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">End Date <span class="text-gray-400 font-normal">(optional)</span></label>
<input type="date" name="end_date"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
</div>
</div>
</form>
</div>
<!-- Modules -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Add Modules
<span class="text-gray-400 font-normal">(optional — can be added later)</span>
</label>
<div class="grid grid-cols-2 gap-3">
<label class="flex items-center gap-3 p-3 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer hover:border-orange-400 has-[:checked]:border-orange-400 has-[:checked]:bg-orange-50 dark:has-[:checked]:bg-orange-900/20 transition-colors">
<input type="checkbox" id="ov-module-sound" class="accent-seismo-orange">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">Sound Monitoring</p>
<p class="text-xs text-gray-500 dark:text-gray-400">SLMs, sessions, reports</p>
</div>
</label>
<label class="flex items-center gap-3 p-3 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer hover:border-blue-400 has-[:checked]:border-blue-400 has-[:checked]:bg-blue-50 dark:has-[:checked]:bg-blue-900/20 transition-colors">
<input type="checkbox" id="ov-module-vibration" class="accent-blue-500">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">Vibration Monitoring</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Seismographs, modems</p>
</div>
</label>
</div>
</div>
</div>
<div id="ov-create-error" class="hidden mt-3 p-3 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-lg text-sm"></div>
<div class="mt-6 flex justify-end space-x-3">
<button type="button"
onclick="hideCreateProjectModal()"
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Cancel
</button>
<button type="submit" id="ov-submit-btn"
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium transition-colors">
Create Project
</button>
</div>
</form>
</div>
</div>
</div>
@@ -241,31 +239,58 @@ function showCreateProjectModal() {
document.getElementById('createProjectModal').classList.remove('hidden');
}
function showCreateProjectModal() {
document.getElementById('createProjectModal').classList.remove('hidden');
document.getElementById('createProjectFormElement').reset();
document.getElementById('ov-create-error').classList.add('hidden');
}
function hideCreateProjectModal() {
document.getElementById('createProjectModal').classList.add('hidden');
document.getElementById('projectTypeSelection').classList.remove('hidden');
document.getElementById('projectDetailsForm').classList.add('hidden');
}
function selectProjectType(typeId, typeName) {
document.getElementById('project_type_id').value = typeId;
document.getElementById('projectTypeSelection').classList.add('hidden');
document.getElementById('projectDetailsForm').classList.remove('hidden');
}
document.getElementById('createProjectFormElement').addEventListener('submit', async function(e) {
e.preventDefault();
const submitBtn = document.getElementById('ov-submit-btn');
const errorDiv = document.getElementById('ov-create-error');
errorDiv.classList.add('hidden');
submitBtn.disabled = true;
submitBtn.textContent = 'Creating...';
function backToTypeSelection() {
document.getElementById('projectTypeSelection').classList.remove('hidden');
document.getElementById('projectDetailsForm').classList.add('hidden');
}
const formData = new FormData(this);
// project_type_id no longer required — send empty string so backend accepts it
formData.set('project_type_id', '');
// Handle form submission success
document.body.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.elt.id === 'createProjectFormElement' && event.detail.successful) {
try {
const resp = await fetch('/api/projects/create', { method: 'POST', body: formData });
const result = await resp.json();
if (!resp.ok || !result.success) {
errorDiv.textContent = result.detail || result.message || 'Failed to create project';
errorDiv.classList.remove('hidden');
return;
}
const projectId = result.project_id;
// Add selected modules
if (document.getElementById('ov-module-sound').checked) {
await fetch(`/api/projects/${projectId}/modules`, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ module_type: 'sound_monitoring' }),
});
}
if (document.getElementById('ov-module-vibration').checked) {
await fetch(`/api/projects/${projectId}/modules`, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ module_type: 'vibration_monitoring' }),
});
}
hideCreateProjectModal();
// Refresh project list
htmx.ajax('GET', '/api/projects/list', {target: '#projects-list'});
// Show success message
alert('Project created successfully!');
} catch(err) {
errorDiv.textContent = `Error: ${err.message}`;
errorDiv.classList.remove('hidden');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Create Project';
}
});
</script>