feat: add allocated status and project allocation to unit management
- Updated dashboard to display allocated units alongside deployed and benched units. - Introduced a quick-info modal for units, showing detailed information including calibration status, project allocation, and upcoming jobs. - Enhanced fleet calendar with a new quick-info modal for units, allowing users to view unit details without navigating away. - Modified devices table to include allocated status and visual indicators for allocated units. - Added allocated filter option in the roster view for better unit management. - Implemented backend migration to add 'allocated' and 'allocated_to_project_id' columns to the roster table. - Updated unit detail view to reflect allocated status and allow for project allocation input.
This commit is contained in:
@@ -650,7 +650,7 @@
|
||||
<!-- Fleet Summary (shown on jobs list) -->
|
||||
<div id="right-fleet-summary" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 flex flex-col gap-4">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Summary</h2>
|
||||
<div id="fleet-summary-stats" class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-center">
|
||||
<div id="fleet-summary-stats" class="flex flex-col gap-0">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
<input type="text" id="summary-search" placeholder="Search by unit ID..."
|
||||
@@ -713,6 +713,70 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unit Quick-Info Modal -->
|
||||
<div id="unit-quick-modal" class="fixed inset-0 z-50 hidden">
|
||||
<div class="fixed inset-0 bg-black/50" onclick="closeUnitQuickModal()"></div>
|
||||
<div class="fixed inset-0 flex items-center justify-center p-4 pointer-events-none">
|
||||
<div id="unit-quick-modal-inner" class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md pointer-events-auto" onclick="event.stopPropagation()">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 id="uqm-title" class="text-lg font-bold text-gray-900 dark:text-white"></h3>
|
||||
<span id="uqm-deployed-badge"></span>
|
||||
<span id="uqm-outforcal-badge"></span>
|
||||
</div>
|
||||
<button onclick="closeUnitQuickModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<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-5 py-4 flex flex-col gap-4">
|
||||
<!-- Cal row -->
|
||||
<div class="flex gap-6">
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-0.5">Last Calibration</p>
|
||||
<p id="uqm-cal-date" class="text-sm font-medium text-gray-900 dark:text-white"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-0.5">Cal Due</p>
|
||||
<p id="uqm-cal-due" class="text-sm font-medium"></p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Location / address -->
|
||||
<div id="uqm-address-row" class="hidden">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-0.5">Address / Location</p>
|
||||
<p id="uqm-address" class="text-sm text-gray-800 dark:text-gray-200"></p>
|
||||
</div>
|
||||
<!-- Project -->
|
||||
<div id="uqm-project-row" class="hidden">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-0.5">Project</p>
|
||||
<p id="uqm-project" class="text-sm text-gray-800 dark:text-gray-200"></p>
|
||||
</div>
|
||||
<!-- Modem -->
|
||||
<div id="uqm-modem-row" class="hidden">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-0.5">Deployed With Modem</p>
|
||||
<p id="uqm-modem" class="text-sm text-gray-800 dark:text-gray-200"></p>
|
||||
</div>
|
||||
<!-- Last seen -->
|
||||
<div id="uqm-lastseen-row" class="hidden">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-0.5">Last Seen</p>
|
||||
<p id="uqm-lastseen" class="text-sm text-gray-800 dark:text-gray-200"></p>
|
||||
</div>
|
||||
<!-- Note -->
|
||||
<div id="uqm-note-row" class="hidden">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-0.5">Note</p>
|
||||
<p id="uqm-note" class="text-sm text-gray-800 dark:text-gray-200 italic"></p>
|
||||
</div>
|
||||
<!-- Reservations -->
|
||||
<div id="uqm-reservations-row" class="hidden">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Upcoming Jobs</p>
|
||||
<div id="uqm-reservations" class="flex flex-col gap-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Day Detail Slide Panel -->
|
||||
<div id="panel-backdrop" class="panel-backdrop" onclick="closeDayPanel()"></div>
|
||||
<div id="day-panel" class="slide-panel">
|
||||
@@ -1678,7 +1742,7 @@ function plannerRenderUnits() {
|
||||
row.innerHTML = `
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<button onclick="event.stopPropagation(); openUnitDetailModal('${unit.id}')"
|
||||
<button onclick="event.stopPropagation(); openUnitQuickModal('${unit.id}')"
|
||||
class="font-medium text-blue-600 dark:text-blue-400 hover:underline text-sm">${unit.id}</button>
|
||||
${deployedBadge}
|
||||
${expiryWarning}
|
||||
@@ -1707,6 +1771,108 @@ function closeUnitDetailModal() {
|
||||
document.getElementById('unit-detail-iframe').src = '';
|
||||
}
|
||||
|
||||
async function openUnitQuickModal(unitId) {
|
||||
document.getElementById('unit-quick-modal').classList.remove('hidden');
|
||||
// Reset while loading
|
||||
document.getElementById('uqm-title').textContent = unitId;
|
||||
document.getElementById('uqm-deployed-badge').innerHTML = '';
|
||||
document.getElementById('uqm-outforcal-badge').innerHTML = '';
|
||||
document.getElementById('uqm-cal-date').textContent = '…';
|
||||
document.getElementById('uqm-cal-due').textContent = '…';
|
||||
['uqm-address-row','uqm-project-row','uqm-modem-row','uqm-lastseen-row','uqm-note-row','uqm-reservations-row']
|
||||
.forEach(id => document.getElementById(id).classList.add('hidden'));
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/fleet-calendar/unit-quick-info/${unitId}`);
|
||||
if (!resp.ok) throw new Error('Not found');
|
||||
const u = await resp.json();
|
||||
const today = new Date(); today.setHours(0,0,0,0);
|
||||
|
||||
// Deployed badge
|
||||
document.getElementById('uqm-deployed-badge').innerHTML = u.deployed
|
||||
? '<span class="text-xs px-2 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-full">Deployed</span>'
|
||||
: '<span class="text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded-full">Benched</span>';
|
||||
|
||||
// Out for cal badge
|
||||
if (u.out_for_calibration) {
|
||||
document.getElementById('uqm-outforcal-badge').innerHTML =
|
||||
'<span class="text-xs px-2 py-0.5 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 rounded-full">Out for Cal</span>';
|
||||
}
|
||||
|
||||
// Cal date
|
||||
const calDateEl = document.getElementById('uqm-cal-date');
|
||||
calDateEl.textContent = u.last_calibrated
|
||||
? new Date(u.last_calibrated + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'})
|
||||
: 'No record';
|
||||
calDateEl.className = `text-sm font-medium ${!u.last_calibrated ? 'text-red-500 dark:text-red-400' : 'text-gray-900 dark:text-white'}`;
|
||||
|
||||
// Cal due
|
||||
const calDueEl = document.getElementById('uqm-cal-due');
|
||||
if (u.next_calibration_due) {
|
||||
const due = new Date(u.next_calibration_due + 'T00:00:00');
|
||||
const expired = due < today;
|
||||
calDueEl.textContent = due.toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'}) + (expired ? ' (expired)' : '');
|
||||
calDueEl.className = `text-sm font-medium ${expired ? 'text-red-500 dark:text-red-400' : 'text-green-600 dark:text-green-400'}`;
|
||||
} else {
|
||||
calDueEl.textContent = '—';
|
||||
calDueEl.className = 'text-sm font-medium text-red-500 dark:text-red-400';
|
||||
}
|
||||
|
||||
// Address
|
||||
if (u.address) {
|
||||
document.getElementById('uqm-address-row').classList.remove('hidden');
|
||||
document.getElementById('uqm-address').textContent = u.address;
|
||||
}
|
||||
|
||||
// Project
|
||||
if (u.project_id) {
|
||||
document.getElementById('uqm-project-row').classList.remove('hidden');
|
||||
document.getElementById('uqm-project').textContent = u.project_id;
|
||||
}
|
||||
|
||||
// Modem
|
||||
if (u.deployed_with_modem_id) {
|
||||
document.getElementById('uqm-modem-row').classList.remove('hidden');
|
||||
document.getElementById('uqm-modem').textContent = u.deployed_with_modem_id;
|
||||
}
|
||||
|
||||
// Last seen
|
||||
if (u.last_seen) {
|
||||
document.getElementById('uqm-lastseen-row').classList.remove('hidden');
|
||||
document.getElementById('uqm-lastseen').textContent =
|
||||
new Date(u.last_seen).toLocaleString('en-US', {month:'short', day:'numeric', year:'numeric', hour:'numeric', minute:'2-digit'});
|
||||
}
|
||||
|
||||
// Note
|
||||
if (u.note) {
|
||||
document.getElementById('uqm-note-row').classList.remove('hidden');
|
||||
document.getElementById('uqm-note').textContent = u.note;
|
||||
}
|
||||
|
||||
// Reservations
|
||||
if (u.reservations && u.reservations.length > 0) {
|
||||
document.getElementById('uqm-reservations-row').classList.remove('hidden');
|
||||
document.getElementById('uqm-reservations').innerHTML = u.reservations.map(r => {
|
||||
const s = r.start_date ? new Date(r.start_date + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric'}) : '';
|
||||
const e = r.end_date_tbd ? 'TBD' : (r.end_date ? new Date(r.end_date + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric'}) : 'TBD');
|
||||
const loc = r.location_name ? ` · ${r.location_name}` : '';
|
||||
return `<div class="flex items-center gap-2 text-sm">
|
||||
<span class="w-2.5 h-2.5 rounded-full flex-shrink-0" style="background:${r.color}"></span>
|
||||
<span class="font-medium text-gray-800 dark:text-gray-200">${r.name}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500">${s}–${e}${loc}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
} catch(e) {
|
||||
document.getElementById('uqm-cal-date').textContent = 'Error loading';
|
||||
}
|
||||
}
|
||||
|
||||
function closeUnitQuickModal() {
|
||||
document.getElementById('unit-quick-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
function plannerAddSlot() {
|
||||
plannerState.slots.push({ unit_id: null, power_type: null, notes: null, location_name: null });
|
||||
plannerRenderSlots();
|
||||
@@ -1716,7 +1882,7 @@ function plannerAddSlot() {
|
||||
// Fleet Summary (right panel on jobs list)
|
||||
// ============================================================
|
||||
let summaryAllUnits = [];
|
||||
let summaryActiveFilter = null; // null | 'deployed' | 'benched' | 'cal_expired'
|
||||
let summaryActiveFilters = new Set(); // multi-select: 'deployed' | 'benched' | 'cal_expired' | 'cal_good' | 'out_for_cal' | 'reserved'
|
||||
|
||||
async function loadFleetSummary() {
|
||||
const plannerDeviceType = document.querySelector('input[name="planner_device_type"]:checked')?.value || 'seismograph';
|
||||
@@ -1724,7 +1890,7 @@ async function loadFleetSummary() {
|
||||
const resp = await fetch(`/api/fleet-calendar/planner-availability?device_type=${plannerDeviceType}`);
|
||||
const data = await resp.json();
|
||||
summaryAllUnits = data.units || [];
|
||||
summaryActiveFilter = null;
|
||||
summaryActiveFilters = new Set();
|
||||
renderFleetSummary();
|
||||
} catch(e) { console.error('Fleet summary load error', e); }
|
||||
}
|
||||
@@ -1733,88 +1899,158 @@ function summaryFilterUnits() {
|
||||
renderFleetSummary();
|
||||
}
|
||||
|
||||
// Stat cards: set exactly this one filter (or clear all if already the only active one)
|
||||
function summarySetFilter(f) {
|
||||
summaryActiveFilter = summaryActiveFilter === f ? null : f;
|
||||
if (f === null) {
|
||||
summaryActiveFilters = new Set();
|
||||
} else if (summaryActiveFilters.size === 1 && summaryActiveFilters.has(f)) {
|
||||
summaryActiveFilters = new Set();
|
||||
} else {
|
||||
summaryActiveFilters = new Set([f]);
|
||||
}
|
||||
renderFleetSummary();
|
||||
}
|
||||
|
||||
// Pills: toggle independently (multi-select)
|
||||
function summaryToggleFilter(f) {
|
||||
if (summaryActiveFilters.has(f)) summaryActiveFilters.delete(f);
|
||||
else summaryActiveFilters.add(f);
|
||||
renderFleetSummary();
|
||||
}
|
||||
|
||||
function renderFleetSummary() {
|
||||
const search = document.getElementById('summary-search')?.value.toLowerCase() || '';
|
||||
const today = new Date(); today.setHours(0,0,0,0);
|
||||
|
||||
// Stats (always against full list)
|
||||
const total = summaryAllUnits.length;
|
||||
const deployed = summaryAllUnits.filter(u => u.deployed).length;
|
||||
const benched = summaryAllUnits.filter(u => !u.deployed).length;
|
||||
const calExpired = summaryAllUnits.filter(u => u.expiry_date && new Date(u.expiry_date + 'T00:00:00') < new Date()).length;
|
||||
// Computed flags for each unit
|
||||
const withFlags = summaryAllUnits.map(u => {
|
||||
const expiry = u.expiry_date ? new Date(u.expiry_date + 'T00:00:00') : null;
|
||||
return {
|
||||
...u,
|
||||
_calExpired: !u.last_calibrated || (expiry && expiry < today),
|
||||
_calGood: u.last_calibrated && expiry && expiry >= today,
|
||||
_outForCal: !!u.out_for_calibration,
|
||||
_allocated: !!u.allocated,
|
||||
_reserved: (u.reservations || []).length > 0,
|
||||
};
|
||||
});
|
||||
|
||||
// Counts always against full list
|
||||
const counts = {
|
||||
total: withFlags.length,
|
||||
deployed: withFlags.filter(u => u.deployed).length,
|
||||
benched: withFlags.filter(u => !u.deployed).length,
|
||||
cal_expired: withFlags.filter(u => u._calExpired).length,
|
||||
cal_good: withFlags.filter(u => u._calGood).length,
|
||||
out_for_cal: withFlags.filter(u => u._outForCal).length,
|
||||
allocated: withFlags.filter(u => u._allocated).length,
|
||||
reserved: withFlags.filter(u => u._reserved).length,
|
||||
};
|
||||
|
||||
const af = summaryActiveFilters;
|
||||
|
||||
// Stat cards — single-shortcut behavior, highlighted when they're the sole active filter
|
||||
const cardActive = (f) => af.size === 1 && af.has(f);
|
||||
const card = (f, label, count, colorClass, ringColor) => {
|
||||
const isActive = f === null ? af.size === 0 : cardActive(f);
|
||||
return `<button onclick="summarySetFilter(${f === null ? 'null' : `'${f}'`})"
|
||||
class="rounded-lg p-3 text-left w-full cursor-pointer transition-all ring-2 ${isActive ? ringColor : 'ring-transparent'} ${colorClass}">
|
||||
<p class="text-2xl font-bold">${count}</p>
|
||||
<p class="text-xs opacity-80">${label}</p>
|
||||
</button>`;
|
||||
};
|
||||
|
||||
const cardBase = 'rounded-lg p-3 text-left w-full cursor-pointer transition-all ring-2';
|
||||
const active = summaryActiveFilter;
|
||||
document.getElementById('fleet-summary-stats').innerHTML = `
|
||||
<button onclick="summarySetFilter(null)"
|
||||
class="${cardBase} ${!active ? 'ring-gray-400 dark:ring-gray-300' : 'ring-transparent'} bg-gray-50 dark:bg-slate-700 hover:bg-gray-100 dark:hover:bg-slate-600">
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">${total}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Total</p>
|
||||
</button>
|
||||
<button onclick="summarySetFilter('deployed')"
|
||||
class="${cardBase} ${active === 'deployed' ? 'ring-green-500' : 'ring-transparent'} bg-green-50 dark:bg-green-900/20 hover:bg-green-100 dark:hover:bg-green-900/40">
|
||||
<p class="text-2xl font-bold text-green-700 dark:text-green-400">${deployed}</p>
|
||||
<p class="text-xs text-green-600 dark:text-green-500">Deployed</p>
|
||||
</button>
|
||||
<button onclick="summarySetFilter('benched')"
|
||||
class="${cardBase} ${active === 'benched' ? 'ring-blue-500' : 'ring-transparent'} bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/40">
|
||||
<p class="text-2xl font-bold text-blue-700 dark:text-blue-400">${benched}</p>
|
||||
<p class="text-xs text-blue-600 dark:text-blue-500">Benched</p>
|
||||
</button>
|
||||
<button onclick="summarySetFilter('cal_expired')"
|
||||
class="${cardBase} ${active === 'cal_expired' ? 'ring-red-500' : 'ring-transparent'} bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/40">
|
||||
<p class="text-2xl font-bold text-red-700 dark:text-red-400">${calExpired}</p>
|
||||
<p class="text-xs text-red-600 dark:text-red-500">Cal Expired</p>
|
||||
</button>
|
||||
`;
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
${card(null, 'Total', counts.total, 'bg-gray-50 dark:bg-slate-700 text-gray-900 dark:text-white hover:bg-gray-100 dark:hover:bg-slate-600', 'ring-gray-400 dark:ring-gray-300')}
|
||||
${card('deployed', 'Deployed', counts.deployed, 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900/40', 'ring-green-500')}
|
||||
${card('benched', 'Benched', counts.benched, 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-900/40', 'ring-blue-500')}
|
||||
${card('cal_good', 'Cal Good', counts.cal_good, 'bg-teal-50 dark:bg-teal-900/20 text-teal-700 dark:text-teal-400 hover:bg-teal-100 dark:hover:bg-teal-900/40', 'ring-teal-500')}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5 mt-2">
|
||||
${summaryPill('cal_expired', 'Cal Expired', counts.cal_expired, af)}
|
||||
${summaryPill('out_for_cal', 'Out for Cal', counts.out_for_cal, af)}
|
||||
${summaryPill('allocated', 'Allocated', counts.allocated, af)}
|
||||
${summaryPill('reserved', 'Reserved', counts.reserved, af)}
|
||||
</div>`;
|
||||
|
||||
// Apply filter + search to the list
|
||||
let units = summaryAllUnits;
|
||||
if (active === 'deployed') units = units.filter(u => u.deployed);
|
||||
else if (active === 'benched') units = units.filter(u => !u.deployed);
|
||||
else if (active === 'cal_expired') units = units.filter(u => u.expiry_date && new Date(u.expiry_date + 'T00:00:00') < new Date());
|
||||
// Apply all active filters (AND logic) + search
|
||||
const filterFns = {
|
||||
deployed: u => u.deployed,
|
||||
benched: u => !u.deployed,
|
||||
cal_expired: u => u._calExpired,
|
||||
cal_good: u => u._calGood,
|
||||
out_for_cal: u => u._outForCal,
|
||||
allocated: u => u._allocated,
|
||||
reserved: u => u._reserved,
|
||||
};
|
||||
let units = af.size === 0 ? withFlags : withFlags.filter(u => [...af].some(f => filterFns[f](u)));
|
||||
if (search) units = units.filter(u => u.id.toLowerCase().includes(search));
|
||||
|
||||
// Unit list
|
||||
const list = document.getElementById('fleet-summary-list');
|
||||
if (units.length === 0) {
|
||||
list.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 text-center py-8">No units found</p>';
|
||||
list.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 text-center py-8">No units match</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = units.map(u => {
|
||||
const calDate = u.last_calibrated
|
||||
? new Date(u.last_calibrated + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'})
|
||||
: 'No cal date';
|
||||
const expired = u.expiry_date && new Date(u.expiry_date + 'T00:00:00') < new Date();
|
||||
: null;
|
||||
const expiryDate = u.expiry_date
|
||||
? new Date(u.expiry_date + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'})
|
||||
: null;
|
||||
|
||||
const deployedBadge = u.deployed
|
||||
? '<span class="text-xs px-1.5 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded">Deployed</span>'
|
||||
: '<span class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded">Benched</span>';
|
||||
const calBadge = expired
|
||||
? `<span class="text-xs px-1.5 py-0.5 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded">Cal expired</span>`
|
||||
: `<span class="text-xs text-gray-400 dark:text-gray-500">Cal: ${calDate}</span>`;
|
||||
const outForCalBadge = u._outForCal
|
||||
? '<span class="text-xs px-1.5 py-0.5 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 rounded">Out for Cal</span>'
|
||||
: '';
|
||||
const allocatedBadge = u._allocated
|
||||
? `<span class="text-xs px-1.5 py-0.5 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400 rounded" title="${u.allocated_to_project_id ? 'For: ' + u.allocated_to_project_id : ''}">Allocated${u.allocated_to_project_id ? ': ' + u.allocated_to_project_id : ''}</span>`
|
||||
: '';
|
||||
let calBadge;
|
||||
if (!calDate) {
|
||||
calBadge = '<span class="text-xs px-1.5 py-0.5 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded">Cal expired</span>';
|
||||
} else if (u._calExpired) {
|
||||
calBadge = `<span class="text-xs px-1.5 py-0.5 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded">Cal expired ${expiryDate}</span>`;
|
||||
} else {
|
||||
calBadge = `<span class="text-xs text-gray-400 dark:text-gray-500">Cal: ${calDate} · exp. ${expiryDate}</span>`;
|
||||
}
|
||||
|
||||
const resBadges = (u.reservations || []).map(r => {
|
||||
const s = r.start_date ? new Date(r.start_date + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric'}) : '';
|
||||
const e = r.end_date ? new Date(r.end_date + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric'}) : 'TBD';
|
||||
return `<span class="text-xs px-1.5 py-0.5 rounded font-medium" style="background-color:${r.color}22; color:${r.color}; border:1px solid ${r.color}66;"><span class="opacity-60">Reserved:</span> ${r.reservation_name} ${s}–${e}</span>`;
|
||||
return `<span class="text-xs px-1.5 py-0.5 rounded font-medium" style="background-color:${r.color}22; color:${r.color}; border:1px solid ${r.color}66;">${r.reservation_name} ${s}–${e}</span>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="flex flex-col gap-1 px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<button onclick="openUnitDetailModal('${u.id}')"
|
||||
<button onclick="openUnitQuickModal('${u.id}')"
|
||||
class="font-medium text-blue-600 dark:text-blue-400 hover:underline text-sm">${u.id}</button>
|
||||
${deployedBadge}
|
||||
${calBadge}
|
||||
${deployedBadge}${outForCalBadge}${allocatedBadge}${calBadge}
|
||||
</div>
|
||||
${resBadges ? `<div class="flex flex-wrap gap-1">${resBadges}</div>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function summaryPill(f, label, count, activeSet) {
|
||||
const isActive = activeSet.has(f);
|
||||
const pillColors = {
|
||||
cal_expired: isActive ? 'bg-red-600 text-white border-red-600' : 'bg-transparent border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-red-500 hover:text-red-600 dark:hover:text-red-400',
|
||||
out_for_cal: isActive ? 'bg-purple-600 text-white border-purple-600' : 'bg-transparent border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-purple-500 hover:text-purple-600 dark:hover:text-purple-400',
|
||||
allocated: isActive ? 'bg-orange-500 text-white border-orange-500' : 'bg-transparent border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-orange-500 hover:text-orange-600 dark:hover:text-orange-400',
|
||||
reserved: isActive ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-transparent border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-indigo-500 hover:text-indigo-600 dark:hover:text-indigo-400',
|
||||
};
|
||||
return `<button onclick="summaryToggleFilter('${f}')"
|
||||
class="text-xs px-2.5 py-1 rounded-full font-medium border transition-colors ${pillColors[f]}">
|
||||
${label} <span class="${isActive ? 'opacity-80' : 'opacity-60'}">${count}</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function showRightPanel(panel) {
|
||||
document.getElementById('right-fleet-summary').classList.toggle('hidden', panel !== 'summary');
|
||||
document.getElementById('right-available-units').classList.toggle('hidden', panel !== 'available');
|
||||
@@ -1959,37 +2195,32 @@ function plannerRenderSlots() {
|
||||
? `<span class="text-gray-300 dark:text-gray-600 cursor-grab active:cursor-grabbing select-none" title="Drag to reorder">⠿</span>`
|
||||
: `<span class="w-4"></span>`;
|
||||
|
||||
// Build unit info badges for filled slots
|
||||
let unitInfoLine = '';
|
||||
// Build inline cal text for filled slots
|
||||
let calInline = '';
|
||||
if (slot.unit_id) {
|
||||
const uData = plannerState.allUnits.find(u => u.id === slot.unit_id);
|
||||
if (uData) {
|
||||
const deployedBadge = uData.deployed
|
||||
? '<span class="px-1.5 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded">Deployed</span>'
|
||||
: '<span class="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded">Benched</span>';
|
||||
const outForCalBadge = uData.out_for_calibration
|
||||
? '<span class="px-1.5 py-0.5 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 rounded">Out for Cal</span>'
|
||||
: '';
|
||||
const calStr = uData.last_calibrated
|
||||
? new Date(uData.last_calibrated + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'})
|
||||
: 'No cal date';
|
||||
const today = new Date(); today.setHours(0,0,0,0);
|
||||
const expiry = uData.expiry_date ? new Date(uData.expiry_date + 'T00:00:00') : null;
|
||||
const calExpired = !uData.last_calibrated || (expiry && expiry < today);
|
||||
const start = document.getElementById('planner-start').value;
|
||||
const end = document.getElementById('planner-end').value;
|
||||
let expiryBadge = '';
|
||||
if (uData.expiry_date) {
|
||||
const expiry = new Date(uData.expiry_date + 'T00:00:00');
|
||||
const jobStart = start ? new Date(start + 'T00:00:00') : null;
|
||||
const jobEnd = end ? new Date(end + 'T00:00:00') : null;
|
||||
const jobStart = start ? new Date(start + 'T00:00:00') : null;
|
||||
const jobEnd = end ? new Date(end + 'T00:00:00') : null;
|
||||
const expiresInJob = expiry && jobStart && jobEnd && expiry >= jobStart && expiry <= jobEnd;
|
||||
|
||||
if (!uData.last_calibrated) {
|
||||
calInline = `<span class="text-xs text-red-500 dark:text-red-400 font-medium">No cal</span>`;
|
||||
} else if (calExpired) {
|
||||
const expiryStr = expiry.toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'});
|
||||
if (jobStart && jobEnd && expiry >= jobStart && expiry <= jobEnd) {
|
||||
expiryBadge = `<span class="px-1.5 py-0.5 bg-amber-50 dark:bg-amber-900/20 text-amber-600 dark:text-amber-400 rounded border border-amber-200 dark:border-amber-800">cal expires ${expiryStr}</span>`;
|
||||
} else if (!jobStart || !jobEnd) {
|
||||
expiryBadge = `<span class="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded">cal exp. ${expiryStr}</span>`;
|
||||
}
|
||||
calInline = `<span class="text-xs text-red-500 dark:text-red-400 font-medium">Cal exp. ${expiryStr}</span>`;
|
||||
} else if (expiresInJob) {
|
||||
const expiryStr = expiry.toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'});
|
||||
calInline = `<span class="text-xs text-amber-500 dark:text-amber-400 font-medium">Cal exp. ${expiryStr}</span>`;
|
||||
} else {
|
||||
expiryBadge = '<span class="px-1.5 py-0.5 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded">No cal</span>';
|
||||
const expiryStr = expiry.toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'});
|
||||
calInline = `<span class="text-xs text-gray-400 dark:text-gray-500">Cal exp. ${expiryStr}</span>`;
|
||||
}
|
||||
unitInfoLine = `<div class="pl-6 flex items-center gap-1.5 flex-wrap text-xs mt-0.5">${deployedBadge}${outForCalBadge}${expiryBadge}<span class="text-gray-400 dark:text-gray-500">Cal: ${calStr}</span></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1998,7 +2229,8 @@ function plannerRenderSlots() {
|
||||
${dragHandle}
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400 w-16 flex-shrink-0">Loc. ${idx + 1}</span>
|
||||
${slot.unit_id
|
||||
? `<span class="flex-1 font-medium text-gray-900 dark:text-white">${slot.unit_id}</span>
|
||||
? `<button onclick="openUnitQuickModal('${slot.unit_id}')" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">${slot.unit_id}</button>
|
||||
${calInline ? `<span class="flex-1">${calInline}</span>` : '<span class="flex-1"></span>'}
|
||||
${powerSelect}
|
||||
<button onclick="plannerClearSlot(${idx})" class="text-xs text-gray-400 hover:text-red-500 flex-shrink-0" title="Remove unit">✕</button>`
|
||||
: `<button onclick="plannerSelectSlot(${idx})" class="flex-1 text-left text-sm italic ${plannerSelectedSlotIdx === idx ? 'text-blue-600 dark:text-blue-400 font-medium' : 'text-gray-400 dark:text-gray-500'}">${plannerSelectedSlotIdx === idx ? '← click a unit to assign here' : 'Empty — click to select'}</button>
|
||||
@@ -2006,7 +2238,6 @@ function plannerRenderSlots() {
|
||||
<button onclick="plannerRemoveSlot(${idx})" class="text-xs text-gray-400 hover:text-red-500 flex-shrink-0" title="Remove location">✕</button>`
|
||||
}
|
||||
</div>
|
||||
${unitInfoLine}
|
||||
<div class="pl-8 flex flex-col gap-1 mt-1">
|
||||
<input type="text" value="${slot.location_name ? slot.location_name.replace(/"/g, '"') : ''}"
|
||||
oninput="plannerSetLocationName(${idx}, this.value)"
|
||||
|
||||
Reference in New Issue
Block a user