Доработка отображения текущего плана
This commit is contained in:
+9
-1
@@ -11,13 +11,15 @@ import org.springframework.web.bind.annotation.RestController
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionRequestDTO
|
||||
import space.nstart.pcp.slots_service.service.MissionService
|
||||
import space.nstart.pcp.slots_service.service.SatelliteCatalogService
|
||||
import space.nstart.pcp.slots_service.service.SlotService
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/current-plans")
|
||||
class CurrentPlansController(
|
||||
private val satelliteCatalogService: SatelliteCatalogService,
|
||||
private val missionService: MissionService
|
||||
private val missionService: MissionService,
|
||||
private val slotService: SlotService
|
||||
) {
|
||||
|
||||
@GetMapping("/satellites")
|
||||
@@ -36,6 +38,9 @@ class CurrentPlansController(
|
||||
@GetMapping("/missions/{missionId}/modes")
|
||||
fun modes(@PathVariable missionId: UUID) = missionService.modesByMission(missionId)
|
||||
|
||||
@GetMapping("/booked-slots")
|
||||
fun bookedSlots(@RequestParam ids: List<Long>) = slotService.bookedByIds(ids)
|
||||
|
||||
@PostMapping("/missions/{missionId}/surveys/calculate")
|
||||
fun calculateSurveys(
|
||||
@PathVariable missionId: UUID,
|
||||
@@ -44,4 +49,7 @@ class CurrentPlansController(
|
||||
|
||||
@PostMapping("/missions/{missionId}/drops/calculate")
|
||||
fun calculateDrops(@PathVariable missionId: UUID) = missionService.calculateDrops(missionId)
|
||||
|
||||
@PostMapping("/missions/{missionId}/confirm")
|
||||
fun confirmMission(@PathVariable missionId: UUID) = missionService.confirmMission(missionId)
|
||||
}
|
||||
|
||||
+9
@@ -103,6 +103,15 @@ class MissionService(webClientBuilderProvider: ObjectProvider<WebClient.Builder>
|
||||
.block()
|
||||
}
|
||||
|
||||
fun confirmMission(missionId: UUID) {
|
||||
webClientBuilder.build()
|
||||
.post()
|
||||
.uri("$url/api/missions/$missionId/confirm")
|
||||
.retrieve()
|
||||
.toBodilessEntity()
|
||||
.block()
|
||||
}
|
||||
|
||||
fun modesByMission(missionId: UUID): List<ModeResponseDTO> =
|
||||
webClientBuilder.build()
|
||||
.get()
|
||||
|
||||
+14
@@ -165,6 +165,20 @@ class SlotService(webClientBuilderProvider: ObjectProvider<WebClient.Builder>) {
|
||||
.block()
|
||||
?: emptyList()
|
||||
|
||||
fun bookedDetailsByIds(bookedSlotIds: List<Long>): List<BookedSlotDetailsDTO> {
|
||||
val ids = bookedSlotIds.filter { it > 0 }.distinct().toSet()
|
||||
if (ids.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return searchBooked(
|
||||
timeStart = null,
|
||||
timeStop = null,
|
||||
requestId = null,
|
||||
satelliteIds = null
|
||||
).filter { it.bookedSlotId in ids }
|
||||
}
|
||||
|
||||
|
||||
fun searchBooked(
|
||||
timeStart: LocalDateTime?,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
filteredSatellites: [],
|
||||
missions: [],
|
||||
currentModes: [],
|
||||
bookedSlotsById: {},
|
||||
selectedSatelliteId: null,
|
||||
selectedMissionId: null,
|
||||
selectedMission: null,
|
||||
@@ -168,6 +169,10 @@
|
||||
class="btn btn-outline-success btn-sm current-plans-action-btn"
|
||||
data-action="calculate-drops"
|
||||
data-mission-id="${escapeHtml(mission.missionId)}">Расчёт сбросов</button>
|
||||
<button type="button"
|
||||
class="btn btn-success btn-sm current-plans-action-btn"
|
||||
data-action="confirm-mission"
|
||||
data-mission-id="${escapeHtml(mission.missionId)}">Утвердить</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
@@ -184,6 +189,8 @@
|
||||
calculateSurveys(missionId, button);
|
||||
} else if (button.dataset.action === 'calculate-drops') {
|
||||
calculateDrops(missionId, button);
|
||||
} else if (button.dataset.action === 'confirm-mission') {
|
||||
confirmMission(missionId, button);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -208,6 +215,7 @@
|
||||
try {
|
||||
const modes = await fetchJson(`/api/current-plans/missions/${encodeURIComponent(missionId)}/modes`);
|
||||
state.currentModes = Array.isArray(modes) ? modes : [];
|
||||
state.bookedSlotsById = await loadBookedSlotsForModes(state.currentModes);
|
||||
renderModes(state.currentModes);
|
||||
setExportEnabled(true);
|
||||
return state.currentModes;
|
||||
@@ -218,6 +226,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBookedSlotsForModes(modes) {
|
||||
const bookedSlotIds = [...new Set((modes || []).flatMap((mode) => mode.bookedSlotIds || []))]
|
||||
.filter((slotId) => Number(slotId) > 0);
|
||||
if (!bookedSlotIds.length) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
bookedSlotIds.forEach((slotId) => params.append('ids', slotId));
|
||||
|
||||
try {
|
||||
const bookedSlots = await fetchJson(`/api/current-plans/booked-slots?${params.toString()}`);
|
||||
return (bookedSlots || []).reduce((acc, slot) => {
|
||||
acc[slot.bookedSlotId] = slot;
|
||||
return acc;
|
||||
}, {});
|
||||
} catch (error) {
|
||||
showAlert(error.message || 'Не удалось загрузить детали забронированных слотов.', 'warning');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function renderModes(modes) {
|
||||
const tbody = el('current-plans-modes-body');
|
||||
if (!tbody) return;
|
||||
@@ -227,35 +257,133 @@
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = modes.map((mode) => {
|
||||
tbody.innerHTML = modes.flatMap((mode) => {
|
||||
const type = mode.type || '—';
|
||||
const duration = formatNumber(mode.duration);
|
||||
const rowClass = modeRowClass(mode);
|
||||
const badgeClass = modeBadgeClass(mode);
|
||||
return `
|
||||
<tr class="${rowClass}">
|
||||
<td><span class="current-plans-badge ${badgeClass}">${escapeHtml(type)}</span></td>
|
||||
const expandable = isExpandableMode(mode);
|
||||
const expandMarker = expandable ? '<span class="current-plans-expand-marker">▸</span>' : '';
|
||||
const row = `
|
||||
<tr class="${rowClass}${expandable ? ' current-plans-expandable-row' : ''}" data-mode-id="${escapeHtml(mode.id ?? '')}">
|
||||
<td>${expandMarker}<span class="current-plans-badge ${badgeClass}">${escapeHtml(type)}</span></td>
|
||||
<td>${formatDateTime(mode.timeStart)}</td>
|
||||
<td>${escapeHtml(mode.revolution ?? '—')}</td>
|
||||
<td>${duration}</td>
|
||||
<td class="current-plans-mode-params">${modeParams(mode)}</td>
|
||||
<td>${modeStatus(mode)}</td>
|
||||
</tr>`;
|
||||
const details = expandable ? modeDetailsRow(mode) : null;
|
||||
return details ? [row, details] : [row];
|
||||
}).join('');
|
||||
|
||||
tbody.querySelectorAll('.current-plans-expandable-row').forEach((row) => {
|
||||
row.addEventListener('click', () => toggleModeDetails(row.dataset.modeId));
|
||||
});
|
||||
}
|
||||
|
||||
function modeRowClass(mode) {
|
||||
if (mode.type === 'DROP') return 'current-plans-mode-row-drop';
|
||||
if (mode.source === 'SLOTS') return 'current-plans-mode-row-slots';
|
||||
if (isSlotsLikeSource(mode.source)) return 'current-plans-mode-row-slots';
|
||||
return '';
|
||||
}
|
||||
|
||||
function modeBadgeClass(mode) {
|
||||
if (mode.type === 'DROP') return 'current-plans-badge-drop';
|
||||
if (mode.source === 'SLOTS') return 'current-plans-badge-slots';
|
||||
if (isSlotsLikeSource(mode.source)) return 'current-plans-badge-slots';
|
||||
return '';
|
||||
}
|
||||
|
||||
function isSlotsLikeSource(source) {
|
||||
return source === 'SLOTS' || source === 'MIXED';
|
||||
}
|
||||
|
||||
function isExpandableMode(mode) {
|
||||
return (Array.isArray(mode.bookedSlotIds) && mode.bookedSlotIds.length > 0)
|
||||
|| (mode.type === 'DROP' && Array.isArray(mode.surveys) && mode.surveys.length > 0);
|
||||
}
|
||||
|
||||
function toggleModeDetails(modeId) {
|
||||
const row = document.querySelector(`#current-plans-modes-body tr[data-mode-id="${cssEscape(modeId)}"]`);
|
||||
const details = document.querySelector(`#current-plans-modes-body tr[data-parent-mode-id="${cssEscape(modeId)}"]`);
|
||||
if (!row || !details) return;
|
||||
|
||||
const collapsed = details.classList.toggle('d-none');
|
||||
row.classList.toggle('current-plans-expanded-row', !collapsed);
|
||||
const marker = row.querySelector('.current-plans-expand-marker');
|
||||
if (marker) marker.textContent = collapsed ? '▸' : '▾';
|
||||
}
|
||||
|
||||
function modeDetailsRow(mode) {
|
||||
const content = mode.type === 'DROP'
|
||||
? dropDetails(mode)
|
||||
: bookedSlotDetails(mode);
|
||||
|
||||
return `
|
||||
<tr class="current-plans-mode-details-row d-none" data-parent-mode-id="${escapeHtml(mode.id ?? '')}">
|
||||
<td colspan="6">
|
||||
<div class="current-plans-mode-details">${content}</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function bookedSlotDetails(mode) {
|
||||
const slotIds = Array.isArray(mode.bookedSlotIds) ? mode.bookedSlotIds : [];
|
||||
const slots = slotIds.map((slotId) => {
|
||||
const slot = state.bookedSlotsById[slotId];
|
||||
if (!slot) {
|
||||
return `<span class="current-plans-detail-token">#${escapeHtml(slotId)}</span>`;
|
||||
}
|
||||
return `<span class="current-plans-detail-token">#${escapeHtml(slot.bookedSlotId)} · slot ${escapeHtml(slot.slotNum)} · cycle ${escapeHtml(slot.cycle)} · sat ${escapeHtml(slot.satelliteId)}</span>`;
|
||||
}).join('');
|
||||
return `
|
||||
<div class="current-plans-detail-title">Забронированные слоты (${slotIds.length})</div>
|
||||
<div class="current-plans-detail-list">${slots}</div>`;
|
||||
}
|
||||
|
||||
function dropDetails(mode) {
|
||||
const surveyIds = Array.isArray(mode.surveys) ? mode.surveys : [];
|
||||
const surveysById = new Map((state.currentModes || []).map((item) => [Number(item.id), item]));
|
||||
const rows = surveyIds.map((surveyId) => {
|
||||
const survey = surveysById.get(Number(surveyId));
|
||||
if (!survey) {
|
||||
return `<tr><td class="current-plans-id">#${escapeHtml(surveyId)}</td><td colspan="4">Съёмка не найдена в текущем списке режимов</td></tr>`;
|
||||
}
|
||||
|
||||
const booked = Array.isArray(survey.bookedSlotIds) && survey.bookedSlotIds.length
|
||||
? survey.bookedSlotIds.map((slotId) => {
|
||||
const slot = state.bookedSlotsById[slotId];
|
||||
return slot ? `#${slot.bookedSlotId} slot ${slot.slotNum}/cycle ${slot.cycle}` : `#${slotId}`;
|
||||
}).join(', ')
|
||||
: '—';
|
||||
return `
|
||||
<tr>
|
||||
<td class="current-plans-id">#${escapeHtml(survey.id)}</td>
|
||||
<td>${formatDateTime(survey.timeStart)}</td>
|
||||
<td>${escapeHtml(survey.revolution ?? '—')}</td>
|
||||
<td>${escapeHtml(survey.source || '—')}</td>
|
||||
<td>${escapeHtml(booked)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="current-plans-detail-title">Сбрасываемые съёмки (${surveyIds.length})</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0 current-plans-detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Начало</th>
|
||||
<th>Виток</th>
|
||||
<th>Источник</th>
|
||||
<th>Booked slots</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function modeParams(mode) {
|
||||
if (mode.type === 'DROP') {
|
||||
const surveys = Array.isArray(mode.surveys) ? mode.surveys.length : 0;
|
||||
@@ -276,7 +404,7 @@
|
||||
if (mode.type === 'DROP') {
|
||||
return '<span class="current-plans-badge current-plans-badge-drop">DROP</span>';
|
||||
}
|
||||
const sourceBadgeClass = mode.source === 'SLOTS' ? ' current-plans-badge-slots' : '';
|
||||
const sourceBadgeClass = isSlotsLikeSource(mode.source) ? ' current-plans-badge-slots' : '';
|
||||
return `
|
||||
<div><span class="current-plans-badge">${escapeHtml(mode.status || '—')}</span></div>
|
||||
<div><span class="current-plans-badge${sourceBadgeClass}">${escapeHtml(mode.source || '—')}</span></div>`;
|
||||
@@ -379,6 +507,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmMission(missionId, button) {
|
||||
setButtonLoading(button, true, 'Утверждение...');
|
||||
try {
|
||||
await fetchJson(`/api/current-plans/missions/${encodeURIComponent(missionId)}/confirm`, {
|
||||
method: 'POST',
|
||||
headers: {'Accept': 'application/json'}
|
||||
});
|
||||
showAlert('План утверждён.', 'success');
|
||||
if (state.selectedSatelliteId) {
|
||||
await loadMissions(state.selectedSatelliteId, state.missionsPage, missionId);
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert(error.message || 'Не удалось утвердить план.', 'danger');
|
||||
} finally {
|
||||
setButtonLoading(button, false);
|
||||
}
|
||||
}
|
||||
|
||||
function exportSelectedMissionCsv() {
|
||||
if (!state.selectedMissionId) {
|
||||
showAlert('Выберите миссию для сохранения CSV.', 'warning');
|
||||
@@ -410,7 +556,7 @@
|
||||
mode.type || '',
|
||||
mode.id ?? '',
|
||||
mode.planId ?? '',
|
||||
mode.timeStart || '',
|
||||
formatDateTime(mode.timeStart),
|
||||
mode.revolution ?? '',
|
||||
mode.duration ?? '',
|
||||
mode.status || '',
|
||||
@@ -469,6 +615,7 @@
|
||||
el('current-plans-modes-body').innerHTML = '';
|
||||
el('current-plans-selected-mission').textContent = 'Выберите миссию в верхней таблице.';
|
||||
state.currentModes = [];
|
||||
state.bookedSlotsById = {};
|
||||
setExportEnabled(false);
|
||||
}
|
||||
|
||||
@@ -570,6 +717,12 @@
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '—';
|
||||
const match = String(value).match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?/);
|
||||
if (match) {
|
||||
const [, year, month, day, hour, minute, second] = match;
|
||||
return `${day}.${month}.${year.slice(2)}, ${hour}:${minute}:${second || '00'}`;
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return escapeHtml(value);
|
||||
return date.toLocaleString('ru-RU', {
|
||||
@@ -652,4 +805,11 @@
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function cssEscape(value) {
|
||||
if (window.CSS && typeof window.CSS.escape === 'function') {
|
||||
return window.CSS.escape(String(value ?? ''));
|
||||
}
|
||||
return String(value ?? '').replaceAll('"', '\\"');
|
||||
}
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user