mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-09 08:47:11 -05:00
* fix: move foreman to global gems to fix startup crash (#1971) * Update exporting code to stream points data to file in batches to red… (#1980) * Update exporting code to stream points data to file in batches to reduce memory usage * Update changelog * Update changelog * Feature/maplibre frontend (#1953) * Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet * Implement phase 1 * Phases 1-3 + part of 4 * Fix e2e tests * Phase 6 * Implement fog of war * Phase 7 * Next step: fix specs, phase 7 done * Use our own map tiles * Extract v2 map logic to separate manager classes * Update settings panel on v2 map * Update v2 e2e tests structure * Reimplement location search in maps v2 * Update speed routes * Implement visits and places creation in v2 * Fix last failing test * Implement visits merging * Fix a routes e2e test and simplify the routes layer styling. * Extract js to modules from maps_v2_controller.js * Implement area creation * Fix spec problem * Fix some e2e tests * Implement live mode in v2 map * Update icons and panel * Extract some styles * Remove unused file * Start adding dark theme to popups on MapLibre maps * Make popups respect dark theme * Move v2 maps to maplibre namespace * Update v2 references to maplibre * Put place, area and visit info into side panel * Update API to use safe settings config method * Fix specs * Fix method name to config in SafeSettings and update usages accordingly * Add missing public files * Add handling for real time points * Fix remembering enabled/disabled layers of the v2 map * Fix lots of e2e tests * Add settings to select map version * Use maps/v2 as main path for MapLibre maps * Update routing * Update live mode * Update maplibre controller * Update changelog * Remove some console.log statements --------- Co-authored-by: Robin Tuszik <mail@robin.gg>
540 lines
18 KiB
JavaScript
540 lines
18 KiB
JavaScript
import { SelectionLayer } from 'maps_maplibre/layers/selection_layer'
|
|
import { SelectedPointsLayer } from 'maps_maplibre/layers/selected_points_layer'
|
|
import { pointsToGeoJSON } from 'maps_maplibre/utils/geojson_transformers'
|
|
import { VisitCard } from 'maps_maplibre/components/visit_card'
|
|
import { Toast } from 'maps_maplibre/components/toast'
|
|
|
|
/**
|
|
* Manages area selection and bulk operations for Maps V2
|
|
* Handles selection mode, visit cards, and bulk actions (merge, confirm, decline)
|
|
*/
|
|
export class AreaSelectionManager {
|
|
constructor(controller) {
|
|
this.controller = controller
|
|
this.map = controller.map
|
|
this.api = controller.api
|
|
this.selectionLayer = null
|
|
this.selectedPointsLayer = null
|
|
this.selectedVisits = []
|
|
this.selectedVisitIds = new Set()
|
|
}
|
|
|
|
/**
|
|
* Start area selection mode
|
|
*/
|
|
async startSelectArea() {
|
|
console.log('[Maps V2] Starting area selection mode')
|
|
|
|
// Initialize selection layer if not exists
|
|
if (!this.selectionLayer) {
|
|
this.selectionLayer = new SelectionLayer(this.map, {
|
|
visible: true,
|
|
onSelectionComplete: this.handleAreaSelected.bind(this)
|
|
})
|
|
|
|
this.selectionLayer.add({
|
|
type: 'FeatureCollection',
|
|
features: []
|
|
})
|
|
|
|
console.log('[Maps V2] Selection layer initialized')
|
|
}
|
|
|
|
// Initialize selected points layer if not exists
|
|
if (!this.selectedPointsLayer) {
|
|
this.selectedPointsLayer = new SelectedPointsLayer(this.map, {
|
|
visible: true
|
|
})
|
|
|
|
this.selectedPointsLayer.add({
|
|
type: 'FeatureCollection',
|
|
features: []
|
|
})
|
|
|
|
console.log('[Maps V2] Selected points layer initialized')
|
|
}
|
|
|
|
// Enable selection mode
|
|
this.selectionLayer.enableSelectionMode()
|
|
|
|
// Update UI - replace Select Area button with Cancel Selection button
|
|
if (this.controller.hasSelectAreaButtonTarget) {
|
|
this.controller.selectAreaButtonTarget.innerHTML = `
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5">
|
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
</svg>
|
|
Cancel Selection
|
|
`
|
|
this.controller.selectAreaButtonTarget.dataset.action = 'click->maps--maplibre#cancelAreaSelection'
|
|
}
|
|
|
|
Toast.info('Draw a rectangle on the map to select points')
|
|
}
|
|
|
|
/**
|
|
* Handle area selection completion
|
|
*/
|
|
async handleAreaSelected(bounds) {
|
|
console.log('[Maps V2] Area selected:', bounds)
|
|
|
|
try {
|
|
Toast.info('Fetching data in selected area...')
|
|
|
|
const [points, visits] = await Promise.all([
|
|
this.api.fetchPointsInArea({
|
|
start_at: this.controller.startDateValue,
|
|
end_at: this.controller.endDateValue,
|
|
min_longitude: bounds.minLng,
|
|
max_longitude: bounds.maxLng,
|
|
min_latitude: bounds.minLat,
|
|
max_latitude: bounds.maxLat
|
|
}),
|
|
this.api.fetchVisitsInArea({
|
|
start_at: this.controller.startDateValue,
|
|
end_at: this.controller.endDateValue,
|
|
sw_lat: bounds.minLat,
|
|
sw_lng: bounds.minLng,
|
|
ne_lat: bounds.maxLat,
|
|
ne_lng: bounds.maxLng
|
|
})
|
|
])
|
|
|
|
console.log('[Maps V2] Found', points.length, 'points and', visits.length, 'visits in area')
|
|
|
|
if (points.length === 0 && visits.length === 0) {
|
|
Toast.info('No data found in selected area')
|
|
this.cancelAreaSelection()
|
|
return
|
|
}
|
|
|
|
// Convert points to GeoJSON and display
|
|
if (points.length > 0) {
|
|
const geojson = pointsToGeoJSON(points)
|
|
this.selectedPointsLayer.updateSelectedPoints(geojson)
|
|
this.selectedPointsLayer.show()
|
|
}
|
|
|
|
// Display visits in side panel and on map
|
|
if (visits.length > 0) {
|
|
this.displaySelectedVisits(visits)
|
|
}
|
|
|
|
// Update UI - show action buttons
|
|
if (this.controller.hasSelectionActionsTarget) {
|
|
this.controller.selectionActionsTarget.classList.remove('hidden')
|
|
}
|
|
|
|
// Update delete button text with count
|
|
if (this.controller.hasDeleteButtonTextTarget) {
|
|
this.controller.deleteButtonTextTarget.textContent = `Delete ${points.length} Point${points.length === 1 ? '' : 's'}`
|
|
}
|
|
|
|
// Disable selection mode
|
|
this.selectionLayer.disableSelectionMode()
|
|
|
|
const messages = []
|
|
if (points.length > 0) messages.push(`${points.length} point${points.length === 1 ? '' : 's'}`)
|
|
if (visits.length > 0) messages.push(`${visits.length} visit${visits.length === 1 ? '' : 's'}`)
|
|
|
|
Toast.success(`Selected ${messages.join(' and ')}`)
|
|
} catch (error) {
|
|
console.error('[Maps V2] Failed to fetch data in area:', error)
|
|
Toast.error('Failed to fetch data in selected area')
|
|
this.cancelAreaSelection()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Display selected visits in side panel
|
|
*/
|
|
displaySelectedVisits(visits) {
|
|
if (!this.controller.hasSelectedVisitsContainerTarget) return
|
|
|
|
this.selectedVisits = visits
|
|
this.selectedVisitIds = new Set()
|
|
|
|
const cardsHTML = visits.map(visit =>
|
|
VisitCard.create(visit, { isSelected: false })
|
|
).join('')
|
|
|
|
this.controller.selectedVisitsContainerTarget.innerHTML = `
|
|
<div class="selected-visits-list">
|
|
<div class="flex items-center gap-2 mb-3 pb-2 border-b border-base-300">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
</svg>
|
|
<h3 class="text-sm font-bold">Visits in Area (${visits.length})</h3>
|
|
</div>
|
|
${cardsHTML}
|
|
</div>
|
|
`
|
|
|
|
this.controller.selectedVisitsContainerTarget.classList.remove('hidden')
|
|
this.attachVisitCardListeners()
|
|
|
|
requestAnimationFrame(() => {
|
|
this.updateBulkActions()
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Attach event listeners to visit cards
|
|
*/
|
|
attachVisitCardListeners() {
|
|
this.controller.element.querySelectorAll('[data-visit-select]').forEach(checkbox => {
|
|
checkbox.addEventListener('change', (e) => {
|
|
const visitId = parseInt(e.target.dataset.visitSelect)
|
|
if (e.target.checked) {
|
|
this.selectedVisitIds.add(visitId)
|
|
} else {
|
|
this.selectedVisitIds.delete(visitId)
|
|
}
|
|
this.updateBulkActions()
|
|
})
|
|
})
|
|
|
|
this.controller.element.querySelectorAll('[data-visit-confirm]').forEach(btn => {
|
|
btn.addEventListener('click', async (e) => {
|
|
const visitId = parseInt(e.currentTarget.dataset.visitConfirm)
|
|
await this.confirmVisit(visitId)
|
|
})
|
|
})
|
|
|
|
this.controller.element.querySelectorAll('[data-visit-decline]').forEach(btn => {
|
|
btn.addEventListener('click', async (e) => {
|
|
const visitId = parseInt(e.currentTarget.dataset.visitDecline)
|
|
await this.declineVisit(visitId)
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Update bulk action buttons visibility and attach listeners
|
|
*/
|
|
updateBulkActions() {
|
|
const selectedCount = this.selectedVisitIds.size
|
|
|
|
const existingBulkActions = this.controller.element.querySelectorAll('.bulk-actions-inline')
|
|
existingBulkActions.forEach(el => el.remove())
|
|
|
|
if (selectedCount >= 2) {
|
|
const selectedVisitCards = Array.from(this.controller.element.querySelectorAll('.visit-card'))
|
|
.filter(card => {
|
|
const visitId = parseInt(card.dataset.visitId)
|
|
return this.selectedVisitIds.has(visitId)
|
|
})
|
|
|
|
if (selectedVisitCards.length > 0) {
|
|
const lastSelectedCard = selectedVisitCards[selectedVisitCards.length - 1]
|
|
|
|
const bulkActionsDiv = document.createElement('div')
|
|
bulkActionsDiv.className = 'bulk-actions-inline mb-2'
|
|
bulkActionsDiv.innerHTML = `
|
|
<div class="bg-primary/10 border-2 border-primary border-dashed rounded-lg p-3">
|
|
<div class="text-xs font-semibold mb-2 text-primary flex items-center gap-2">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
<span>${selectedCount} visit${selectedCount === 1 ? '' : 's'} selected</span>
|
|
</div>
|
|
<div class="grid grid-cols-3 gap-1.5">
|
|
<button class="btn btn-xs btn-outline normal-case" data-bulk-merge>
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
|
</svg>
|
|
Merge
|
|
</button>
|
|
<button class="btn btn-xs btn-primary normal-case" data-bulk-confirm>
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
Confirm
|
|
</button>
|
|
<button class="btn btn-xs btn-outline btn-error normal-case" data-bulk-decline>
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
Decline
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`
|
|
|
|
lastSelectedCard.insertAdjacentElement('afterend', bulkActionsDiv)
|
|
|
|
const mergeBtn = bulkActionsDiv.querySelector('[data-bulk-merge]')
|
|
const confirmBtn = bulkActionsDiv.querySelector('[data-bulk-confirm]')
|
|
const declineBtn = bulkActionsDiv.querySelector('[data-bulk-decline]')
|
|
|
|
if (mergeBtn) mergeBtn.addEventListener('click', () => this.bulkMergeVisits())
|
|
if (confirmBtn) confirmBtn.addEventListener('click', () => this.bulkConfirmVisits())
|
|
if (declineBtn) declineBtn.addEventListener('click', () => this.bulkDeclineVisits())
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Confirm a single visit
|
|
*/
|
|
async confirmVisit(visitId) {
|
|
try {
|
|
await this.api.updateVisitStatus(visitId, 'confirmed')
|
|
Toast.success('Visit confirmed')
|
|
await this.refreshSelectedVisits()
|
|
} catch (error) {
|
|
console.error('[Maps V2] Failed to confirm visit:', error)
|
|
Toast.error('Failed to confirm visit')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decline a single visit
|
|
*/
|
|
async declineVisit(visitId) {
|
|
try {
|
|
await this.api.updateVisitStatus(visitId, 'declined')
|
|
Toast.success('Visit declined')
|
|
await this.refreshSelectedVisits()
|
|
} catch (error) {
|
|
console.error('[Maps V2] Failed to decline visit:', error)
|
|
Toast.error('Failed to decline visit')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Bulk merge selected visits
|
|
*/
|
|
async bulkMergeVisits() {
|
|
const visitIds = Array.from(this.selectedVisitIds)
|
|
|
|
if (visitIds.length < 2) {
|
|
Toast.error('Select at least 2 visits to merge')
|
|
return
|
|
}
|
|
|
|
if (!confirm(`Merge ${visitIds.length} visits into one?`)) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
Toast.info('Merging visits...')
|
|
const mergedVisit = await this.api.mergeVisits(visitIds)
|
|
Toast.success('Visits merged successfully')
|
|
|
|
this.selectedVisitIds.clear()
|
|
this.replaceVisitsWithMerged(visitIds, mergedVisit)
|
|
this.updateBulkActions()
|
|
} catch (error) {
|
|
console.error('[Maps V2] Failed to merge visits:', error)
|
|
Toast.error('Failed to merge visits')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Bulk confirm selected visits
|
|
*/
|
|
async bulkConfirmVisits() {
|
|
const visitIds = Array.from(this.selectedVisitIds)
|
|
|
|
try {
|
|
Toast.info('Confirming visits...')
|
|
await this.api.bulkUpdateVisits(visitIds, 'confirmed')
|
|
Toast.success(`Confirmed ${visitIds.length} visits`)
|
|
|
|
this.selectedVisitIds.clear()
|
|
await this.refreshSelectedVisits()
|
|
} catch (error) {
|
|
console.error('[Maps V2] Failed to confirm visits:', error)
|
|
Toast.error('Failed to confirm visits')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Bulk decline selected visits
|
|
*/
|
|
async bulkDeclineVisits() {
|
|
const visitIds = Array.from(this.selectedVisitIds)
|
|
|
|
if (!confirm(`Decline ${visitIds.length} visits?`)) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
Toast.info('Declining visits...')
|
|
await this.api.bulkUpdateVisits(visitIds, 'declined')
|
|
Toast.success(`Declined ${visitIds.length} visits`)
|
|
|
|
this.selectedVisitIds.clear()
|
|
await this.refreshSelectedVisits()
|
|
} catch (error) {
|
|
console.error('[Maps V2] Failed to decline visits:', error)
|
|
Toast.error('Failed to decline visits')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Replace merged visit cards with the new merged visit
|
|
*/
|
|
replaceVisitsWithMerged(oldVisitIds, mergedVisit) {
|
|
const container = this.controller.element.querySelector('.selected-visits-list')
|
|
if (!container) return
|
|
|
|
const mergedStartTime = new Date(mergedVisit.started_at).getTime()
|
|
const allCards = Array.from(container.querySelectorAll('.visit-card'))
|
|
|
|
let insertBeforeCard = null
|
|
for (const card of allCards) {
|
|
const cardId = parseInt(card.dataset.visitId)
|
|
if (oldVisitIds.includes(cardId)) continue
|
|
|
|
const cardVisit = this.selectedVisits.find(v => v.id === cardId)
|
|
if (cardVisit) {
|
|
const cardStartTime = new Date(cardVisit.started_at).getTime()
|
|
if (cardStartTime > mergedStartTime) {
|
|
insertBeforeCard = card
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
oldVisitIds.forEach(id => {
|
|
const card = this.controller.element.querySelector(`.visit-card[data-visit-id="${id}"]`)
|
|
if (card) card.remove()
|
|
})
|
|
|
|
this.selectedVisits = this.selectedVisits.filter(v => !oldVisitIds.includes(v.id))
|
|
this.selectedVisits.push(mergedVisit)
|
|
this.selectedVisits.sort((a, b) => new Date(a.started_at) - new Date(b.started_at))
|
|
|
|
const newCardHTML = VisitCard.create(mergedVisit, { isSelected: false })
|
|
|
|
if (insertBeforeCard) {
|
|
insertBeforeCard.insertAdjacentHTML('beforebegin', newCardHTML)
|
|
} else {
|
|
container.insertAdjacentHTML('beforeend', newCardHTML)
|
|
}
|
|
|
|
const header = container.querySelector('h3')
|
|
if (header) {
|
|
header.textContent = `Visits in Area (${this.selectedVisits.length})`
|
|
}
|
|
|
|
this.attachVisitCardListeners()
|
|
}
|
|
|
|
/**
|
|
* Refresh selected visits after changes
|
|
*/
|
|
async refreshSelectedVisits() {
|
|
const bounds = this.selectionLayer.currentRect
|
|
if (!bounds) return
|
|
|
|
try {
|
|
const visits = await this.api.fetchVisitsInArea({
|
|
start_at: this.controller.startDateValue,
|
|
end_at: this.controller.endDateValue,
|
|
sw_lat: bounds.start.lat < bounds.end.lat ? bounds.start.lat : bounds.end.lat,
|
|
sw_lng: bounds.start.lng < bounds.end.lng ? bounds.start.lng : bounds.end.lng,
|
|
ne_lat: bounds.start.lat > bounds.end.lat ? bounds.start.lat : bounds.end.lat,
|
|
ne_lng: bounds.start.lng > bounds.end.lng ? bounds.start.lng : bounds.end.lng
|
|
})
|
|
|
|
this.displaySelectedVisits(visits)
|
|
} catch (error) {
|
|
console.error('[Maps V2] Failed to refresh visits:', error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancel area selection
|
|
*/
|
|
cancelAreaSelection() {
|
|
console.log('[Maps V2] Cancelling area selection')
|
|
|
|
if (this.selectionLayer) {
|
|
this.selectionLayer.disableSelectionMode()
|
|
this.selectionLayer.clearSelection()
|
|
}
|
|
|
|
if (this.selectedPointsLayer) {
|
|
this.selectedPointsLayer.clearSelection()
|
|
}
|
|
|
|
if (this.controller.hasSelectedVisitsContainerTarget) {
|
|
this.controller.selectedVisitsContainerTarget.classList.add('hidden')
|
|
this.controller.selectedVisitsContainerTarget.innerHTML = ''
|
|
}
|
|
|
|
if (this.controller.hasSelectedVisitsBulkActionsTarget) {
|
|
this.controller.selectedVisitsBulkActionsTarget.classList.add('hidden')
|
|
}
|
|
|
|
this.selectedVisits = []
|
|
this.selectedVisitIds = new Set()
|
|
|
|
if (this.controller.hasSelectAreaButtonTarget) {
|
|
this.controller.selectAreaButtonTarget.innerHTML = `
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5">
|
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
|
<path d="M9 3v18"></path>
|
|
<path d="M15 3v18"></path>
|
|
<path d="M3 9h18"></path>
|
|
<path d="M3 15h18"></path>
|
|
</svg>
|
|
Select Area
|
|
`
|
|
this.controller.selectAreaButtonTarget.classList.remove('btn-error')
|
|
this.controller.selectAreaButtonTarget.classList.add('btn', 'btn-outline')
|
|
this.controller.selectAreaButtonTarget.dataset.action = 'click->maps--maplibre#startSelectArea'
|
|
}
|
|
|
|
if (this.controller.hasSelectionActionsTarget) {
|
|
this.controller.selectionActionsTarget.classList.add('hidden')
|
|
}
|
|
|
|
Toast.info('Selection cancelled')
|
|
}
|
|
|
|
/**
|
|
* Delete selected points
|
|
*/
|
|
async deleteSelectedPoints() {
|
|
const pointCount = this.selectedPointsLayer.getCount()
|
|
const pointIds = this.selectedPointsLayer.getSelectedPointIds()
|
|
|
|
if (pointIds.length === 0) {
|
|
Toast.error('No points selected')
|
|
return
|
|
}
|
|
|
|
const confirmed = confirm(
|
|
`Are you sure you want to delete ${pointCount} point${pointCount === 1 ? '' : 's'}? This action cannot be undone.`
|
|
)
|
|
|
|
if (!confirmed) return
|
|
|
|
console.log('[Maps V2] Deleting', pointIds.length, 'points')
|
|
|
|
try {
|
|
Toast.info('Deleting points...')
|
|
const result = await this.api.bulkDeletePoints(pointIds)
|
|
|
|
console.log('[Maps V2] Deleted', result.count, 'points')
|
|
|
|
this.cancelAreaSelection()
|
|
|
|
await this.controller.loadMapData({
|
|
showLoading: false,
|
|
fitBounds: false,
|
|
showToast: false
|
|
})
|
|
|
|
Toast.success(`Deleted ${result.count} point${result.count === 1 ? '' : 's'}`)
|
|
} catch (error) {
|
|
console.error('[Maps V2] Failed to delete points:', error)
|
|
Toast.error('Failed to delete points. Please try again.')
|
|
}
|
|
}
|
|
}
|