import $ from 'jquery'; import axios from 'axios'; const CELL_SIZE = 80; const CELL_GAP = 16; const GRID_UNIT = CELL_SIZE + CELL_GAP; const MIN_ZOOM = 0.05; const MAX_ZOOM = 3; const ZOOM_FACTOR = 0.1; const SCROLL_SENSITIVITY = 0.6; function parseData(elementId, fallback) { const el = document.getElementById(elementId); if (!el) { return fallback; } try { return JSON.parse(el.textContent ?? 'null') ?? fallback; } catch (error) { console.error(`Failed to parse payload for ${elementId}`, error); return fallback; } } function computeContentDimensions(rows) { const maxX = Math.max(0, ...rows.map((row) => row.location.x)); const maxY = Math.max(0, ...rows.map((row) => row.location.y)); const width = Math.max(1, maxX + 1) * (CELL_SIZE + CELL_GAP); const height = Math.max(1, maxY + 1) * (CELL_SIZE + CELL_GAP); return { width, height }; } function applyStatusClass(element, status) { const targetStatus = status ?? 'unknown'; element.classList.forEach((name) => { if (name.startsWith('status-')) { element.classList.remove(name); } }); element.classList.add(`status-${targetStatus}`); } function createCell(row) { const cell = document.createElement('div'); cell.classList.add('map-cell'); cell.dataset.rowId = String(row.id); cell.style.left = `${row.location.x * (CELL_SIZE + CELL_GAP)}px`; cell.style.top = `${row.location.y * (CELL_SIZE + CELL_GAP)}px`; cell.style.width = `${CELL_SIZE}px`; cell.style.height = `${CELL_SIZE}px`; applyStatusClass(cell, row.status); const countBadge = document.createElement('div'); countBadge.classList.add('map-cell-count'); countBadge.dataset.role = 'vine-count'; countBadge.textContent = String(row.vine_count ?? 0); const varietyLabel = document.createElement('div'); varietyLabel.classList.add('map-cell-variety'); varietyLabel.dataset.role = 'variety-label'; varietyLabel.textContent = row.variety?.label ?? 'Unassigned'; cell.appendChild(countBadge); cell.appendChild(varietyLabel); return cell; } export default function initMapEdit() { const $root = $('[data-page="vineyard-map-edit"]'); if (!$root.length) { return; } const rows = parseData('vineyard-edit-rows', []); const routes = parseData('vineyard-edit-routes', {}); const mapViewport = $root.find('[data-role="map-container"]')[0]; if (!mapViewport) { return; } let currentRows = [...rows]; const rowElements = new Map(); const locationIndex = new Map(); const coordinateKey = (x, y) => `${x}:${y}`; const parseCoordinateKey = (key) => { const [x, y] = key.split(':').map((value) => parseInt(value, 10)); return { x, y }; }; let selectedRow = null; let relocationTarget = null; let currentMode = 'edit'; const removeSelection = new Set(); const addSelection = new Set(); const addMarkers = new Map(); let isSelectingArea = false; let selectionBox = null; let selectionStartPoint = null; let selectionMerge = false; let selectionMode = null; let selectionMoved = false; let suppressNextClick = false; mapViewport.innerHTML = ''; const mapContent = document.createElement('div'); mapContent.classList.add('vineyard-map-grid'); mapViewport.appendChild(mapContent); const selectionLayer = document.createElement('div'); selectionLayer.classList.add('map-selection-layer'); mapViewport.appendChild(selectionLayer); const pendingMarker = document.createElement('div'); pendingMarker.classList.add('pending-cell'); pendingMarker.style.width = `${CELL_SIZE}px`; pendingMarker.style.height = `${CELL_SIZE}px`; mapContent.appendChild(pendingMarker); let contentDimensions = computeContentDimensions(currentRows); const viewportState = { scale: 1, translateX: 0, translateY: 0, }; let viewportAdjusted = false; const markViewportAdjusted = () => { viewportAdjusted = true; }; const clampTransform = () => { const viewportWidth = mapViewport.clientWidth; const viewportHeight = mapViewport.clientHeight; const scaledWidth = contentDimensions.width * viewportState.scale; const scaledHeight = contentDimensions.height * viewportState.scale; if (scaledWidth <= viewportWidth) { viewportState.translateX = (viewportWidth - scaledWidth) / 2; } else { const minX = viewportWidth - scaledWidth; viewportState.translateX = Math.min(0, Math.max(minX, viewportState.translateX)); } if (scaledHeight <= viewportHeight) { viewportState.translateY = (viewportHeight - scaledHeight) / 2; } else { const minY = viewportHeight - scaledHeight; viewportState.translateY = Math.min(0, Math.max(minY, viewportState.translateY)); } }; const applyTransform = () => { clampTransform(); mapContent.style.transform = `translate(${viewportState.translateX}px, ${viewportState.translateY}px) scale(${viewportState.scale})`; }; const fitContentToViewport = ({ force = false } = {}) => { if (viewportAdjusted && !force) { applyTransform(); return; } if (!contentDimensions.width || !contentDimensions.height) { viewportState.scale = 1; viewportState.translateX = 0; viewportState.translateY = 0; applyTransform(); return; } const viewportWidth = mapViewport.clientWidth || 1; const viewportHeight = mapViewport.clientHeight || 1; const scaleX = viewportWidth / contentDimensions.width; const scaleY = viewportHeight / contentDimensions.height; const fittedScale = Math.min(1, Math.min(scaleX, scaleY)); const scale = Math.min(MAX_ZOOM, Math.max(fittedScale, Math.min(MIN_ZOOM, fittedScale))); viewportState.scale = scale; viewportState.translateX = (viewportWidth - contentDimensions.width * viewportState.scale) / 2; viewportState.translateY = (viewportHeight - contentDimensions.height * viewportState.scale) / 2; applyTransform(); }; const mapPointFromViewport = (x, y) => ({ x: (x - viewportState.translateX) / viewportState.scale, y: (y - viewportState.translateY) / viewportState.scale, }); const mapPointFromEvent = (event) => { const rect = mapViewport.getBoundingClientRect(); const viewportX = event.clientX - rect.left; const viewportY = event.clientY - rect.top; return { viewport: { x: viewportX, y: viewportY, width: rect.width, height: rect.height }, map: mapPointFromViewport(viewportX, viewportY), }; }; const mapRectFromClientRect = (rect) => { const viewportRect = mapViewport.getBoundingClientRect(); const left = rect.left - viewportRect.left; const top = rect.top - viewportRect.top; const right = rect.right - viewportRect.left; const bottom = rect.bottom - viewportRect.top; const topLeft = mapPointFromViewport(left, top); const bottomRight = mapPointFromViewport(right, bottom); return { left: Math.min(topLeft.x, bottomRight.x), top: Math.min(topLeft.y, bottomRight.y), right: Math.max(topLeft.x, bottomRight.x), bottom: Math.max(topLeft.y, bottomRight.y), }; }; const updateContentSize = ({ preferFit = false } = {}) => { contentDimensions = computeContentDimensions(currentRows); mapContent.style.width = `${contentDimensions.width}px`; mapContent.style.height = `${contentDimensions.height}px`; if (!viewportAdjusted || preferFit) { fitContentToViewport({ force: preferFit }); } else { applyTransform(); } }; const mountRow = (row) => { const cell = createCell(row); mapContent.appendChild(cell); rowElements.set(Number(row.id), cell); locationIndex.set(coordinateKey(row.location.x, row.location.y), Number(row.id)); }; currentRows.forEach(mountRow); updateContentSize({ preferFit: true }); const refreshCellStates = () => { rowElements.forEach((element, rowId) => { const isSelected = currentMode === 'edit' && selectedRow && Number(selectedRow.id) === rowId; const isMarkedForRemoval = removeSelection.has(rowId) && currentMode === 'remove'; element.classList.toggle('selected', Boolean(isSelected)); element.classList.toggle('remove-selected', Boolean(isMarkedForRemoval)); }); }; const $rowForm = $root.find('[data-role="row-form"]'); const $deleteButton = $rowForm.find('[data-control="delete-row"]'); const $saveRowButton = $rowForm.find('button[type="submit"]'); const $relocationTarget = $root.find('[data-role="relocation-target"]'); const $removeCommit = $root.find('[data-control="commit-remove-selection"]'); const $removeCount = $root.find('[data-role="remove-selection-count"]'); const $removeClear = $root.find('[data-control="clear-remove-selection"]'); const $addCommit = $root.find('[data-control="commit-add-selection"]'); const $addClear = $root.find('[data-control="clear-add-selection"]'); const $addCount = $root.find('[data-role="add-selection-count"]'); const $addVineCount = $root.find('[name="add_vine_count"]'); const $clearRelocation = $root.find('[data-control="clear-relocation"]'); const $modeSwitch = $root.find('[data-role="mode-switch"]'); const $modeButtons = $modeSwitch.find('[data-mode]'); const $modePanels = $root.find('[data-mode-panel]'); const $feedback = $root.find('[data-role="map-feedback"]'); const setFeedback = (message, tone = 'info') => { if (!$feedback.length) { return; } $feedback.removeClass('feedback-info feedback-success feedback-error'); if (!message) { $feedback.text(''); return; } $feedback.addClass(`feedback-${tone}`); $feedback.text(message); }; const updateActionAvailability = () => { const removeActive = currentMode === 'remove'; const addActive = currentMode === 'add'; $clearRelocation.prop('disabled', !relocationTarget); $removeCommit.prop('disabled', !(removeActive && removeSelection.size > 0)); $removeClear.prop('disabled', removeSelection.size === 0); $addCommit.prop('disabled', !(addActive && addSelection.size > 0)); $addClear.prop('disabled', addSelection.size === 0); if (currentMode !== 'edit') { $saveRowButton.prop('disabled', true); $deleteButton.prop('disabled', true); } }; const updatePendingMarker = () => { const target = currentMode === 'edit' ? relocationTarget : null; if (target) { pendingMarker.style.left = `${target.x * GRID_UNIT}px`; pendingMarker.style.top = `${target.y * GRID_UNIT}px`; pendingMarker.style.display = 'block'; } else { pendingMarker.style.display = 'none'; } }; const populateRowForm = (row) => { $rowForm.find('[name="row_id"]').val(row?.id ?? ''); $rowForm.find('[name="location"]').val(row ? `${row.location.x},${row.location.y}` : ''); $rowForm.find('[name="vine_count"]').val(row?.vine_count ?? ''); $deleteButton.prop('disabled', !row); $saveRowButton.prop('disabled', !row); updateActionAvailability(); }; const stageRelocation = (coords) => { relocationTarget = coords; if (coords) { $relocationTarget.text(`${coords.x},${coords.y}`); } else { $relocationTarget.text('None'); } updatePendingMarker(); updateActionAvailability(); }; const updateAddUi = () => { $addCount.text(String(addSelection.size)); updateActionAvailability(); }; const ensureAddMarker = (coords) => { const key = coordinateKey(coords.x, coords.y); if (addMarkers.has(key)) { return addMarkers.get(key); } const marker = document.createElement('div'); marker.classList.add('add-marker'); marker.style.left = `${coords.x * GRID_UNIT}px`; marker.style.top = `${coords.y * GRID_UNIT}px`; marker.style.width = `${CELL_SIZE}px`; marker.style.height = `${CELL_SIZE}px`; mapContent.appendChild(marker); addMarkers.set(key, marker); return marker; }; const stageAddTarget = (coords) => { const key = coordinateKey(coords.x, coords.y); if (addSelection.has(key) || locationIndex.has(key)) { return; } addSelection.add(key); ensureAddMarker(coords); }; const removeAddTargetByKey = (key) => { if (!addSelection.has(key)) { return; } addSelection.delete(key); const marker = addMarkers.get(key); if (marker) { marker.remove(); addMarkers.delete(key); } }; const clearAddTargets = ({ silent = false } = {}) => { if (addSelection.size === 0) { if (!silent) { updateAddUi(); } return; } addSelection.forEach((key) => { const marker = addMarkers.get(key); if (marker) { marker.remove(); } }); addSelection.clear(); addMarkers.clear(); if (!silent) { updateAddUi(); } }; const cancelAreaSelection = () => { if (selectionBox) { selectionBox.remove(); } selectionBox = null; selectionStartPoint = null; isSelectingArea = false; selectionMerge = false; selectionMode = null; selectionMoved = false; }; const beginAreaSelection = (event) => { if (event.button !== 0) { return; } if (!(currentMode === 'remove' || currentMode === 'add')) { return; } if (event.target.closest('[data-row-id]')) { return; } cancelAreaSelection(); isSelectingArea = true; selectionMode = currentMode; selectionMerge = event.shiftKey; selectionMoved = false; selectionBox = document.createElement('div'); selectionBox.classList.add('selection-box'); selectionLayer.appendChild(selectionBox); const rect = mapViewport.getBoundingClientRect(); const startX = event.clientX - rect.left; const startY = event.clientY - rect.top; selectionStartPoint = { x: startX, y: startY }; Object.assign(selectionBox.style, { left: `${startX}px`, top: `${startY}px`, width: '0px', height: '0px', }); event.preventDefault(); }; const updateAreaSelection = (event) => { if (!isSelectingArea || !selectionBox || !selectionStartPoint) { return; } const rect = mapViewport.getBoundingClientRect(); const currentX = event.clientX - rect.left; const currentY = event.clientY - rect.top; const left = Math.min(selectionStartPoint.x, currentX); const top = Math.min(selectionStartPoint.y, currentY); const width = Math.abs(currentX - selectionStartPoint.x); const height = Math.abs(currentY - selectionStartPoint.y); if (!selectionMoved && (width > 3 || height > 3)) { selectionMoved = true; } Object.assign(selectionBox.style, { left: `${left}px`, top: `${top}px`, width: `${width}px`, height: `${height}px`, }); }; const finalizeAreaSelection = () => { if (!isSelectingArea) { return; } const boxRect = selectionBox?.getBoundingClientRect() ?? null; if (selectionBox) { selectionBox.remove(); } const mode = selectionMode; const merge = selectionMerge; const moved = selectionMoved; selectionBox = null; selectionStartPoint = null; isSelectingArea = false; selectionMode = null; selectionMerge = false; selectionMoved = false; suppressNextClick = moved; if (!moved || !boxRect || !mode) { return; } suppressNextClick = true; if (mode === 'remove') { const intersectingRows = []; rowElements.forEach((element, rowId) => { const cellRect = element.getBoundingClientRect(); const fullyContained = cellRect.left >= boxRect.left && cellRect.right <= boxRect.right && cellRect.top >= boxRect.top && cellRect.bottom <= boxRect.bottom; if (fullyContained) { intersectingRows.push(rowId); } }); if (!merge) { removeSelection.clear(); } intersectingRows.forEach((rowId) => removeSelection.add(rowId)); updateRemoveUi(); const size = removeSelection.size; setFeedback( size ? `Marked ${size} row${size === 1 ? '' : 's'} for deletion.` : 'No rows selected yet. Click rows to mark them for deletion.', 'info' ); return; } if (mode === 'add') { const bounds = mapRectFromClientRect(boxRect); const totalColumns = Math.max(1, Math.ceil(contentDimensions.width / GRID_UNIT)); const totalRows = Math.max(1, Math.ceil(contentDimensions.height / GRID_UNIT)); const startX = Math.max(0, Math.min(Math.floor(bounds.left / GRID_UNIT), totalColumns - 1)); const endX = Math.max(startX, Math.min(Math.ceil(bounds.right / GRID_UNIT), totalColumns)); const startY = Math.max(0, Math.min(Math.floor(bounds.top / GRID_UNIT), totalRows - 1)); const endY = Math.max(startY, Math.min(Math.ceil(bounds.bottom / GRID_UNIT), totalRows)); const coords = []; for (let x = startX; x < endX; x += 1) { for (let y = startY; y < endY; y += 1) { const key = coordinateKey(x, y); if (locationIndex.has(key)) { continue; } const cellLeft = x * GRID_UNIT; const cellTop = y * GRID_UNIT; const cellRight = cellLeft + CELL_SIZE; const cellBottom = cellTop + CELL_SIZE; const fullyContained = cellLeft >= bounds.left && cellRight <= bounds.right && cellTop >= bounds.top && cellBottom <= bounds.bottom; if (fullyContained) { coords.push({ x, y }); } } } if (!merge) { clearAddTargets({ silent: true }); } coords.forEach((coord) => stageAddTarget(coord)); updateAddUi(); const size = addSelection.size; setFeedback( size ? `Staged ${size} new row${size === 1 ? '' : 's'} for creation.` : 'No empty squares selected yet.', 'info' ); } }; const handleWheel = (event) => { if (event.ctrlKey) { event.preventDefault(); const previousScale = viewportState.scale; const direction = -event.deltaY; const scaleDelta = direction > 0 ? 1 + ZOOM_FACTOR : 1 - ZOOM_FACTOR; const nextScale = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, viewportState.scale * scaleDelta)); if (nextScale === previousScale) { return; } const rect = mapViewport.getBoundingClientRect(); const offsetX = event.clientX - rect.left; const offsetY = event.clientY - rect.top; const zoomRatio = nextScale / previousScale; viewportState.translateX = offsetX - (offsetX - viewportState.translateX) * zoomRatio; viewportState.translateY = offsetY - (offsetY - viewportState.translateY) * zoomRatio; viewportState.scale = nextScale; markViewportAdjusted(); applyTransform(); return; } event.preventDefault(); if (event.shiftKey) { viewportState.translateX -= event.deltaY * SCROLL_SENSITIVITY; viewportState.translateX -= event.deltaX * SCROLL_SENSITIVITY; } else { viewportState.translateY -= event.deltaY * SCROLL_SENSITIVITY; viewportState.translateX -= event.deltaX * SCROLL_SENSITIVITY; } markViewportAdjusted(); applyTransform(); }; const updateRemoveUi = () => { $removeCount.text(String(removeSelection.size)); refreshCellStates(); updateActionAvailability(); }; $clearRelocation.on('click', () => { if (!relocationTarget) { return; } stageRelocation(null); setFeedback('Staged move cleared.', 'info'); }); $addClear.on('click', () => { if (addSelection.size === 0) { return; } clearAddTargets(); setFeedback('Selection cleared.', 'info'); }); $addCommit.on('click', async () => { if (addSelection.size === 0) { return; } if (!routes.add) { console.error('Add route not configured'); setFeedback('Create route is not configured.', 'error'); return; } const vineInput = parseInt($addVineCount.val(), 10); const vineCount = Number.isFinite(vineInput) ? Math.max(0, vineInput) : 0; const selectionKeys = Array.from(addSelection.values()); const entries = selectionKeys.map((key) => { const coords = parseCoordinateKey(key); return { key, x: coords.x, y: coords.y, payload: { location: `${coords.x},${coords.y}`, vine_count: vineCount, }, }; }); const rowsPayload = entries.map((entry) => entry.payload); if (rowsPayload.length === 0) { setFeedback('No empty squares selected yet.', 'info'); return; } try { const response = await axios.post(routes.add, { rows: rowsPayload }); const createdIds = Array.isArray(response.data?.created) ? response.data.created : []; if (!createdIds.length) { setFeedback('Rows were created but the response did not return their identifiers.', 'error'); return; } clearAddTargets({ silent: true }); const processed = Math.min(createdIds.length, entries.length); for (let index = 0; index < processed; index += 1) { const rawId = createdIds[index]; const numericId = Number(rawId); if (!Number.isFinite(numericId) || numericId <= 0) { continue; } const entry = entries[index]; const newRow = { id: numericId, location: { x: entry.x, y: entry.y }, vine_count: entry.payload.vine_count, status: 'inactive', variety: null, }; upsertRow(newRow); locationIndex.set(entry.key, numericId); const element = createCell(newRow); mapContent.appendChild(element); rowElements.set(numericId, element); } selectedRow = null; updateAddUi(); updateContentSize(); refreshCellStates(); const requestedCount = entries.length; const createdCount = Math.min(createdIds.length, requestedCount); if (createdCount === requestedCount) { setFeedback(`Added ${createdCount} new row${createdCount === 1 ? '' : 's'}.`, 'success'); } else { setFeedback( `Added ${createdCount} new row${createdCount === 1 ? '' : 's'}. Some selections may require a refresh to appear.`, 'info' ); } } catch (error) { console.error('Failed to add rows', error); setFeedback('Unable to add the selected rows. Please try again.', 'error'); } }); const setMode = (mode) => { if (currentMode === mode) { return; } currentMode = mode; cancelAreaSelection(); $modeButtons.removeClass('active'); $modeButtons.filter(`[data-mode="${mode}"]`).addClass('active'); $modePanels.removeClass('active'); $modePanels.filter(`[data-mode-panel="${mode}"]`).addClass('active'); if (mode !== 'edit') { selectedRow = null; populateRowForm(null); stageRelocation(null); } if (mode !== 'remove') { removeSelection.clear(); } if (mode !== 'add') { clearAddTargets({ silent: true }); } updateRemoveUi(); updateAddUi(); updatePendingMarker(); switch (mode) { case 'edit': setFeedback('Edit mode active. Select a row to adjust head count or click an empty cell to stage a move.', 'info'); break; case 'add': setFeedback('Add mode active. Select empty squares to stage new rows, then create them.', 'info'); break; case 'remove': setFeedback('Remove mode active. Select rows to delete, then confirm the removal.', 'info'); break; } }; const handleModeClick = (event) => { const mode = $(event.currentTarget).data('mode'); if (typeof mode === 'string') { setMode(mode); } }; $modeButtons.on('click', handleModeClick); populateRowForm(null); stageRelocation(null); updateRemoveUi(); updateAddUi(); currentMode = null; setMode('edit'); const computeCoordinatesFromEvent = (event) => { const { viewport, map } = mapPointFromEvent(event); if (viewport.x < 0 || viewport.y < 0 || viewport.x > viewport.width || viewport.y > viewport.height) { return null; } if (map.x < 0 || map.y < 0) { return null; } const cellX = Math.floor(map.x / GRID_UNIT); const cellY = Math.floor(map.y / GRID_UNIT); const innerX = map.x - cellX * GRID_UNIT; const innerY = map.y - cellY * GRID_UNIT; if (innerX > CELL_SIZE || innerY > CELL_SIZE) { return null; } return { x: cellX, y: cellY }; }; const findRowById = (rowId) => currentRows.find((row) => Number(row.id) === Number(rowId)) ?? null; const upsertRow = (row) => { const index = currentRows.findIndex((existing) => Number(existing.id) === Number(row.id)); if (index >= 0) { currentRows[index] = row; } else { currentRows.push(row); } }; const removeRowFromCollections = (rowId) => { const numericId = Number(rowId); const row = findRowById(numericId); currentRows = currentRows.filter((entry) => Number(entry.id) !== numericId); if (row) { locationIndex.delete(coordinateKey(row.location.x, row.location.y)); } rowElements.get(numericId)?.remove(); rowElements.delete(numericId); removeSelection.delete(numericId); }; mapViewport.addEventListener('click', (event) => { if (suppressNextClick) { suppressNextClick = false; return; } const cell = event.target.closest('[data-row-id]'); if (cell) { event.stopPropagation(); const rowId = Number(cell.dataset.rowId); if (!Number.isFinite(rowId)) { return; } if (currentMode === 'remove') { const multi = event.shiftKey; if (multi) { if (removeSelection.has(rowId)) { removeSelection.delete(rowId); } else { removeSelection.add(rowId); } } else if (removeSelection.size === 1 && removeSelection.has(rowId)) { removeSelection.clear(); } else { removeSelection.clear(); removeSelection.add(rowId); } updateRemoveUi(); const size = removeSelection.size; setFeedback( size ? `Marked ${size} row${size === 1 ? '' : 's'} for deletion.` : 'No rows selected yet. Click rows to mark them for deletion.', 'info' ); return; } if (currentMode === 'add') { setFeedback('Add mode works with empty squares. Click an open grid space to stage a new row.', 'info'); return; } selectedRow = findRowById(rowId); populateRowForm(selectedRow); stageRelocation(null); refreshCellStates(); if (selectedRow) { setFeedback( `Editing row #${selectedRow.id} at ${selectedRow.location.x},${selectedRow.location.y}.`, 'info' ); } return; } if (currentMode === 'remove') { setFeedback('Remove mode is active. Click existing rows to mark them for removal.', 'info'); return; } const coords = computeCoordinatesFromEvent(event); if (!coords) { setFeedback('Click inside a square on the grid to select a location.', 'info'); return; } const locationKey = coordinateKey(coords.x, coords.y); const existingRowId = locationIndex.get(locationKey); if (currentMode === 'edit') { if (existingRowId) { selectedRow = findRowById(existingRowId); populateRowForm(selectedRow); stageRelocation(null); refreshCellStates(); if (selectedRow) { setFeedback( `Editing row #${selectedRow.id} at ${selectedRow.location.x},${selectedRow.location.y}.`, 'info' ); } return; } if (!selectedRow) { setFeedback('Select a row first before staging a new location.', 'info'); stageRelocation(null); return; } stageRelocation(coords); setFeedback(`Staged new location ${coords.x},${coords.y}. Save the row to move it.`, 'info'); return; } if (currentMode === 'add') { if (existingRowId) { setFeedback('That square already contains a row. Choose an empty location.', 'error'); return; } const key = coordinateKey(coords.x, coords.y); const multi = event.shiftKey; if (multi) { if (addSelection.has(key)) { removeAddTargetByKey(key); } else { stageAddTarget(coords); } } else { if (addSelection.size === 1 && addSelection.has(key)) { clearAddTargets(); setFeedback('Selection cleared.', 'info'); return; } clearAddTargets({ silent: true }); stageAddTarget(coords); } updateAddUi(); const size = addSelection.size; setFeedback( size ? `Staged ${size} new row${size === 1 ? '' : 's'} for creation.` : 'No empty squares selected yet.', 'info' ); return; } }); mapViewport.addEventListener('wheel', handleWheel, { passive: false }); mapViewport.addEventListener('mousedown', beginAreaSelection); window.addEventListener('mousemove', updateAreaSelection); window.addEventListener('mouseup', finalizeAreaSelection); $rowForm.on('submit', async (event) => { event.preventDefault(); if (!selectedRow) { setFeedback('Select a row to edit before saving changes.', 'info'); return; } if (!routes.update) { console.error('Update route not configured'); setFeedback('Update route is not configured.', 'error'); return; } if (relocationTarget) { const occupied = locationIndex.get(coordinateKey(relocationTarget.x, relocationTarget.y)); if (occupied && Number(occupied) !== Number(selectedRow.id)) { setFeedback('Another row already occupies the staged location.', 'error'); return; } } const stagedLocation = relocationTarget ? `${relocationTarget.x},${relocationTarget.y}` : undefined; const payload = { rows: [ { id: selectedRow.id, vine_count: parseInt($rowForm.find('[name="vine_count"]').val(), 10) || 0, location: stagedLocation, }, ], }; try { await axios.post(routes.update, payload); const previousKey = coordinateKey(selectedRow.location.x, selectedRow.location.y); const updatedRow = { ...selectedRow, vine_count: payload.rows[0].vine_count, location: stagedLocation ? { x: relocationTarget.x, y: relocationTarget.y } : selectedRow.location, }; upsertRow(updatedRow); selectedRow = updatedRow; if (stagedLocation) { locationIndex.delete(previousKey); locationIndex.set( coordinateKey(updatedRow.location.x, updatedRow.location.y), Number(updatedRow.id) ); } const element = rowElements.get(Number(updatedRow.id)); if (element) { const badge = element.querySelector('[data-role="vine-count"]'); if (badge) { badge.textContent = String(updatedRow.vine_count); } element.style.left = `${updatedRow.location.x * (CELL_SIZE + CELL_GAP)}px`; element.style.top = `${updatedRow.location.y * (CELL_SIZE + CELL_GAP)}px`; applyStatusClass(element, updatedRow.status); } populateRowForm(selectedRow); stageRelocation(null); refreshCellStates(); updateContentSize(); const feedbackMessage = stagedLocation ? `Row updated and moved to ${stagedLocation}.` : 'Row updated successfully.'; setFeedback(feedbackMessage, 'success'); } catch (error) { console.error('Failed to update row', error); setFeedback('Unable to update the selected row. Please try again.', 'error'); } }); $deleteButton.on('click', async (event) => { event.preventDefault(); if (!selectedRow) { setFeedback('Select a row first before trying to delete it.', 'info'); return; } const destroyRoute = routes.destroy?.replace('__ROW__', String(selectedRow.id)); if (!destroyRoute) { console.error('Destroy route not configured'); setFeedback('Delete route is not configured.', 'error'); return; } if (!window.confirm('Remove the selected row from the vineyard map?')) { setFeedback('Deletion cancelled.', 'info'); return; } try { await axios.post(destroyRoute, { _method: 'DELETE' }); const rowId = selectedRow.id; removeRowFromCollections(rowId); selectedRow = null; populateRowForm(null); stageRelocation(null); refreshCellStates(); updateContentSize(); setFeedback('Row deleted successfully.', 'success'); } catch (error) { console.error('Failed to remove row', error); setFeedback('Unable to delete the selected row. Please try again.', 'error'); } }); $removeCommit.on('click', async () => { if (currentMode !== 'remove' || removeSelection.size === 0) { return; } if (!routes.remove) { console.error('Remove route not configured'); setFeedback('Remove route is not configured.', 'error'); return; } if (!window.confirm(`Delete ${removeSelection.size} selected row(s)?`)) { setFeedback('Deletion cancelled.', 'info'); return; } const ids = Array.from(removeSelection); try { await axios.post(routes.remove, { rows: ids }); ids.forEach((rowId) => { removeRowFromCollections(rowId); if (selectedRow && Number(selectedRow.id) === Number(rowId)) { selectedRow = null; } }); populateRowForm(selectedRow); stageRelocation(null); updateContentSize(); removeSelection.clear(); updateRemoveUi(); setFeedback(`Deleted ${ids.length} row${ids.length === 1 ? '' : 's'}.`, 'success'); } catch (error) { console.error('Failed to delete selected rows', error); setFeedback('Unable to delete the selected rows. Please try again.', 'error'); } }); $removeClear.on('click', () => { if (removeSelection.size === 0) { return; } removeSelection.clear(); updateRemoveUi(); setFeedback('Selection cleared.', 'info'); }); }