diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CurrentPlansController.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CurrentPlansController.kt index dc8a956..5ac91f3 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CurrentPlansController.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CurrentPlansController.kt @@ -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) = 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) } diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/MissionService.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/MissionService.kt index b6802de..f3fa13a 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/MissionService.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/MissionService.kt @@ -103,6 +103,15 @@ class MissionService(webClientBuilderProvider: ObjectProvider .block() } + fun confirmMission(missionId: UUID) { + webClientBuilder.build() + .post() + .uri("$url/api/missions/$missionId/confirm") + .retrieve() + .toBodilessEntity() + .block() + } + fun modesByMission(missionId: UUID): List = webClientBuilder.build() .get() diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt index 996c79d..8e2360b 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt @@ -165,6 +165,20 @@ class SlotService(webClientBuilderProvider: ObjectProvider) { .block() ?: emptyList() + fun bookedDetailsByIds(bookedSlotIds: List): List { + 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?, diff --git a/services/pcp-ui-service/src/main/resources/static/current_plans_scripts.js b/services/pcp-ui-service/src/main/resources/static/current_plans_scripts.js index 29138a6..7ee0e33 100644 --- a/services/pcp-ui-service/src/main/resources/static/current_plans_scripts.js +++ b/services/pcp-ui-service/src/main/resources/static/current_plans_scripts.js @@ -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)}">Расчёт сбросов + `; }).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 ` - - ${escapeHtml(type)} + const expandable = isExpandableMode(mode); + const expandMarker = expandable ? '' : ''; + const row = ` + + ${expandMarker}${escapeHtml(type)} ${formatDateTime(mode.timeStart)} ${escapeHtml(mode.revolution ?? '—')} ${duration} ${modeParams(mode)} ${modeStatus(mode)} `; + 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 ` + + +
${content}
+ + `; + } + + function bookedSlotDetails(mode) { + const slotIds = Array.isArray(mode.bookedSlotIds) ? mode.bookedSlotIds : []; + const slots = slotIds.map((slotId) => { + const slot = state.bookedSlotsById[slotId]; + if (!slot) { + return `#${escapeHtml(slotId)}`; + } + return `#${escapeHtml(slot.bookedSlotId)} · slot ${escapeHtml(slot.slotNum)} · cycle ${escapeHtml(slot.cycle)} · sat ${escapeHtml(slot.satelliteId)}`; + }).join(''); + return ` +
Забронированные слоты (${slotIds.length})
+
${slots}
`; + } + + 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 `#${escapeHtml(surveyId)}Съёмка не найдена в текущем списке режимов`; + } + + 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 ` + + #${escapeHtml(survey.id)} + ${formatDateTime(survey.timeStart)} + ${escapeHtml(survey.revolution ?? '—')} + ${escapeHtml(survey.source || '—')} + ${escapeHtml(booked)} + `; + }).join(''); + + return ` +
Сбрасываемые съёмки (${surveyIds.length})
+
+ + + + + + + + + + + ${rows} +
IDНачалоВитокИсточникBooked slots
+
`; + } + 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 'DROP'; } - const sourceBadgeClass = mode.source === 'SLOTS' ? ' current-plans-badge-slots' : ''; + const sourceBadgeClass = isSlotsLikeSource(mode.source) ? ' current-plans-badge-slots' : ''; return `
${escapeHtml(mode.status || '—')}
${escapeHtml(mode.source || '—')}
`; @@ -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('"', '\\"'); + } })();