Доработка отображения текущего плана
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.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)
|
||||||
}
|
}
|
||||||
|
|||||||
+9
@@ -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()
|
||||||
|
|||||||
+14
@@ -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('"', '"')
|
.replaceAll('"', '"')
|
||||||
.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