import { useState, useEffect, useCallback } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { MapPin, Plus, Loader2, Pencil, Trash2, X } from 'lucide-react'; import { api, type StorageLocation } from '../api/client'; import { Button } from './Button'; import { ConfirmModal } from './ConfirmModal'; import { useToast } from '../contexts/ToastContext'; import { inventoryLocationsQueryKey, invalidateInventoryLocations } from '../utils/inventoryQueries'; interface LocationsModalProps { open: boolean; onClose: () => void; // Optional even with startCreating: a caller that just wants the inline // "create a location" dialog without picking one afterward can omit it. // The save always closes the modal regardless of whether this is set. onPickLocation?: (locationId: number) => void; startCreating?: boolean; } export function LocationsModal({ open, onClose, onPickLocation, startCreating }: LocationsModalProps) { const { t } = useTranslation(); const queryClient = useQueryClient(); const { showToast } = useToast(); const [editorOpen, setEditorOpen] = useState(false); const [editing, setEditing] = useState(null); const [name, setName] = useState(''); const [deleteTarget, setDeleteTarget] = useState(null); const { data: locations = [], isLoading } = useQuery({ queryKey: inventoryLocationsQueryKey, queryFn: api.getLocations, enabled: open, }); const { data: locationSensors = [] } = useQuery({ queryKey: ['locationHaSensors'], queryFn: () => api.getLocationHASensors(), enabled: open, }); const sensorCountByLocation = locationSensors.reduce>((acc, sensor) => { acc[sensor.location_id] = (acc[sensor.location_id] ?? 0) + 1; return acc; }, {}); const invalidate = () => { invalidateInventoryLocations(queryClient); queryClient.invalidateQueries({ queryKey: ['inventory-spools'] }); queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] }); queryClient.invalidateQueries({ queryKey: ['locationHaSensors'] }); queryClient.invalidateQueries({ queryKey: ['locationHaSensorReadings'] }); }; const saveMutation = useMutation({ mutationFn: async () => { const trimmed = name.trim(); if (!trimmed) throw new Error(t('locations.nameRequired')); if (editing) { return api.updateLocation(editing.id, { name: trimmed }); } return api.createLocation({ name: trimmed }); }, onSuccess: (saved) => { showToast(t(editing ? 'locations.updated' : 'locations.created'), 'success'); invalidate(); // startCreating mode has no location-list view to fall back to (see the // render branch below and closeEditor's own unconditional onClose), so // a save here must always close — with or without onPickLocation, which // is optional by design for a caller that only wants location // management, not a picker. Gating this on onPickLocation being set // used to leave editorOpen false with open still true: nothing left to // render, but the caller never told to close. if (!editing && startCreating) { onPickLocation?.(saved.id); onClose(); return; } setEditorOpen(false); setEditing(null); setName(''); }, onError: (err: Error) => { showToast(err.message || t('locations.saveFailed'), 'error'); }, }); const deleteMutation = useMutation({ mutationFn: (id: number) => api.deleteLocation(id), onSuccess: () => { showToast(t('locations.deleted'), 'success'); setDeleteTarget(null); invalidate(); }, onError: (err: Error) => { showToast(err.message || t('locations.deleteFailed'), 'error'); }, }); const openCreate = () => { setEditing(null); setName(''); setEditorOpen(true); }; useEffect(() => { if (open && startCreating) { setEditing(null); setName(''); setEditorOpen(true); } }, [open, startCreating]); const openEdit = (location: StorageLocation) => { setEditing(location); setName(location.name); setEditorOpen(true); }; const closeEditor = useCallback(() => { if (saveMutation.isPending) return; if (startCreating) { onClose(); return; } setEditorOpen(false); setEditing(null); setName(''); }, [saveMutation.isPending, startCreating, onClose]); // Esc closes the inner editor first; if it's closed, Esc closes the outer // modal — but only when neither save nor delete is mid-flight, so a stray // keypress during a network round-trip doesn't drop the user back into the // inventory page with an orphaned spinner. useEffect(() => { if (!open) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key !== 'Escape') return; if (saveMutation.isPending || deleteMutation.isPending) return; if (editorOpen) { closeEditor(); } else if (!deleteTarget) { onClose(); } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [open, editorOpen, deleteTarget, saveMutation.isPending, deleteMutation.isPending, closeEditor, onClose]); const handleSave = (e: React.FormEvent) => { e.preventDefault(); saveMutation.mutate(); }; if (!open) return null; const modalTitleId = 'locations-modal-title'; const editorTitleId = 'location-editor-title'; const editorForm = (
setName(e.target.value)} autoFocus />
); if (startCreating) { return editorOpen ? (

{t('locations.add')}

{editorForm}
) : null; } return (
{ if (saveMutation.isPending || deleteMutation.isPending) return; onClose(); }} />

{t('locations.title')}

{t('locations.subtitle')}

{isLoading ? (
{t('common.loading')}
) : locations.length === 0 ? (
{t('locations.empty')}
) : ( {locations.map((loc) => ( { if (onPickLocation) { onPickLocation(loc.id); onClose(); } }} > ))}
{t('locations.name')} {t('locations.sensors')} {t('locations.spools')} {t('common.actions')}
{loc.name} {sensorCountByLocation[loc.id] ?? 0} {loc.spool_count} e.stopPropagation()}>
)}
{editorOpen && (

{editing ? t('locations.edit') : t('locations.add')}

{editorForm}
)} {deleteTarget && ( deleteMutation.mutate(deleteTarget.id)} onCancel={() => setDeleteTarget(null)} /> )}
); }