grocery-app/frontend/src/components/maps/LocationMapBottomSheet.jsx
2026-06-03 20:43:52 -07:00

384 lines
13 KiB
JavaScript

import {
DEFAULT_MAP_FILTERS,
MAP_OBJECT_TYPES,
getMapObjectDisplayLabel,
} from "../../lib/locationMapUtils";
function objectZoneId(object) {
return object?.zone_id ? String(object.zone_id) : "";
}
const DISPLAY_CONTROLS = [
["showZones", "Zones"],
["showLabels", "Labels"],
["showCounts", "Counts"],
["showPins", "Pins"],
["showMyItems", "Mine"],
["showOtherItems", "Others"],
["showCompleted", "Done"],
["showUnmapped", "Unmapped"],
];
export default function LocationMapBottomSheet({
mode,
editorTool,
canManage,
filters,
setFilters,
layersOpen,
setLayersOpen,
selectedObject,
selectedZoneItems,
visibleUnmappedItems,
history,
future,
saving,
savingAction,
hasUnsavedChanges,
mapState,
mapSize,
onAddObject,
onUndo,
onRedo,
onSaveDraft,
onPublish,
onPreviewDraft,
onPanMode,
onEditObjects,
onEditMode,
onObjectField,
onZoneLinkChange,
onClearSelection,
onDuplicateObject,
onDeleteObject,
}) {
const selectedTitle = getMapObjectDisplayLabel(selectedObject);
const selectedAssignedItems = selectedObject?.zone_id
? (mapState?.items || []).filter((item) => String(item.zone_id || "") === objectZoneId(selectedObject))
: [];
const hasHiddenSelectedItems = selectedAssignedItems.length > 0 && selectedZoneItems.length === 0;
const selectedItemCountLabel = hasHiddenSelectedItems
? "0 shown"
: `${selectedZoneItems.length} item${selectedZoneItems.length === 1 ? "" : "s"}`;
const sheetTitle = mode === "edit"
? selectedObject && editorTool === "edit"
? selectedTitle
: editorTool === "edit"
? "Edit Objects"
: "Pan Mode"
: selectedObject
? selectedTitle
: "Map Details";
const sheetDescription = selectedObject && editorTool === "edit"
? "Move, resize, link, rename, or nudge with arrow keys."
: editorTool === "edit"
? "Tap an area to select it."
: "Drag and zoom without changing objects.";
const showHeaderItemCount = mode !== "edit" && Boolean(selectedObject);
const showEditorControls = mode === "edit" && canManage;
const showSelectedObjectForm = Boolean(mode === "edit" && editorTool === "edit" && selectedObject);
const changedLayerCount = DISPLAY_CONTROLS.filter(([key]) => filters[key] !== DEFAULT_MAP_FILTERS[key]).length;
const unmappedItemPreview = visibleUnmappedItems.slice(0, 8);
const hiddenUnmappedItemCount = visibleUnmappedItems.length - unmappedItemPreview.length;
const selectedWidth = Number(selectedObject?.width) || 40;
const selectedHeight = Number(selectedObject?.height) || 40;
const maxX = Math.max(0, Math.round((mapSize?.width || selectedWidth) - selectedWidth));
const maxY = Math.max(0, Math.round((mapSize?.height || selectedHeight) - selectedHeight));
const maxWidth = Math.max(40, Math.round(mapSize?.width || selectedWidth));
const maxHeight = Math.max(40, Math.round(mapSize?.height || selectedHeight));
const saveDraftLabel = savingAction === "save" ? "Saving..." : "Save Draft";
const publishLabel = savingAction === "publish" ? "Publishing..." : "Publish";
const showSelectedZoneItems = () => {
setFilters((current) => ({
...current,
showMyItems: true,
showOtherItems: true,
showCompleted: true,
}));
};
const primaryDraftActions = showEditorControls ? (
<div className="location-map-editor-actions location-map-primary-actions">
<button type="button" className="primary" onClick={onSaveDraft} disabled={saving || !hasUnsavedChanges}>
{saveDraftLabel}
</button>
<button
type="button"
className="primary"
onClick={onPublish}
disabled={saving || (!mapState?.draft_map && !hasUnsavedChanges)}
>
{publishLabel}
</button>
</div>
) : null;
const secondaryDraftActions = showEditorControls ? (
<div className="location-map-editor-actions location-map-secondary-actions">
<button type="button" onClick={onAddObject} disabled={saving}>Add Area</button>
<button type="button" onClick={onPreviewDraft} aria-label="Preview Draft" disabled={saving}>Preview</button>
<button type="button" onClick={onUndo} disabled={saving || history.length === 0}>Undo</button>
<button type="button" onClick={onRedo} disabled={saving || future.length === 0}>Redo</button>
</div>
) : null;
const editorActions = showEditorControls ? (
<div className="location-map-editor-action-stack" role="group" aria-label="Map draft actions">
{primaryDraftActions}
{secondaryDraftActions}
</div>
) : canManage && mapState?.published_map ? (
<div className="location-map-editor-actions">
<button type="button" onClick={onEditMode}>Edit Map</button>
</div>
) : null;
return (
<aside className="location-map-bottom-sheet">
<div className="location-map-sheet-header">
<div>
<div className="location-map-sheet-heading">
<strong>{sheetTitle}</strong>
{showHeaderItemCount ? (
<span className="location-map-sheet-count">{selectedItemCountLabel}</span>
) : null}
</div>
{mode === "edit" && canManage ? (
<span>{sheetDescription}</span>
) : null}
</div>
<button
type="button"
className={`location-map-layer-toggle ${layersOpen ? "active" : ""}`}
onClick={() => setLayersOpen((current) => !current)}
aria-expanded={layersOpen}
aria-label={changedLayerCount ? `Layers, ${changedLayerCount} changed` : "Layers"}
>
Layers
{changedLayerCount ? (
<span className="location-map-layer-count" aria-hidden="true">{changedLayerCount}</span>
) : null}
</button>
</div>
{layersOpen ? (
<div className="location-map-display-panel">
{changedLayerCount ? (
<button
type="button"
className="location-map-layer-reset"
onClick={() => setFilters({ ...DEFAULT_MAP_FILTERS })}
>
Reset
</button>
) : null}
<div className="location-map-display-controls">
{DISPLAY_CONTROLS.map(([key, label]) => (
<label key={key}>
<input
type="checkbox"
checked={filters[key]}
onChange={(event) =>
setFilters((current) => ({ ...current, [key]: event.target.checked }))
}
/>
{label}
</label>
))}
</div>
</div>
) : null}
{showSelectedObjectForm && primaryDraftActions ? (
<div
className="location-map-editor-action-stack location-map-selected-primary-actions"
role="group"
aria-label="Map draft actions"
>
{primaryDraftActions}
</div>
) : editorActions}
{showEditorControls ? (
<div className="location-map-mobile-tool-switch" aria-label="Edit tool">
<button
type="button"
className={[editorTool === "pan" ? "active" : "", "is-pan"].filter(Boolean).join(" ")}
onClick={onPanMode}
disabled={saving}
>
Pan
</button>
<button
type="button"
className={[editorTool === "edit" ? "active" : "", "is-edit"].filter(Boolean).join(" ")}
onClick={onEditObjects}
disabled={saving}
>
Objects
</button>
</div>
) : null}
{mode === "edit" && editorTool === "edit" && selectedObject ? (
<div className="location-map-object-form">
<label>
Label
<input
value={selectedObject.label || ""}
disabled={saving}
onChange={(event) => onObjectField("label", event.target.value)}
/>
</label>
<label>
Type
<select
aria-label="Object type"
value={selectedObject.type}
disabled={saving}
onChange={(event) => onObjectField("type", event.target.value)}
>
{MAP_OBJECT_TYPES.map((type) => (
<option key={type} value={type}>{type}</option>
))}
</select>
</label>
<label>
Zone
<select
aria-label="Linked zone"
value={objectZoneId(selectedObject)}
disabled={saving}
onChange={(event) => onZoneLinkChange(event.target.value)}
>
<option value="">No linked zone</option>
{(mapState?.zones || []).map((zone) => (
<option key={zone.id} value={zone.id}>{zone.name}</option>
))}
</select>
</label>
<div className="location-map-object-grid">
<label>
X
<input
type="number"
min="0"
max={maxX}
step="1"
value={Math.round(selectedObject.x)}
disabled={saving}
onChange={(event) => onObjectField("x", Number(event.target.value))}
/>
</label>
<label>
Y
<input
type="number"
min="0"
max={maxY}
step="1"
value={Math.round(selectedObject.y)}
disabled={saving}
onChange={(event) => onObjectField("y", Number(event.target.value))}
/>
</label>
<label>
W
<input
type="number"
min="40"
max={maxWidth}
step="1"
value={Math.round(selectedObject.width)}
disabled={saving}
onChange={(event) => onObjectField("width", Number(event.target.value))}
/>
</label>
<label>
H
<input
type="number"
min="40"
max={maxHeight}
step="1"
value={Math.round(selectedObject.height)}
disabled={saving}
onChange={(event) => onObjectField("height", Number(event.target.value))}
/>
</label>
</div>
<div className="location-map-editor-actions">
<button type="button" onClick={onClearSelection}>Done</button>
<button type="button" onClick={onDuplicateObject} disabled={saving}>Duplicate</button>
<button type="button" className="danger" onClick={onDeleteObject} disabled={saving}>Delete</button>
</div>
</div>
) : mode === "edit" && editorTool === "edit" ? (
<p className="location-map-muted">
Tap an area to edit its name, linked zone, size, and position.
</p>
) : mode === "edit" ? (
<p className="location-map-muted">
Use Pan Mode to move around the map. Switch to Objects when you want to edit areas.
</p>
) : selectedObject ? (
<div className="location-map-zone-items">
{selectedZoneItems.length === 0 ? (
<div className="location-map-zone-empty">
<p className="location-map-muted">
{hasHiddenSelectedItems
? "Items are assigned here, but hidden by layer filters."
: "No items assigned to this zone yet."}
</p>
{hasHiddenSelectedItems ? (
<button type="button" onClick={showSelectedZoneItems}>
Show Zone Items
</button>
) : null}
</div>
) : (
<ul>
{selectedZoneItems.map((item) => (
<li key={item.id}>
<span>{item.item_name}</span>
<small>x{item.quantity}</small>
</li>
))}
</ul>
)}
</div>
) : (
<p className="location-map-muted">
Tap a zone to view assigned items. Item pins stay hidden unless you turn them on in Layers.
</p>
)}
{showSelectedObjectForm && secondaryDraftActions ? (
<div
className="location-map-editor-action-stack location-map-selected-secondary-actions"
role="group"
aria-label="More map draft actions"
>
{secondaryDraftActions}
</div>
) : null}
{filters.showUnmapped && visibleUnmappedItems.length > 0 ? (
<div className="location-map-unmapped-list">
<strong>Unmapped Items</strong>
<ul>
{unmappedItemPreview.map((item) => (
<li key={item.id}>{item.item_name}</li>
))}
{hiddenUnmappedItemCount > 0 ? (
<li
className="location-map-unmapped-more"
aria-label={`${hiddenUnmappedItemCount} more unmapped items`}
>
+{hiddenUnmappedItemCount} more
</li>
) : null}
</ul>
</div>
) : null}
</aside>
);
}