Доработка отображения текущего плана

This commit is contained in:
emelianov
2026-05-25 22:02:17 +03:00
parent d4682fdeb7
commit 1229995f90
4 changed files with 200 additions and 9 deletions
@@ -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.pcp_types_lib.dto.satellite.mission.MissionRequestDTO
import space.nstart.pcp.slots_service.service.MissionService import space.nstart.pcp.slots_service.service.MissionService
import space.nstart.pcp.slots_service.service.SatelliteCatalogService import space.nstart.pcp.slots_service.service.SatelliteCatalogService
import space.nstart.pcp.slots_service.service.SlotService
import java.util.UUID import java.util.UUID
@RestController @RestController
@RequestMapping("/api/current-plans") @RequestMapping("/api/current-plans")
class CurrentPlansController( class CurrentPlansController(
private val satelliteCatalogService: SatelliteCatalogService, private val satelliteCatalogService: SatelliteCatalogService,
private val missionService: MissionService private val missionService: MissionService,
private val slotService: SlotService
) { ) {
@GetMapping("/satellites") @GetMapping("/satellites")
@@ -36,6 +38,9 @@ class CurrentPlansController(
@GetMapping("/missions/{missionId}/modes") @GetMapping("/missions/{missionId}/modes")
fun modes(@PathVariable missionId: UUID) = missionService.modesByMission(missionId) 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") @PostMapping("/missions/{missionId}/surveys/calculate")
fun calculateSurveys( fun calculateSurveys(
@PathVariable missionId: UUID, @PathVariable missionId: UUID,
@@ -44,4 +49,7 @@ class CurrentPlansController(
@PostMapping("/missions/{missionId}/drops/calculate") @PostMapping("/missions/{missionId}/drops/calculate")
fun calculateDrops(@PathVariable missionId: UUID) = missionService.calculateDrops(missionId) fun calculateDrops(@PathVariable missionId: UUID) = missionService.calculateDrops(missionId)
@PostMapping("/missions/{missionId}/confirm")
fun confirmMission(@PathVariable missionId: UUID) = missionService.confirmMission(missionId)
} }
@@ -103,6 +103,15 @@ class MissionService(webClientBuilderProvider: ObjectProvider<WebClient.Builder>
.block() .block()
} }
fun confirmMission(missionId: UUID) {
webClientBuilder.build()
.post()
.uri("$url/api/missions/$missionId/confirm")
.retrieve()
.toBodilessEntity()
.block()
}
fun modesByMission(missionId: UUID): List<ModeResponseDTO> = fun modesByMission(missionId: UUID): List<ModeResponseDTO> =
webClientBuilder.build() webClientBuilder.build()
.get() .get()
@@ -165,6 +165,20 @@ class SlotService(webClientBuilderProvider: ObjectProvider<WebClient.Builder>) {
.block() .block()
?: emptyList() ?: 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( fun searchBooked(
timeStart: LocalDateTime?, timeStart: LocalDateTime?,
@@ -4,6 +4,7 @@
filteredSatellites: [], filteredSatellites: [],
missions: [], missions: [],
currentModes: [], currentModes: [],
bookedSlotsById: {},
selectedSatelliteId: null, selectedSatelliteId: null,
selectedMissionId: null, selectedMissionId: null,
selectedMission: null, selectedMission: null,
@@ -168,6 +169,10 @@
class="btn btn-outline-success btn-sm current-plans-action-btn" class="btn btn-outline-success btn-sm current-plans-action-btn"
data-action="calculate-drops" data-action="calculate-drops"
data-mission-id="${escapeHtml(mission.missionId)}">Расчёт сбросов</button> 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> </td>
</tr>`; </tr>`;
}).join(''); }).join('');
@@ -184,6 +189,8 @@
calculateSurveys(missionId, button); calculateSurveys(missionId, button);
} else if (button.dataset.action === 'calculate-drops') { } else if (button.dataset.action === 'calculate-drops') {
calculateDrops(missionId, button); calculateDrops(missionId, button);
} else if (button.dataset.action === 'confirm-mission') {
confirmMission(missionId, button);
} }
}); });
}); });
@@ -208,6 +215,7 @@
try { try {
const modes = await fetchJson(`/api/current-plans/missions/${encodeURIComponent(missionId)}/modes`); const modes = await fetchJson(`/api/current-plans/missions/${encodeURIComponent(missionId)}/modes`);
state.currentModes = Array.isArray(modes) ? modes : []; state.currentModes = Array.isArray(modes) ? modes : [];
state.bookedSlotsById = await loadBookedSlotsForModes(state.currentModes);
renderModes(state.currentModes); renderModes(state.currentModes);
setExportEnabled(true); setExportEnabled(true);
return state.currentModes; 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) { function renderModes(modes) {
const tbody = el('current-plans-modes-body'); const tbody = el('current-plans-modes-body');
if (!tbody) return; if (!tbody) return;
@@ -227,35 +257,133 @@
return; return;
} }
tbody.innerHTML = modes.map((mode) => { tbody.innerHTML = modes.flatMap((mode) => {
const type = mode.type || '—'; const type = mode.type || '—';
const duration = formatNumber(mode.duration); const duration = formatNumber(mode.duration);
const rowClass = modeRowClass(mode); const rowClass = modeRowClass(mode);
const badgeClass = modeBadgeClass(mode); const badgeClass = modeBadgeClass(mode);
return ` const expandable = isExpandableMode(mode);
<tr class="${rowClass}"> const expandMarker = expandable ? '<span class="current-plans-expand-marker">▸</span>' : '';
<td><span class="current-plans-badge ${badgeClass}">${escapeHtml(type)}</span></td> 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>${formatDateTime(mode.timeStart)}</td>
<td>${escapeHtml(mode.revolution ?? '—')}</td> <td>${escapeHtml(mode.revolution ?? '—')}</td>
<td>${duration}</td> <td>${duration}</td>
<td class="current-plans-mode-params">${modeParams(mode)}</td> <td class="current-plans-mode-params">${modeParams(mode)}</td>
<td>${modeStatus(mode)}</td> <td>${modeStatus(mode)}</td>
</tr>`; </tr>`;
const details = expandable ? modeDetailsRow(mode) : null;
return details ? [row, details] : [row];
}).join(''); }).join('');
tbody.querySelectorAll('.current-plans-expandable-row').forEach((row) => {
row.addEventListener('click', () => toggleModeDetails(row.dataset.modeId));
});
} }
function modeRowClass(mode) { function modeRowClass(mode) {
if (mode.type === 'DROP') return 'current-plans-mode-row-drop'; 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 ''; return '';
} }
function modeBadgeClass(mode) { function modeBadgeClass(mode) {
if (mode.type === 'DROP') return 'current-plans-badge-drop'; 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 ''; 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) { function modeParams(mode) {
if (mode.type === 'DROP') { if (mode.type === 'DROP') {
const surveys = Array.isArray(mode.surveys) ? mode.surveys.length : 0; const surveys = Array.isArray(mode.surveys) ? mode.surveys.length : 0;
@@ -276,7 +404,7 @@
if (mode.type === 'DROP') { if (mode.type === 'DROP') {
return '<span class="current-plans-badge current-plans-badge-drop">DROP</span>'; 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 ` return `
<div><span class="current-plans-badge">${escapeHtml(mode.status || '—')}</span></div> <div><span class="current-plans-badge">${escapeHtml(mode.status || '—')}</span></div>
<div><span class="current-plans-badge${sourceBadgeClass}">${escapeHtml(mode.source || '—')}</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() { function exportSelectedMissionCsv() {
if (!state.selectedMissionId) { if (!state.selectedMissionId) {
showAlert('Выберите миссию для сохранения CSV.', 'warning'); showAlert('Выберите миссию для сохранения CSV.', 'warning');
@@ -410,7 +556,7 @@
mode.type || '', mode.type || '',
mode.id ?? '', mode.id ?? '',
mode.planId ?? '', mode.planId ?? '',
mode.timeStart || '', formatDateTime(mode.timeStart),
mode.revolution ?? '', mode.revolution ?? '',
mode.duration ?? '', mode.duration ?? '',
mode.status || '', mode.status || '',
@@ -469,6 +615,7 @@
el('current-plans-modes-body').innerHTML = ''; el('current-plans-modes-body').innerHTML = '';
el('current-plans-selected-mission').textContent = 'Выберите миссию в верхней таблице.'; el('current-plans-selected-mission').textContent = 'Выберите миссию в верхней таблице.';
state.currentModes = []; state.currentModes = [];
state.bookedSlotsById = {};
setExportEnabled(false); setExportEnabled(false);
} }
@@ -570,6 +717,12 @@
function formatDateTime(value) { function formatDateTime(value) {
if (!value) return '—'; 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); const date = new Date(value);
if (Number.isNaN(date.getTime())) return escapeHtml(value); if (Number.isNaN(date.getTime())) return escapeHtml(value);
return date.toLocaleString('ru-RU', { return date.toLocaleString('ru-RU', {
@@ -652,4 +805,11 @@
.replaceAll('"', '&quot;') .replaceAll('"', '&quot;')
.replaceAll("'", '&#039;'); .replaceAll("'", '&#039;');
} }
function cssEscape(value) {
if (window.CSS && typeof window.CSS.escape === 'function') {
return window.CSS.escape(String(value ?? ''));
}
return String(value ?? '').replaceAll('"', '\\"');
}
})(); })();