const PropertiesManagementModal = ({ isOpen, onClose, currentUser, selectedPropertyContext, onPropertyChange, onDataChange }) => { const { useState, useEffect, useRef } = React; const TagsInput = window.TagsInput; // ═══════════════════════════════════════════════════════════════════════════ // STATE MANAGEMENT // ═══════════════════════════════════════════════════════════════════════════ const [properties, setProperties] = useState([]); const [selectedProperty, setSelectedProperty] = useState(null); const [isLoading, setIsLoading] = useState(true); const [loadError, setLoadError] = useState(''); const [searchText, setSearchText] = useState(''); // Active Tab: 'details', 'vendors', 'venues', or 'statistics' (for OTO) const [activeTab, setActiveTab] = useState('details'); // OTO Statistics State const [otoStats, setOtoStats] = useState(null); const [loadingOtoStats, setLoadingOtoStats] = useState(false); const [overviewPropertiesDetail, setOverviewPropertiesDetail] = useState(null); // 'active' | 'owned' | 'managed' — which properties list to show in modal // Property Details State const [isEditingDetails, setIsEditingDetails] = useState(false); const [isSavingDetails, setIsSavingDetails] = useState(false); const [saveMessage, setSaveMessage] = useState({ type: '', text: '' }); const [editPhone, setEditPhone] = useState(''); const [editGMUsername, setEditGMUsername] = useState(''); const [editAbbreviation, setEditAbbreviation] = useState(''); const [editIsOwned, setEditIsOwned] = useState(false); const [editIsManagedByOto, setEditIsManagedByOto] = useState(false); const [editAmenities, setEditAmenities] = useState([]); const [editAmenitiesVenue, setEditAmenitiesVenue] = useState([]); const [isEditingAmenities, setIsEditingAmenities] = useState(false); const [savingAmenities, setSavingAmenities] = useState(false); const [eligibleManagers, setEligibleManagers] = useState([]); const [loadingManagers, setLoadingManagers] = useState(false); // Vendors State const [vendors, setVendors] = useState([]); const [loadingVendors, setLoadingVendors] = useState(false); // Vendor catalog (system-wide vendors + vendor services) const [allVendors, setAllVendors] = useState([]); const [vendorServices, setVendorServices] = useState([]); const [loadingVendorCatalog, setLoadingVendorCatalog] = useState(false); // Editing state: // mode: 'add' (assign to property), 'edit' (change assignment), 'details' (edit vendor service details) const [editingVendor, setEditingVendor] = useState(null); // { mode, pvs_id, vendor_service_id, vendor_id, service, phone, email, url, notes, new_vendor_name, new_service_name, service_select_id } const [isSavingVendor, setIsSavingVendor] = useState(false); const [vendorSaveMessage, setVendorSaveMessage] = useState({ type: '', text: '' }); // Dining Venues State const [venues, setVenues] = useState([]); const [venuesLoading, setVenuesLoading] = useState(false); const [restaurants, setRestaurants] = useState([]); const [restaurantsLoading, setRestaurantsLoading] = useState(false); const [selectedConceptId, setSelectedConceptId] = useState(''); const [conceptSearch, setConceptSearch] = useState(''); const [editingVenue, setEditingVenue] = useState(null); const [savingVenue, setSavingVenue] = useState(false); const [addingConcept, setAddingConcept] = useState(false); const [newConceptName, setNewConceptName] = useState(''); const [newConceptDescription, setNewConceptDescription] = useState(''); const [savingConcept, setSavingConcept] = useState(false); const [deletingConceptId, setDeletingConceptId] = useState(null); const [conceptNameSuggestions, setConceptNameSuggestions] = useState([]); const newConceptNameInputRef = useRef(null); // Add Property const [showAddProperty, setShowAddProperty] = useState(false); const [newPropertyCode, setNewPropertyCode] = useState(''); const [newPropertyName, setNewPropertyName] = useState(''); const [newPropertyAddress, setNewPropertyAddress] = useState(''); const [newPropertyCity, setNewPropertyCity] = useState(''); const [newPropertyState, setNewPropertyState] = useState(''); const [newPropertyZip, setNewPropertyZip] = useState(''); const [savingNewProperty, setSavingNewProperty] = useState(false); const [addPropertyError, setAddPropertyError] = useState(''); const currentUsername = currentUser?.Username || currentUser?.username || ''; // Permission checks from actual permission flags const isPrivileged = !!currentUser?.can_manage_properties; const canEditDetails = !!currentUser?.can_edit_property_details; const canManageVendors = !!currentUser?.can_manage_vendors; const canManageDiningVenues = !!currentUser?.can_manage_venues || !!currentUser?.can_edit_dining_concepts; const canSeeAllProperties = isPrivileged || !!currentUser?.can_view_all_properties; const canAddProperties = window.canAddProperties ? window.canAddProperties(currentUser) : !!(currentUser?.can_manage_properties && currentUser?.can_add_properties); const displayedVenues = selectedConceptId ? (venues || []).filter(v => String(v?.restaurant_id || '') === String(selectedConceptId)) : (venues || []); // Predefined amenity options (selection box, not free-text) const AMENITY_OPTIONS = [ 'Pool', 'Fitness Center', 'Beach Access', 'Free WiFi', 'Restaurant', 'Bar', 'Spa', 'Parking', 'Business Center', 'Pet Friendly', 'Room Service', 'Concierge', 'Laundry' ]; // Vendor Categories const VENDOR_CATEGORIES = [ { value: 'hsia', label: 'HSIA (Guest Internet)' }, { value: 'back_office_internet', label: 'Back Office Internet' }, { value: 'guest_internet', label: 'Guest Internet Provider' }, { value: 'pos', label: 'Point of Sale' }, { value: 'av', label: 'Audio/Visual' }, { value: 'phone', label: 'Phone System' }, { value: 'tv', label: 'TV/Entertainment' }, { value: 'music', label: 'Hotel Music' }, { value: 'key_system', label: 'Key Management System' }, { value: 'pms', label: 'Property Management System' }, { value: 'hvac', label: 'HVAC Controls' }, { value: 'fire_safety', label: 'Fire & Life Safety' } ]; // ═══════════════════════════════════════════════════════════════════════════ // UTILITIES // ═══════════════════════════════════════════════════════════════════════════ const formatPhone = (value) => { if (!value) return ''; const digits = String(value).replace(/\D/g, ''); if (digits.length <= 3) return digits; if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`; return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6, 10)}`; }; const formatPhoneForDisplay = (phone) => { if (!phone) return ''; const cleaned = String(phone).replace(/\D/g, ''); if (cleaned.length !== 10) return String(phone); return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6, 10)}`; }; const handlePhoneChange = (e) => { const digits = e.target.value.replace(/\D/g, '').slice(0, 10); setEditPhone(formatPhone(digits)); }; const getCategoryLabel = (category) => { const cat = VENDOR_CATEGORIES.find(c => c.value === category); return cat ? cat.label : category; }; // ═══════════════════════════════════════════════════════════════════════════ // DATA LOADING // ═══════════════════════════════════════════════════════════════════════════ const loadOtoStatistics = async () => { try { setLoadingOtoStats(true); const response = await fetch('/api/statistics/oto', { headers: currentUsername ? { 'X-OTO-User': currentUsername } : {} }); if (response.ok) { const data = await response.json(); setOtoStats(data); } } catch (err) { console.error('Error loading OTO statistics:', err); } finally { setLoadingOtoStats(false); } }; // Load OTO stats when statistics tab is active useEffect(() => { if (activeTab === 'statistics' && selectedProperty?.code === 'OTO' && !otoStats) { loadOtoStatistics(); } }, [activeTab, selectedProperty]); const loadProperties = async () => { try { setIsLoading(true); setLoadError(''); const response = await fetch('/api/properties/management', { headers: currentUsername ? { 'X-OTO-User': currentUsername } : {} }); if (!response.ok) { const data = await response.json(); throw new Error(data.detail || 'Failed to load properties'); } const data = await response.json(); setProperties(data.properties || []); // Initialize with selectedPropertyContext if available, otherwise first property if (selectedPropertyContext && data.properties?.some(p => p.code === selectedPropertyContext.property_code)) { const contextProperty = data.properties.find(p => p.code === selectedPropertyContext.property_code); setSelectedProperty(contextProperty); } else if (data.properties?.length > 0 && !selectedProperty) { setSelectedProperty(data.properties[0]); } } catch (err) { setLoadError(err.message || 'Failed to load properties'); } finally { setIsLoading(false); } }; const loadSingleProperty = async () => { try { setIsLoading(true); setLoadError(''); const code = selectedPropertyContext?.property_code || currentUser?.properties?.[0]?.property_code; if (!code) { setProperties([]); setSelectedProperty(null); setLoadError('No property is associated with this account.'); return; } const response = await fetch(`/api/properties/${code}/details`, { headers: currentUsername ? { 'X-OTO-User': currentUsername } : {} }); const data = await response.json(); if (!response.ok) throw new Error(data.detail || 'Failed to load property'); const prop = data.property; setProperties([prop]); setSelectedProperty(prop); } catch (err) { setLoadError(err.message || 'Failed to load property'); } finally { setIsLoading(false); } }; const loadEligibleManagers = async (propertyCode) => { try { setLoadingManagers(true); const response = await fetch(`/api/properties/${propertyCode}/eligible-managers`, { headers: currentUsername ? { 'X-OTO-User': currentUsername } : {} }); if (!response.ok) throw new Error('Failed to load eligible managers'); const data = await response.json(); // Debug: log raw response so we can see what's coming back from the API console.log('[OTO DEBUG] Eligible managers response', { propertyCode, data }); setEligibleManagers(data.managers || []); } catch (err) { console.error('[OTO DEBUG] Error loading eligible managers', { propertyCode, error: err }); setEligibleManagers([]); } finally { setLoadingManagers(false); } }; const loadVendors = async (propertyCode) => { if (!propertyCode) return; try { setLoadingVendors(true); const response = await fetch(`/api/properties/${propertyCode}/vendor-services`, { headers: currentUsername ? { 'X-OTO-User': currentUsername } : {} }); if (!response.ok) throw new Error('Failed to load vendor services'); const data = await response.json(); setVendors(data.vendor_services || []); } catch (err) { console.error('Error loading vendor services:', err); setVendors([]); } finally { setLoadingVendors(false); } }; const loadAllVendors = async () => { try { setLoadingVendorCatalog(true); const response = await fetch('/api/vendors', { headers: currentUsername ? { 'X-OTO-User': currentUsername } : {} }); if (!response.ok) throw new Error('Failed to load vendors'); const data = await response.json(); setAllVendors(data.vendors || []); } catch (err) { console.error('Error loading vendors:', err); setAllVendors([]); } finally { setLoadingVendorCatalog(false); } }; const loadServicesForVendor = async (vendorId) => { if (!vendorId) { setVendorServices([]); return; } try { setLoadingVendorCatalog(true); const response = await fetch(`/api/vendors/${vendorId}/services`, { headers: currentUsername ? { 'X-OTO-User': currentUsername } : {} }); if (!response.ok) throw new Error('Failed to load vendor services'); const data = await response.json(); setVendorServices(data.services || []); } catch (err) { console.error('Error loading vendor services:', err); setVendorServices([]); } finally { setLoadingVendorCatalog(false); } }; const loadVenues = async (propertyCode) => { if (!propertyCode) return; try { setVenuesLoading(true); const response = await fetch(`/api/properties/${propertyCode}/venues`, { headers: currentUsername ? { 'X-OTO-User': currentUsername } : {} }); // Surface any backend error text in console (helps when the API returns non-JSON errors) if (!response.ok) { let detail = ''; try { const errJson = await response.json(); detail = errJson?.detail ? `: ${errJson.detail}` : ''; } catch (_) { try { detail = `: ${await response.text()}`; } catch (_) { } } throw new Error(`Failed to load venues${detail}`); } const data = await response.json(); // Be tolerant to different response shapes: // - { venues: [...] } // - { items: [...] } // - [...] (array) // - { data: [...] } let venuesList = []; if (Array.isArray(data)) { venuesList = data; } else if (Array.isArray(data?.venues)) { venuesList = data.venues; } else if (Array.isArray(data?.items)) { venuesList = data.items; } else if (Array.isArray(data?.data)) { venuesList = data.data; } else if (Array.isArray(data?.results)) { venuesList = data.results; } // If the backend returns venues across multiple properties, filter client-side. const filtered = (venuesList || []).filter(v => { const pc = v?.property_code || v?.propertyCode || v?.property || v?.property_id || v?.propertyId; // If there's no property field at all, keep it (assume API already filtered). if (pc == null) return true; return String(pc).toUpperCase() === String(propertyCode).toUpperCase(); }); // Normalize restaurant name if backend returns restaurant_id instead of restaurant_name const normalized = filtered.map(v => { const restaurantId = v?.restaurant_id ?? v?.restaurantId ?? v?.restaurant; const restaurantName = v?.restaurant_name ?? v?.restaurantName ?? (restaurantId != null ? (restaurants.find(r => String(r.id) === String(restaurantId))?.name || '') : ''); return { ...v, restaurant_id: restaurantId ?? v?.restaurant_id, restaurant_name: restaurantName }; }); setVenues(normalized); } catch (err) { console.error('Error loading venues:', err); setVenues([]); } finally { setVenuesLoading(false); } }; const loadRestaurants = async (propertyCode = null) => { try { setRestaurantsLoading(true); const url = propertyCode ? `/api/restaurants?property_code=${encodeURIComponent(propertyCode)}` : '/api/restaurants'; const response = await fetch(url, { headers: currentUsername ? { 'X-OTO-User': currentUsername } : {} }); if (!response.ok) throw new Error('Failed to load restaurants'); const data = await response.json(); setRestaurants(data.restaurants || []); } catch (err) { console.error('🔴 Error loading restaurants:', err); setRestaurants([]); } finally { setRestaurantsLoading(false); } }; const handleSaveVenue = async () => { if (!selectedProperty || !editingVenue) return; try { setSavingVenue(true); const method = editingVenue.mode === 'add' ? 'POST' : 'PUT'; const url = editingVenue.mode === 'add' ? `/api/properties/${selectedProperty.code}/venues` : `/api/venues/${editingVenue.id}`; const featuresFlat = {}; const predefinedIds = ['self_pour_system']; Object.entries(editingVenue.features || {}).forEach(([k, f]) => { if (predefinedIds.includes(k) && typeof f === 'string') { if (f.trim()) featuresFlat[k] = f.trim(); } else if (f && typeof f === 'object' && (f.key || f.value)) { const key = String(f.key || '').trim(); if (key) featuresFlat[key] = String(f.value || ''); } }); const payload = { restaurant_id: editingVenue.restaurant_id ? parseInt(editingVenue.restaurant_id, 10) : null, name: editingVenue.name, type: editingVenue.type, links: editingVenue.links || {}, features: featuresFlat, vendor_ids: Array.isArray(editingVenue.vendor_ids) ? editingVenue.vendor_ids : (editingVenue.vendors || []).map(v => v.id) }; const response = await fetch(url, { method, headers: { 'Content-Type': 'application/json', 'X-OTO-User': currentUsername }, body: JSON.stringify(payload) }); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || 'Failed to save venue'); } setEditingVenue(null); await loadVenues(selectedProperty.code); if (onDataChange) onDataChange('venues'); } catch (err) { console.error('Error saving venue:', err); alert('Error: ' + err.message); } finally { setSavingVenue(false); } }; const handleDeleteVenue = async (venueId) => { if (!confirm('Delete this venue? This cannot be undone.')) return; try { const response = await fetch(`/api/venues/${venueId}`, { method: 'DELETE', headers: currentUsername ? { 'X-OTO-User': currentUsername } : {} }); if (!response.ok) throw new Error('Failed to delete venue'); await loadVenues(selectedProperty.code); if (onDataChange) onDataChange('venues'); } catch (err) { console.error('Error deleting venue:', err); alert('Error: ' + err.message); } }; const handleSaveConcept = async () => { if (!selectedProperty || !newConceptName.trim()) return; const propCode = selectedProperty.property_code || selectedProperty.code; if (propCode === 'OTO') return; const nameTrimmed = newConceptName.trim(); const hasPropField = (restaurants || []).some(x => x && (x.property_code !== undefined && x.property_code !== null)); const conceptsForProperty = hasPropField ? (restaurants || []).filter(x => String(x?.property_code || '') === String(propCode)) : (restaurants || []); const alreadyExists = conceptsForProperty.some(c => (c.name || '').trim().toLowerCase() === nameTrimmed.toLowerCase()); if (alreadyExists) { window.otoNotify?.toast?.('This concept already exists at this property. Use a different name or add locations to the existing one.', 'error') || alert('This concept already exists at this property. Use a different name or add locations to the existing one.'); return; } try { setSavingConcept(true); const response = await fetch('/api/restaurants', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-OTO-User': currentUsername }, body: JSON.stringify({ property_code: propCode, name: nameTrimmed, description: (newConceptDescription || '').trim() }) }); if (!response.ok) { const data = await response.json(); throw new Error(data.detail || 'Failed to add concept'); } setAddingConcept(false); setNewConceptName(''); setNewConceptDescription(''); await loadRestaurants(propCode); } catch (err) { alert('Error: ' + (err.message || 'Failed to add concept')); } finally { setSavingConcept(false); } }; const handleDeleteConcept = async (conceptId, conceptName) => { if (!confirm(`Remove the restaurant concept "${conceptName}"? Any venues under it will need to be removed or reassigned first.`)) return; try { setDeletingConceptId(conceptId); const response = await fetch(`/api/restaurants/${conceptId}`, { method: 'DELETE', headers: currentUsername ? { 'X-OTO-User': currentUsername } : {} }); if (!response.ok) { const data = await response.json(); throw new Error(data.detail || 'Failed to remove concept'); } const propCode = selectedProperty?.property_code || selectedProperty?.code; if (propCode) { await loadRestaurants(propCode); await loadVenues(propCode); } } catch (err) { alert('Error: ' + (err.message || 'Failed to remove concept')); } finally { setDeletingConceptId(null); } }; const startAddVendor = async () => { if (!selectedProperty) return; setVendorSaveMessage({ type: '', text: '' }); setEditingVendor({ mode: 'add', pvs_id: null, vendor_service_id: null, vendor_id: '', service: '', phone: '', email: '', url: '', notes: '', new_vendor_name: '', new_service_name: '', service_select_id: '' }); await loadAllVendors(); setVendorServices([]); }; const startEditVendorAssignment = async (pvs) => { if (!pvs) return; setVendorSaveMessage({ type: '', text: '' }); setEditingVendor({ mode: 'edit', pvs_id: pvs.id, vendor_service_id: pvs.vendor_service_id, vendor_id: String(pvs.vendor_id || ''), service: pvs.service || '', phone: pvs.phone || '', email: pvs.email || '', url: pvs.url || '', notes: pvs.notes || '', new_vendor_name: '', new_service_name: '', service_select_id: String(pvs.vendor_service_id || '') }); await loadAllVendors(); await loadServicesForVendor(pvs.vendor_id); }; const handleCancelVendor = () => { setEditingVendor(null); setVendorServices([]); setVendorSaveMessage({ type: '', text: '' }); }; const ensureVendorAndService = async () => { // Returns vendor_service_id to assign // If vendor_id is '__new__' or blank, create vendor using new_vendor_name // If service_select_id is '__new__' or blank, create vendor service using new_service_name + contact fields let vendorId = editingVendor.vendor_id; if (!vendorId || vendorId === '__new__') { const vendorName = (editingVendor.new_vendor_name || '').trim(); if (!vendorName) throw new Error('Please enter a vendor name'); const resp = await fetch('/api/vendors', { method: 'POST', headers: { 'Content-Type': 'application/json', ...(currentUsername ? { 'X-OTO-User': currentUsername } : {}) }, body: JSON.stringify({ name: vendorName }) }); if (!resp.ok) { const data = await resp.json().catch(() => ({})); throw new Error(data.detail || 'Failed to create vendor'); } const data = await resp.json(); vendorId = String(data.vendor?.id || data.vendor_id || ''); } // Existing service picked if (editingVendor.service_select_id && editingVendor.service_select_id !== '__new__') { return Number(editingVendor.service_select_id); } // Create new service const serviceName = (editingVendor.new_service_name || editingVendor.service || '').trim(); if (!serviceName) throw new Error('Please enter a service name'); const resp2 = await fetch('/api/vendor-services', { method: 'POST', headers: { 'Content-Type': 'application/json', ...(currentUsername ? { 'X-OTO-User': currentUsername } : {}) }, body: JSON.stringify({ vendor_id: Number(vendorId), service: serviceName, phone: editingVendor.phone || null, email: editingVendor.email || null, url: editingVendor.url || null, notes: editingVendor.notes || null }) }); if (!resp2.ok) { const data = await resp2.json().catch(() => ({})); throw new Error(data.detail || 'Failed to create vendor service'); } const data2 = await resp2.json(); return Number(data2.vendor_service?.id || data2.vendor_service_id || data2.id); }; const handleSaveVendor = async () => { if (!selectedProperty || !editingVendor) return; if (!canManageVendors) return; try { setIsSavingVendor(true); setVendorSaveMessage({ type: '', text: '' }); if (editingVendor.mode === 'edit') { // Update the vendor service details (phone/email/url/notes/service name), // then update the property's assignment (vendor_service_id). const vendorId = editingVendor.vendor_id ? Number(editingVendor.vendor_id) : null; if (!vendorId) throw new Error('Please select a vendor'); const vendorServiceId = editingVendor.service_select_id ? Number(editingVendor.service_select_id) : null; if (!vendorServiceId) throw new Error('Please select a service'); const serviceName = (editingVendor.service || '').trim(); if (!serviceName) throw new Error('Please enter a service name'); // 1) Update vendor service details (global for this vendor service) const respDetails = await fetch(`/api/vendor-services/${vendorServiceId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', ...(currentUsername ? { 'X-OTO-User': currentUsername } : {}) }, body: JSON.stringify({ vendor_id: vendorId, service: serviceName, phone: editingVendor.phone || null, email: editingVendor.email || null, url: editingVendor.url || null, notes: editingVendor.notes || null }) }); if (!respDetails.ok) { const data = await respDetails.json().catch(() => ({})); throw new Error(data.detail || 'Failed to update vendor service details'); } // 2) Update the property assignment (even if unchanged, this keeps updated_at/updated_by accurate) const respAssign = await fetch(`/api/properties/${selectedProperty.code}/vendor-services/${editingVendor.pvs_id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', ...(currentUsername ? { 'X-OTO-User': currentUsername } : {}) }, body: JSON.stringify({ vendor_service_id: vendorServiceId }) }); if (!respAssign.ok) { const data = await respAssign.json().catch(() => ({})); throw new Error(data.detail || 'Failed to update vendor assignment'); } } else { // add const vendorServiceId = await ensureVendorAndService(); const resp = await fetch(`/api/properties/${selectedProperty.code}/vendor-services`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(currentUsername ? { 'X-OTO-User': currentUsername } : {}) }, body: JSON.stringify({ vendor_service_id: vendorServiceId }) }); if (!resp.ok) { const data = await resp.json().catch(() => ({})); throw new Error(data.detail || 'Failed to assign vendor service'); } } setVendorSaveMessage({ type: 'success', text: 'Vendor information saved.' }); setEditingVendor(null); setVendorServices([]); await loadVendors(selectedProperty.code); } catch (err) { console.error(err); setVendorSaveMessage({ type: 'error', text: err.message || 'Failed to save vendor information.' }); } finally { setIsSavingVendor(false); } }; const handleRemoveVendor = async (pvs) => { if (!selectedProperty || !pvs) return; if (!canManageVendors) return; if (!window.confirm('Remove this vendor from the property?')) return; try { setIsSavingVendor(true); const resp = await fetch(`/api/properties/${selectedProperty.code}/vendor-services/${pvs.id}`, { method: 'DELETE', headers: currentUsername ? { 'X-OTO-User': currentUsername } : {} }); if (!resp.ok) { const data = await resp.json().catch(() => ({})); throw new Error(data.detail || 'Failed to remove vendor'); } setVendorSaveMessage({ type: 'success', text: 'Vendor removed from property.' }); await loadVendors(selectedProperty.code); } catch (err) { setVendorSaveMessage({ type: 'error', text: err.message || 'Failed to remove vendor.' }); } finally { setIsSavingVendor(false); } }; // ═══════════════════════════════════════════════════════════════════════════ // EFFECTS // ═══════════════════════════════════════════════════════════════════════════ useEffect(() => { if (!isOpen) return; // Don't load if modal is closed if (canSeeAllProperties) { loadProperties(); } else { loadSingleProperty(); } }, [isOpen, currentUsername, canSeeAllProperties, selectedPropertyContext?.property_code]); useEffect(() => { if (selectedProperty && activeTab === 'vendors') { loadVendors(selectedProperty.code); // Vendors tab also shows the "Venues at This Property" panel, so load venues + restaurants here too. loadVenues(selectedProperty.code); loadRestaurants(selectedProperty.code); } else if (selectedProperty && activeTab === 'venues') { loadVenues(selectedProperty.code); loadRestaurants(selectedProperty.code); loadAllVendors(); } }, [selectedProperty, activeTab]); // Auto-dismiss vendor save message after 3 seconds useEffect(() => { if (vendorSaveMessage.text) { const timer = setTimeout(() => { setVendorSaveMessage({ type: '', text: '' }); }, 3000); return () => clearTimeout(timer); } }, [vendorSaveMessage.text]); // Suggest existing concept names as they type (debounced) useEffect(() => { if (!addingConcept) { setConceptNameSuggestions([]); return; } const q = newConceptName.trim(); if (!q) { setConceptNameSuggestions([]); return; } let cancelled = false; const t = setTimeout(() => { fetch(`/api/restaurants/concept-names?q=${encodeURIComponent(q)}&limit=10`, { headers: currentUsername ? { 'X-OTO-User': currentUsername } : {} }) .then(res => res.ok ? res.json() : { concept_names: [] }) .then(data => { if (!cancelled) setConceptNameSuggestions(data.concept_names || []); }) .catch(() => { if (!cancelled) setConceptNameSuggestions([]); }); }, 250); return () => { cancelled = true; clearTimeout(t); }; }, [addingConcept, newConceptName, currentUsername]); // ═══════════════════════════════════════════════════════════════════════════ // PROPERTY DETAILS HANDLERS // ═══════════════════════════════════════════════════════════════════════════ const handleEditDetails = () => { if (!selectedProperty) return; setEditPhone(formatPhone(selectedProperty.phone_number || '')); setEditGMUsername(selectedProperty.general_manager_username || ''); setEditAbbreviation(selectedProperty.abbreviation || ''); // Initialize ownership/management toggles from current property values setEditIsOwned(!!selectedProperty.is_owned); const managedBy = selectedProperty.managed_by || ''; setEditIsManagedByOto(/oto/i.test(managedBy)); if (canEditDetails) { loadEligibleManagers(selectedProperty.code); } setIsEditingDetails(true); setSaveMessage({ type: '', text: '' }); }; const handleCancelDetails = () => { setIsEditingDetails(false); setEditPhone(''); setEditGMUsername(''); setEditAbbreviation(''); setEditIsOwned(false); setEditIsManagedByOto(false); setSaveMessage({ type: '', text: '' }); }; const handleSaveDetails = async () => { if (!selectedProperty) return; try { setIsSavingDetails(true); setSaveMessage({ type: '', text: '' }); const phoneDigits = editPhone.replace(/\D/g, ''); const body = { phone_number: phoneDigits || null, general_manager_username: editGMUsername || null, abbreviation: (editAbbreviation && editAbbreviation.trim()) || null, // Owned by OTO? Uses is_owned boolean. is_owned: !!editIsOwned, // Managed by OTO? When true, set a consistent label; when false, clear it. managed_by: editIsManagedByOto ? 'OTO Development' : null, amenities: Array.isArray(selectedProperty.amenities) ? selectedProperty.amenities : [] }; const response = await fetch(`/api/properties/${selectedProperty.code}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'X-OTO-User': currentUsername }, body: JSON.stringify(body) }); const data = await response.json(); if (!response.ok) throw new Error(data.detail || 'Failed to save'); const savedAmenities = data.property?.amenities ?? selectedProperty.amenities ?? []; setSelectedProperty(prev => ({ ...prev, phone_number: phoneDigits || null, general_manager_username: editGMUsername || null, general_manager_name: data.property?.general_manager_name ?? prev?.general_manager_name ?? null, general_manager_email: data.property?.general_manager_email ?? prev?.general_manager_email ?? null, is_owned: body.is_owned, managed_by: body.managed_by, abbreviation: (editAbbreviation && editAbbreviation.trim()) || null, amenities: savedAmenities })); const updatedAbbrev = (editAbbreviation && editAbbreviation.trim()) || null; setProperties(prev => prev.map(p => p.code === selectedProperty.code ? { ...p, ...data.property, abbreviation: data.property?.abbreviation ?? updatedAbbrev, amenities: savedAmenities } : p )); setIsEditingDetails(false); setSaveMessage({ type: 'success', text: 'Property details saved successfully' }); setTimeout(() => setSaveMessage({ type: '', text: '' }), 3000); } catch (err) { setSaveMessage({ type: 'error', text: err.message || 'Failed to save property details' }); } finally { setIsSavingDetails(false); } }; // ═══════════════════════════════════════════════════════════════════════════ // VENDOR HANDLERS // ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════ // VENDOR HANDLERS - TO BE IMPLEMENTED FOR NEW SYSTEM // ═══════════════════════════════════════════════════════════════════════════ // TODO: Implement handlers for new 3-table vendor system // - Add vendor service to property // - Edit property-specific vendor details // - Remove vendor service from property // ═══════════════════════════════════════════════════════════════════════════ // FILTERED PROPERTIES // ═══════════════════════════════════════════════════════════════════════════ const filteredProperties = properties.filter(prop => { if (!searchText) return true; const search = searchText.toLowerCase(); return ( prop.code?.toLowerCase().includes(search) || prop.name?.toLowerCase().includes(search) || prop.abbreviation?.toLowerCase().includes(search) || prop.city?.toLowerCase().includes(search) || prop.state?.toLowerCase().includes(search) ); }); const handleOpenAddProperty = () => { setNewPropertyCode(''); setNewPropertyName(''); setNewPropertyAddress(''); setNewPropertyCity(''); setNewPropertyState(''); setNewPropertyZip(''); setAddPropertyError(''); setShowAddProperty(true); }; const handleCloseAddProperty = () => { setShowAddProperty(false); setAddPropertyError(''); }; const handleCreateProperty = async (e) => { e.preventDefault(); const code = (newPropertyCode || '').trim().toUpperCase(); const name = (newPropertyName || '').trim(); if (!code) { setAddPropertyError('Property code is required.'); return; } if (!name) { setAddPropertyError('Property name is required.'); return; } setSavingNewProperty(true); setAddPropertyError(''); try { const response = await fetch('/api/properties', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-OTO-User': currentUsername }, body: JSON.stringify({ code, name, address: (newPropertyAddress || '').trim() || undefined, city: (newPropertyCity || '').trim() || undefined, state: (newPropertyState || '').trim() || undefined, zip: (newPropertyZip || '').trim() || undefined, }) }); const data = await response.json(); if (!response.ok) throw new Error(data.detail || 'Failed to create property'); await loadProperties(); const created = data.property || { code, name }; setSelectedProperty(created); if (onPropertyChange) { onPropertyChange({ property_code: created.code, name: created.name, address: created.address, city: created.city, state: created.state, zip: created.zip }); } if (onDataChange) onDataChange(); handleCloseAddProperty(); } catch (err) { setAddPropertyError(err.message || 'Failed to create property.'); } finally { setSavingNewProperty(false); } }; // ═══════════════════════════════════════════════════════════════════════════ // RENDER // ═══════════════════════════════════════════════════════════════════════════ if (!isOpen) return null; return (
{loadError}
{loadError}
{selectedProperty.name}
Missing General Manager
This property does not have a General Manager assigned. Click "Edit" to assign one.
{selectedProperty.phone_number ? formatPhone(selectedProperty.phone_number) : Not set }
)}{selectedProperty.is_owned ? 'Yes, OTO owns this hotel' : 'No, OTO does not own this hotel'}
)}{(/oto/i.test(selectedProperty.managed_by || '')) ? 'Yes, OTO manages this hotel' : 'No, OTO does not manage this hotel'}
)}{selectedProperty.general_manager_name || Not set}
) ) : ( selectedProperty.general_manager_name ? ({selectedProperty.general_manager_name}
{selectedProperty.general_manager_email}
Not assigned
) )}Active properties, staff, dining venues, and knowledge base
Click a row to view the list
No properties in this category.
; } return (Manage technology vendors for {selectedProperty.code}
No vendor information yet
No vendors configured yet
{service.service}
Property amenities and venue locations for {selectedProperty.code}.
e.g. self-pour beer. Add any; use “Other” for more.
{(() => { const FEATURE_OPTIONS = [{ id: 'self_pour_system', label: 'Self-pour beer system', defaultValue: 'e.g. iPourIt' }]; const predefinedIds = FEATURE_OPTIONS.map(o => o.id); const features = editingVenue.features || {}; const entries = [...FEATURE_OPTIONS.filter(o => features[o.id] != null).map(o => ({ key: o.id, label: o.label, defaultValue: o.defaultValue, isPredefined: true })), ...Object.entries(features).filter(([k]) => !predefinedIds.includes(k)).map(([k, v]) => ({ key: k, label: null, isPredefined: false }))]; return (Companies that support this location.
New restaurant concept
Type to see existing concepts; click a suggestion to use it and close the list.
e.g. self-pour beer, POS, reservation. Add any that apply; you can add more types anytime.
{(() => { const FEATURE_OPTIONS = [ { id: 'self_pour_system', label: 'Self-pour beer system', defaultValue: 'e.g. iPourIt' }, ]; const predefinedIds = FEATURE_OPTIONS.map(o => o.id); const features = editingVenue.features || {}; const featureEntries = [ ...FEATURE_OPTIONS.filter(o => features[o.id] !== undefined && features[o.id] !== null).map(o => ({ key: o.id, label: o.label, defaultValue: o.defaultValue, isPredefined: true })), ...Object.entries(features).filter(([k]) => !predefinedIds.includes(k)).map(([k, v]) => ({ key: k, label: null, defaultValue: '', isPredefined: false, value: typeof v === 'object' && v !== null ? v : v })) ]; return (Companies or contacts that support this location (e.g. self-pour, POS, or other technology).
No concepts yet. Use “+ Add concept” above to add a restaurant concept, then add locations under it.
) : (Select a property