feat(deployments): mobile capture wizard + classify hopper + dashboard banner
UI for the pending-deployment workflow (commits 2 + 3 from the plan,
landed together since commit 1 already shipped the full backend).
New surfaces
- /deploy — mobile-first 3-step wizard. Pick unit → take photo (uses
<input capture="environment"> so it opens the phone camera) → add
optional note + submit. EXIF GPS auto-extracted on the server.
Success page shows the captured coords + links to either "Deploy
another" or "View pending hopper." Whole flow is meant to take
under 90 seconds on site.
- /tools/pending-deployments — the hopper. Filter pills: Awaiting /
Assigned / Cancelled. Each card shows photo thumbnail, unit serial
link, captured-at timestamp, coordinates, operator note, and
status-appropriate actions.
- Classify modal on the hopper: two modes — "Assign to existing
location" (project + location pickers, scoped to vibration_monitoring)
or "Create new location" (with new-or-existing project, plus a
"use captured coords" checkbox that writes the pending row's coords
onto the new location). Calls /pending/{id}/promote on submit.
- Cancel button uses prompt() for the optional reason → POSTs to
/pending/{id}/cancel.
Backend additions
- GET /api/deployments/seismograph-picker — JSON list of non-retired
seismograph units for the /deploy unit picker. Annotates each unit
with has_pending so the picker can flag units that already have a
pending capture waiting.
Discovery
- New "Field Deploy" + "Pending Deployments" cards on /tools.
- Dashboard banner: auto-shows when there are awaiting captures,
polled every 30s. Hides when count drops to 0. Click → /tools/
pending-deployments.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Field Deploy - Seismo Fleet Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-md mx-auto">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Field Deploy</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Capture an install while you're still on site. Project + location can be picked later at a desk.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Stepper -->
|
||||
<div class="flex items-center justify-between mb-6 text-xs text-gray-500 dark:text-gray-400">
|
||||
<div id="step-pill-1" class="flex items-center gap-1 text-seismo-orange font-medium">
|
||||
<span class="w-6 h-6 rounded-full bg-seismo-orange text-white inline-flex items-center justify-center text-xs">1</span>
|
||||
Unit
|
||||
</div>
|
||||
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-700 mx-2"></div>
|
||||
<div id="step-pill-2" class="flex items-center gap-1">
|
||||
<span class="w-6 h-6 rounded-full bg-gray-200 dark:bg-gray-700 inline-flex items-center justify-center">2</span>
|
||||
Photo
|
||||
</div>
|
||||
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-700 mx-2"></div>
|
||||
<div id="step-pill-3" class="flex items-center gap-1">
|
||||
<span class="w-6 h-6 rounded-full bg-gray-200 dark:bg-gray-700 inline-flex items-center justify-center">3</span>
|
||||
Confirm
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: Pick unit -->
|
||||
<div id="step-1" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-4">
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Which seismograph?</span>
|
||||
<input id="unit-search" type="search" autocomplete="off"
|
||||
placeholder="Type a serial like BE12599…"
|
||||
class="mt-2 w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white"
|
||||
oninput="onUnitSearch()">
|
||||
</label>
|
||||
<div id="unit-list" class="max-h-72 overflow-y-auto space-y-1.5"></div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Photo -->
|
||||
<div id="step-2" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Deploying</p>
|
||||
<p class="font-mono font-semibold text-seismo-orange" id="step2-unit-label">—</p>
|
||||
</div>
|
||||
<button onclick="goToStep(1)" class="text-xs text-gray-500 hover:text-seismo-orange">Change</button>
|
||||
</div>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Install photo</span>
|
||||
<input id="photo-input" type="file" accept="image/*" capture="environment"
|
||||
onchange="onPhotoPicked(event)"
|
||||
class="mt-2 w-full text-sm text-gray-500 file:mr-4 file:py-3 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-seismo-orange file:text-white hover:file:bg-orange-600">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
On mobile, this opens the camera. EXIF GPS is auto-extracted.
|
||||
</p>
|
||||
</label>
|
||||
|
||||
<div id="photo-preview-wrap" class="hidden">
|
||||
<img id="photo-preview" class="w-full rounded-lg border border-gray-200 dark:border-gray-700" alt="Install photo preview">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Note + confirm -->
|
||||
<div id="step-3" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-4">
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Deploying</p>
|
||||
<p class="font-mono font-semibold text-seismo-orange" id="step3-unit-label">—</p>
|
||||
</div>
|
||||
|
||||
<div id="step3-photo-wrap" class="rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700">
|
||||
<img id="step3-photo" class="w-full" alt="Install photo">
|
||||
</div>
|
||||
|
||||
<div id="step3-coords-status" class="text-xs"></div>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Site memo (optional)</span>
|
||||
<textarea id="note-input" rows="3"
|
||||
placeholder="e.g. Carnegie Museum, north entrance loading dock"
|
||||
class="mt-2 w-full px-3 py-2 text-base border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white"></textarea>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Helpful when classifying later. Free text — anything that helps you remember.
|
||||
</p>
|
||||
</label>
|
||||
|
||||
<div id="capture-error" class="hidden text-sm text-red-600"></div>
|
||||
|
||||
<button id="capture-submit"
|
||||
onclick="submitCapture()"
|
||||
class="w-full px-4 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium text-base">
|
||||
Capture
|
||||
</button>
|
||||
<button onclick="goToStep(2)" class="w-full text-sm text-gray-500 hover:text-seismo-orange">← Retake photo</button>
|
||||
</div>
|
||||
|
||||
<!-- Success -->
|
||||
<div id="step-done" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-4 text-center">
|
||||
<div class="w-16 h-16 mx-auto rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Captured</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
<span id="done-unit-label" class="font-mono font-semibold text-seismo-orange"></span>
|
||||
is now in the pending hopper.
|
||||
You can classify it from <a href="/tools/pending-deployments" class="text-seismo-orange hover:text-seismo-navy underline">Tools → Pending Deployments</a> later.
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 mt-2" id="done-coords-label"></p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button onclick="resetWizard()"
|
||||
class="px-4 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium">
|
||||
Deploy another
|
||||
</button>
|
||||
<a href="/tools/pending-deployments"
|
||||
class="px-4 py-3 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg font-medium inline-flex items-center justify-center">
|
||||
View pending
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let _deployState = {
|
||||
unit_id: null,
|
||||
photo_file: null,
|
||||
photo_preview_url: null,
|
||||
captured: null, // server response from /capture
|
||||
};
|
||||
|
||||
let _unitSearchDebounce = null;
|
||||
async function onUnitSearch() {
|
||||
if (_unitSearchDebounce) clearTimeout(_unitSearchDebounce);
|
||||
_unitSearchDebounce = setTimeout(_fetchUnitList, 150);
|
||||
}
|
||||
|
||||
async function _fetchUnitList() {
|
||||
const q = document.getElementById('unit-search').value.trim();
|
||||
const list = document.getElementById('unit-list');
|
||||
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">Searching…</p>';
|
||||
try {
|
||||
const r = await fetch(`/api/deployments/seismograph-picker?q=${encodeURIComponent(q)}&limit=20`);
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
const data = await r.json();
|
||||
_renderUnitList(data.units || []);
|
||||
} catch (e) {
|
||||
list.innerHTML = `<p class="text-sm text-red-500 px-3 py-2">Search failed: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function _renderUnitList(units) {
|
||||
const list = document.getElementById('unit-list');
|
||||
if (units.length === 0) {
|
||||
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">No matching units.</p>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = units.map(u => {
|
||||
const noteHtml = u.note ? `<div class="text-xs text-gray-500 dark:text-gray-400 truncate">${_esc(u.note)}</div>` : '';
|
||||
const pendingBadge = u.has_pending
|
||||
? '<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 ml-2">already pending</span>'
|
||||
: '';
|
||||
return `<button onclick="onPickUnit('${_esc(u.id)}')"
|
||||
class="w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-mono font-semibold text-gray-900 dark:text-white">${_esc(u.id)}</span>
|
||||
${pendingBadge}
|
||||
</div>
|
||||
${noteHtml}
|
||||
</button>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function _esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function onPickUnit(unitId) {
|
||||
_deployState.unit_id = unitId;
|
||||
document.getElementById('step2-unit-label').textContent = unitId;
|
||||
document.getElementById('step3-unit-label').textContent = unitId;
|
||||
goToStep(2);
|
||||
}
|
||||
|
||||
function onPhotoPicked(e) {
|
||||
const file = e.target.files && e.target.files[0];
|
||||
if (!file) return;
|
||||
_deployState.photo_file = file;
|
||||
// Show preview.
|
||||
if (_deployState.photo_preview_url) URL.revokeObjectURL(_deployState.photo_preview_url);
|
||||
_deployState.photo_preview_url = URL.createObjectURL(file);
|
||||
document.getElementById('photo-preview').src = _deployState.photo_preview_url;
|
||||
document.getElementById('step3-photo').src = _deployState.photo_preview_url;
|
||||
document.getElementById('photo-preview-wrap').classList.remove('hidden');
|
||||
// Advance after a tiny delay so the user sees the preview.
|
||||
setTimeout(() => goToStep(3), 400);
|
||||
}
|
||||
|
||||
function goToStep(n) {
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const el = document.getElementById('step-' + i);
|
||||
if (el) el.classList.toggle('hidden', i !== n);
|
||||
const pill = document.getElementById('step-pill-' + i);
|
||||
if (pill) {
|
||||
if (i === n) {
|
||||
pill.classList.remove('text-gray-500', 'dark:text-gray-400');
|
||||
pill.classList.add('text-seismo-orange', 'font-medium');
|
||||
pill.querySelector('span').classList.remove('bg-gray-200', 'dark:bg-gray-700');
|
||||
pill.querySelector('span').classList.add('bg-seismo-orange', 'text-white');
|
||||
} else {
|
||||
pill.classList.add('text-gray-500', 'dark:text-gray-400');
|
||||
pill.classList.remove('text-seismo-orange', 'font-medium');
|
||||
pill.querySelector('span').classList.add('bg-gray-200', 'dark:bg-gray-700');
|
||||
pill.querySelector('span').classList.remove('bg-seismo-orange', 'text-white');
|
||||
}
|
||||
}
|
||||
}
|
||||
document.getElementById('step-done').classList.add('hidden');
|
||||
}
|
||||
|
||||
async function submitCapture() {
|
||||
const btn = document.getElementById('capture-submit');
|
||||
const err = document.getElementById('capture-error');
|
||||
const note = document.getElementById('note-input').value.trim();
|
||||
err.classList.add('hidden');
|
||||
|
||||
if (!_deployState.unit_id || !_deployState.photo_file) {
|
||||
err.textContent = 'Need a unit and a photo first.';
|
||||
err.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Capturing…';
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('unit_id', _deployState.unit_id);
|
||||
fd.append('operator_note', note);
|
||||
fd.append('photo', _deployState.photo_file);
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/deployments/capture', {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
});
|
||||
if (!r.ok) {
|
||||
const e = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||
throw new Error(e.detail || 'HTTP ' + r.status);
|
||||
}
|
||||
const data = await r.json();
|
||||
_deployState.captured = data;
|
||||
_showDone(data);
|
||||
} catch (e) {
|
||||
err.textContent = e.message;
|
||||
err.classList.remove('hidden');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Capture';
|
||||
}
|
||||
}
|
||||
|
||||
function _showDone(data) {
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
document.getElementById('step-' + i).classList.add('hidden');
|
||||
}
|
||||
document.getElementById('step-done').classList.remove('hidden');
|
||||
document.getElementById('done-unit-label').textContent = data.pending_deployment.unit_id;
|
||||
const coords = data.extracted_coords;
|
||||
const status = document.getElementById('done-coords-label');
|
||||
if (coords) {
|
||||
status.textContent = `GPS: ${coords}`;
|
||||
} else {
|
||||
status.textContent = 'No GPS in photo EXIF — you can add coordinates when classifying.';
|
||||
}
|
||||
}
|
||||
|
||||
function resetWizard() {
|
||||
_deployState = { unit_id: null, photo_file: null, photo_preview_url: null, captured: null };
|
||||
document.getElementById('unit-search').value = '';
|
||||
document.getElementById('note-input').value = '';
|
||||
document.getElementById('photo-input').value = '';
|
||||
document.getElementById('photo-preview-wrap').classList.add('hidden');
|
||||
document.getElementById('capture-error').classList.add('hidden');
|
||||
const btn = document.getElementById('capture-submit');
|
||||
btn.disabled = false; btn.textContent = 'Capture';
|
||||
goToStep(1);
|
||||
_fetchUnitList();
|
||||
}
|
||||
|
||||
// Kick off initial unit list on page load.
|
||||
_fetchUnitList();
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user