diff --git a/Core/Resgrid.Config/InfoConfig.cs b/Core/Resgrid.Config/InfoConfig.cs index 95ec0c07b..d98529add 100644 --- a/Core/Resgrid.Config/InfoConfig.cs +++ b/Core/Resgrid.Config/InfoConfig.cs @@ -65,5 +65,10 @@ public string GetLogonUrl() { return AppUrl + "/Account/LogOn"; } + + public string GetRegisterUrl() + { + return AppUrl + "/Account/Register"; + } } } diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx index 5a26e4b7b..34952bac1 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx @@ -190,4 +190,77 @@ أخرى هل أنت متأكد من رغبتك في حذف بث الفيديو هذا؟ لم تتم إضافة أي بث فيديو لهذه المكالمة. + إضافي + تم إيفاد جميع {0} + هل أنت متأكد؟ + تم تحديث البلاغ + تم تحديث هذا البلاغ، يرجى تحديث الصفحة لعرض أحدث المعلومات. + البلاغات للسنة + فشل تسجيل الوصول. يرجى المحاولة مرة أخرى. + حسناً + متأخر + حقول مخصصة + الوجهة + حدد نقطة اهتمام للوجهة مثل مستشفى أو ملجأ أو مركز نقل أو منطقة تجمع. + محادثة الإيفاد + وقت الإيفاد + أدخل الرسالة هنا... + وقت الوصول المتوقع + الذهاب إلى الإيفاد + الذهاب إلى الرئيسية + مجموعة + (تم اختيار المجموعة) + الموقع الداخلي + الرجاء إدخال قيم رقمية صحيحة لخط العرض وخط الطول. + خط العرض + ع + خط الطول + ط + تنبيهات الملاحظات + المستخدمون المتصلون + عدد الموظفين + طباعة (تصدير) العرض + أساسي + نص البروتوكول لـ {0} + الأسئلة الخاصة بـ {0} + إزالة رابط البلاغ هذا + تم إرسال الطلب! + هل أنت متأكد أنك تريد إعادة فتح هذا البلاغ؟ سيؤدي ذلك إلى حذف جميع بيانات الإغلاق المرتبطة، بما في ذلك من أغلق البلاغ، ومتى تم إغلاقه، وحالة الإغلاق، وأي ملاحظات إغلاق. + جارٍ معالجة طلبك لإعادة فتح البلاغ. يرجى مراجعة صفحة لوحة التحكم الخاصة بالبلاغات المفتوحة لعرض البلاغ. + المسار من {0} + البحث في نقاط الاهتمام للوجهة... + البحث في المواقع الداخلية... + تعيين دبوس على الخريطة + التوظيف + المستخدمون/الوحدات غير المجمّعون + تعذّر على What3Words العثور على موقع لتلك الكلمات. تأكد من أنها 3 كلمات مفصولة بنقاط. + what.three.words + الإجابة على الأسئلة + منخفض + متوسط + عالٍ + طارئ + ملغى + نوع الوحدة + التعرض للمواد الخطرة + تدوير القطاع + إعادة التأهيل + ملف + غير نشط + يرجى تحديد نقطة اهتمام صالحة للوجهة. + قام بتسجيل الخروج. + يجب تحديد ملف لإرفاقه. + نوع المستند ({0}) غير قابل للاستيراد. + الملف كبير جداً، ويجب أن يكون أصغر من 10 ميجابايت. + لا يوجد موقع + بلاغ عام + اختر جهة اتصال + لا توجد وجهة + نقاط الاهتمام + تجاوز + المسافة + المدة + خطوة + كم + دقيقة diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx index fb6943bb3..553405227 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx @@ -611,4 +611,223 @@ Keine Videoübertragungen für diesen Einsatz hinzugefügt. + + Zusätzlich + + + Alle von {0} alarmiert + + + Sind Sie sicher? + + + Einsatz aktualisiert + + + Dieser Einsatz wurde aktualisiert, bitte laden Sie die Seite neu, um die neuesten Informationen anzuzeigen. + + + Einsätze für das Jahr + + + Eincheckvorgang fehlgeschlagen. Bitte versuchen Sie es erneut. + + + OK + + + ÜBERFÄLLIG + + + Benutzerdefinierte Felder + + + Ziel + + + Wählen Sie einen Ziel-POI wie ein Krankenhaus, eine Unterkunft, ein Übergabezentrum oder einen Bereitstellungsraum. + + + Leitstellen-Chat + + + Alarmierungszeit + + + Nachricht hier eingeben... + + + ETA + + + Zur Leitstelle + + + Zur Startseite + + + Gruppe + + + (Gruppe ausgewählt) + + + Innenraumstandort + + + Bitte geben Sie gültige numerische Breiten- und Längengradwerte ein. + + + Breitengrad + + + Br. + + + Längengrad + + + Lg. + + + Hinweismeldungen + + + Online-Benutzer + + + Personalanzahl + + + Ansicht drucken (exportieren) + + + Primär + + + Protokolltext für {0} + + + Fragen für {0} + + + Diesen Einsatzlink entfernen + + + Anfrage gesendet! + + + Sind Sie sicher, dass Sie diesen Einsatz wieder öffnen möchten? Dadurch werden alle zugehörigen Abschlussdaten gelöscht, einschließlich wer den Einsatz geschlossen hat, wann er geschlossen wurde, sein Abschlussstatus und alle Abschlussnotizen. + + + Ihre Anfrage zur Wiedereröffnung des Einsatzes wird bearbeitet. Bitte überprüfen Sie die Seite „Offene Einsätze“ im Dashboard, um den Einsatz anzuzeigen. + + + Route von {0} + + + Ziel-POIs suchen... + + + Innenraumstandorte suchen... + + + Pin auf Karte setzen + + + Personalbesetzung + + + Nicht gruppierte Benutzer/Einheiten + + + What3Words konnte für diese Wörter keinen Standort finden. Stellen Sie sicher, dass es 3 durch Punkte getrennte Wörter sind. + + + what.three.words + + + Fragen beantworten + + + Niedrig + + + Mittel + + + Hoch + + + Notfall + + + Abgebrochen + + + Einheitstyp + + + Gefahrstoffexposition + + + Sektorrotation + + + Reha + + + Datei + + + Inaktiv + + + Bitte wählen Sie einen gültigen Ziel-POI aus. + + + hat sich abgemeldet. + + + Sie müssen eine Datei zum Anhängen auswählen. + + + Dokumenttyp ({0}) kann nicht importiert werden. + + + Datei ist zu groß und muss kleiner als 10 MB sein. + + + Kein Standort + + + Allgemeiner Einsatz + + + Kontakt auswählen + + + Kein Ziel + + + POIs + + + Überschrieben + + + Entfernung + + + Dauer + + + Schritt + + + km + + + min + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx index 03af7278b..53765a6a9 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx @@ -660,4 +660,77 @@ No video feeds have been added to this call. + Additional + All of {0} Dispatched + Are you sure? + Call Updated + This call was updated, please refresh the page to view the latest information. + Calls for Year + Failed to perform check-in. Please try again. + OK + OVERDUE + Custom Fields + Destination + Select a destination POI such as a hospital, shelter, transfer center, or staging area. + Dispatch Chat + Dispatch Time + Enter message here... + ETA + Go to Dispatch + Go to Home + Group + (Group Selected) + Indoor Location + Please enter valid numeric latitude and longitude values. + Latitude + Lat + Longitude + Lng + Note Alerts + Online Users + Personnel Count + Print (Export) View + Primary + Protocol Text for {0} + Questions for {0} + Remove this call link + Request Sent! + Are you sure you want to re-open this call? This will delete all the associated close data, including who closed the call, when it was closed, its close state, and any close notes. + Your request to re-open the call is being processed. Please check the Open Calls dashboard page to view the call. + Route from {0} + Search destination POIs... + Search indoor locations... + Set Pin on Map + Staffing + Ungrouped Users/Units + What3Words was unable to find a location for those words. Ensure they are 3 words separated by periods. + what.three.words + Answer Questions + Low + Medium + High + Emergency + Cancelled + Unit Type + Hazmat Exposure + Sector Rotation + Rehab + File + Inactive + Please select a valid destination POI. + logged off. + You must select a file to attach. + Document type ({0}) is not importable. + File is too large, must be smaller then 10MB. + No Location + Generic Call + Select Contact + No Destination + POIs + Override + Distance + Duration + Step + km + min diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx index 6d06e966a..e7ab9fc0b 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx @@ -660,4 +660,223 @@ No se han agregado transmisiones de video a esta llamada. + + Adicional + + + Todo {0} despachado + + + ¿Está seguro? + + + Llamada actualizada + + + Esta llamada fue actualizada, por favor actualice la página para ver la información más reciente. + + + Llamadas por año + + + No se pudo realizar el registro de entrada. Por favor, inténtelo de nuevo. + + + OK + + + VENCIDO + + + Campos personalizados + + + Destino + + + Seleccione un PDI de destino como un hospital, refugio, centro de transferencia o área de preparación. + + + Chat de despacho + + + Hora de despacho + + + Ingrese el mensaje aquí... + + + ETA + + + Ir al despacho + + + Ir al inicio + + + Grupo + + + (Grupo seleccionado) + + + Ubicación interior + + + Por favor ingrese valores numéricos válidos de latitud y longitud. + + + Latitud + + + Lat + + + Longitud + + + Lon + + + Alertas de notas + + + Usuarios en línea + + + Recuento de personal + + + Vista de impresión (exportar) + + + Primario + + + Texto del protocolo para {0} + + + Preguntas para {0} + + + Eliminar este enlace de llamada + + + ¡Solicitud enviada! + + + ¿Está seguro de que desea reabrir esta llamada? Esto eliminará todos los datos de cierre asociados, incluido quién cerró la llamada, cuándo se cerró, su estado de cierre y cualquier nota de cierre. + + + Su solicitud para reabrir la llamada está siendo procesada. Por favor, consulte la página del panel de llamadas abiertas para ver la llamada. + + + Ruta desde {0} + + + Buscar PDI de destino... + + + Buscar ubicaciones interiores... + + + Establecer pin en el mapa + + + Personal + + + Usuarios/Unidades no agrupados + + + What3Words no pudo encontrar una ubicación para esas palabras. Asegúrese de que sean 3 palabras separadas por puntos. + + + what.three.words + + + Responder preguntas + + + Baja + + + Media + + + Alta + + + Emergencia + + + Cancelada + + + Tipo de unidad + + + Exposición a materiales peligrosos + + + Rotación de sector + + + Rehabilitación + + + Archivo + + + Inactivo + + + Seleccione un PDI de destino válido. + + + cerró sesión. + + + Debe seleccionar un archivo para adjuntarlo. + + + El tipo de documento ({0}) no se puede importar. + + + El archivo es demasiado grande; debe ser menor de 10 MB. + + + Sin ubicación + + + Llamada genérica + + + Seleccionar contacto + + + Sin destino + + + PDI + + + Anulación + + + Distancia + + + Duración + + + Paso + + + km + + + min + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx index 215f5d882..9a0454bcc 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx @@ -611,4 +611,223 @@ Aucun flux vidéo n'a été ajouté à cet appel. + + Supplémentaire + + + Tout {0} dépêché + + + Êtes-vous sûr ? + + + Appel mis à jour + + + Cet appel a été mis à jour, veuillez actualiser la page pour voir les dernières informations. + + + Appels pour l'année + + + Échec du pointage. Veuillez réessayer. + + + OK + + + EN RETARD + + + Champs personnalisés + + + Destination + + + Sélectionnez un POI de destination tel qu'un hôpital, un abri, un centre de transfert ou une zone de regroupement. + + + Chat de dispatch + + + Heure de dispatch + + + Saisissez votre message ici... + + + ETA + + + Aller au dispatch + + + Aller à l'accueil + + + Groupe + + + (Groupe sélectionné) + + + Localisation intérieure + + + Veuillez entrer des valeurs numériques valides de latitude et longitude. + + + Latitude + + + Lat + + + Longitude + + + Lon + + + Alertes de notes + + + Utilisateurs en ligne + + + Effectif du personnel + + + Vue impression (export) + + + Principal + + + Texte du protocole pour {0} + + + Questions pour {0} + + + Supprimer ce lien d'appel + + + Demande envoyée ! + + + Êtes-vous sûr de vouloir rouvrir cet appel ? Cela supprimera toutes les données de clôture associées, notamment qui a clôturé l'appel, quand il a été clôturé, son état de clôture et toutes les notes de clôture. + + + Votre demande de réouverture de l'appel est en cours de traitement. Veuillez consulter le tableau de bord des appels ouverts pour voir l'appel. + + + Itinéraire depuis {0} + + + Rechercher des POI de destination... + + + Rechercher des localisations intérieures... + + + Placer une épingle sur la carte + + + Effectifs + + + Utilisateurs/Unités non groupés + + + What3Words n'a pas pu trouver de localisation pour ces mots. Assurez-vous qu'il s'agit de 3 mots séparés par des points. + + + what.three.words + + + Répondre aux questions + + + Faible + + + Moyenne + + + Élevée + + + Urgence + + + Annulé + + + Type d'unité + + + Exposition matières dangereuses + + + Rotation de secteur + + + Réhabilitation + + + Fichier + + + Inactif + + + Veuillez sélectionner un POI de destination valide. + + + s'est déconnecté. + + + Vous devez sélectionner un fichier à joindre. + + + Le type de document ({0}) n'est pas importable. + + + Le fichier est trop volumineux et doit être inférieur à 10 Mo. + + + Aucun emplacement + + + Appel générique + + + Sélectionner un contact + + + Aucune destination + + + POI + + + Surcharge + + + Distance + + + Durée + + + Étape + + + km + + + min + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx index 9e6c0afae..4aaabc4ac 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx @@ -611,4 +611,223 @@ Nessun feed video è stato aggiunto a questa chiamata. + + Aggiuntivo + + + Tutto {0} inviato + + + Sei sicuro? + + + Chiamata aggiornata + + + Questa chiamata è stata aggiornata, aggiorna la pagina per vedere le informazioni più recenti. + + + Chiamate per anno + + + Check-in non riuscito. Riprova. + + + OK + + + IN RITARDO + + + Campi personalizzati + + + Destinazione + + + Seleziona un POI di destinazione come un ospedale, rifugio, centro di trasferimento o area di raccolta. + + + Chat di dispatch + + + Ora di dispatch + + + Inserisci qui il messaggio... + + + ETA + + + Vai al dispatch + + + Vai alla home + + + Gruppo + + + (Gruppo selezionato) + + + Posizione interna + + + Inserisci valori numerici validi di latitudine e longitudine. + + + Latitudine + + + Lat + + + Longitudine + + + Lon + + + Avvisi note + + + Utenti online + + + Conteggio personale + + + Vista stampa (esporta) + + + Primario + + + Testo del protocollo per {0} + + + Domande per {0} + + + Rimuovi questo collegamento alla chiamata + + + Richiesta inviata! + + + Sei sicuro di voler riaprire questa chiamata? Ciò eliminerà tutti i dati di chiusura associati, inclusi chi ha chiuso la chiamata, quando è stata chiusa, il suo stato di chiusura e qualsiasi nota di chiusura. + + + La tua richiesta di riaprire la chiamata è in elaborazione. Controlla la pagina della dashboard delle chiamate aperte per visualizzare la chiamata. + + + Percorso da {0} + + + Cerca POI di destinazione... + + + Cerca posizioni interne... + + + Imposta un pin sulla mappa + + + Personale + + + Utenti/Unità non raggruppati + + + What3Words non è riuscito a trovare una posizione per quelle parole. Assicurati che siano 3 parole separate da punti. + + + what.three.words + + + Rispondi alle domande + + + Bassa + + + Media + + + Alta + + + Emergenza + + + Annullata + + + Tipo di unità + + + Esposizione a materiali pericolosi + + + Rotazione del settore + + + Riabilitazione + + + File + + + Inattivo + + + Seleziona un POI di destinazione valido. + + + si è disconnesso. + + + Devi selezionare un file da allegare. + + + Il tipo di documento ({0}) non è importabile. + + + Il file è troppo grande e deve essere inferiore a 10 MB. + + + Nessuna posizione + + + Chiamata generica + + + Seleziona contatto + + + Nessuna destinazione + + + POI + + + Sovrascrittura + + + Distanza + + + Durata + + + Passo + + + km + + + min + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx index 49a46cdb0..877d4816a 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx @@ -611,4 +611,223 @@ Nie dodano żadnych transmisji wideo do tego zgłoszenia. + + Dodatkowe + + + Wszyscy z {0} zadysponowani + + + Czy na pewno? + + + Zgłoszenie zaktualizowane + + + To zgłoszenie zostało zaktualizowane, odśwież stronę, aby zobaczyć najnowsze informacje. + + + Zgłoszenia w roku + + + Nie udało się wykonać meldunku. Spróbuj ponownie. + + + OK + + + PO TERMINIE + + + Pola niestandardowe + + + Cel + + + Wybierz docelowy POI, taki jak szpital, schronienie, centrum transferowe lub obszar koncentracji. + + + Czat dyspozytorni + + + Czas dysponowania + + + Wpisz wiadomość tutaj... + + + ETA + + + Przejdź do dyspozytorni + + + Przejdź do strony głównej + + + Grupa + + + (Wybrano grupę) + + + Lokalizacja wewnętrzna + + + Wprowadź prawidłowe numeryczne wartości szerokości i długości geograficznej. + + + Szerokość geograficzna + + + Szer. + + + Długość geograficzna + + + Dług. + + + Alerty notatek + + + Użytkownicy online + + + Liczba personelu + + + Widok do druku (eksport) + + + Główny + + + Tekst protokołu dla {0} + + + Pytania dla {0} + + + Usuń to powiązanie zgłoszenia + + + Żądanie wysłane! + + + Czy na pewno chcesz ponownie otworzyć to zgłoszenie? Spowoduje to usunięcie wszystkich powiązanych danych zamknięcia, w tym kto zamknął zgłoszenie, kiedy zostało zamknięte, jego status zamknięcia oraz wszelkich notatek zamknięcia. + + + Twoje żądanie ponownego otwarcia zgłoszenia jest przetwarzane. Sprawdź stronę pulpitu otwartych zgłoszeń, aby zobaczyć zgłoszenie. + + + Trasa od {0} + + + Szukaj docelowych POI... + + + Szukaj lokalizacji wewnętrznych... + + + Ustaw pinezkę na mapie + + + Obsada + + + Niezgrupowani użytkownicy/jednostki + + + What3Words nie mógł znaleźć lokalizacji dla tych słów. Upewnij się, że są to 3 słowa oddzielone kropkami. + + + what.three.words + + + Odpowiedz na pytania + + + Niski + + + Średni + + + Wysoki + + + Alarmowy + + + Anulowane + + + Typ jednostki + + + Narażenie na materiały niebezpieczne + + + Rotacja sektora + + + Rehabilitacja + + + Plik + + + Nieaktywny + + + Wybierz prawidłowy docelowy POI. + + + wylogował się. + + + Musisz wybrać plik do dołączenia. + + + Typ dokumentu ({0}) nie może zostać zaimportowany. + + + Plik jest zbyt duży i musi być mniejszy niż 10 MB. + + + Brak lokalizacji + + + Zgłoszenie ogólne + + + Wybierz kontakt + + + Brak celu + + + POI + + + Nadpisanie + + + Odległość + + + Czas trwania + + + Krok + + + km + + + min + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx index ab207c860..d3ca02d29 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx @@ -611,4 +611,223 @@ Inga videoflöden har lagts till för detta ärende. + + Ytterligare + + + Alla i {0} utlarmade + + + Är du säker? + + + Ärende uppdaterat + + + Detta ärende har uppdaterats, uppdatera sidan för att se den senaste informationen. + + + Ärenden för året + + + Incheckning misslyckades. Försök igen. + + + OK + + + FÖRSENAD + + + Anpassade fält + + + Destination + + + Välj en destinations-POI som ett sjukhus, skydd, överföringscenter eller uppställningsområde. + + + Larmchatt + + + Larmtid + + + Skriv meddelande här... + + + ETA + + + Gå till larm + + + Gå till startsidan + + + Grupp + + + (Grupp vald) + + + Inomhusplats + + + Ange giltiga numeriska värden för latitud och longitud. + + + Latitud + + + Lat + + + Longitud + + + Lon + + + Notisvarningar + + + Användare online + + + Personalantal + + + Utskriftsvy (export) + + + Primär + + + Protokolltext för {0} + + + Frågor för {0} + + + Ta bort denna ärendelänk + + + Begäran skickad! + + + Är du säker på att du vill öppna detta ärende igen? Detta tar bort all tillhörande stängningsdata, inklusive vem som stängde ärendet, när det stängdes, dess stängningsstatus och eventuella stängningsanteckningar. + + + Din begäran om att öppna ärendet igen behandlas. Kontrollera dashboardsidan för öppna ärenden för att visa ärendet. + + + Rutt från {0} + + + Sök destinations-POI:er... + + + Sök inomhusplatser... + + + Sätt nål på kartan + + + Bemanning + + + Ogrupperade användare/enheter + + + What3Words kunde inte hitta en plats för dessa ord. Kontrollera att det är 3 ord separerade med punkter. + + + what.three.words + + + Besvara frågor + + + Låg + + + Medel + + + Hög + + + Akut + + + Avbruten + + + Enhetstyp + + + Exponering för farliga ämnen + + + Sektorrotation + + + Rehab + + + Fil + + + Inaktiv + + + Välj en giltig destinations-POI. + + + loggade ut. + + + Du måste välja en fil att bifoga. + + + Dokumenttypen ({0}) kan inte importeras. + + + Filen är för stor och måste vara mindre än 10 MB. + + + Ingen plats + + + Generiskt ärende + + + Välj kontakt + + + Ingen destination + + + POI + + + Åsidosättning + + + Avstånd + + + Varaktighet + + + Steg + + + km + + + min + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx index 9c03bc8f5..fceda8643 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx @@ -611,4 +611,223 @@ До цього виклику не додано жодних відеотрансляцій. + + Додатково + + + Усіх з {0} направлено + + + Ви впевнені? + + + Виклик оновлено + + + Цей виклик було оновлено, оновіть сторінку, щоб побачити найновішу інформацію. + + + Виклики за рік + + + Не вдалося виконати відмітку. Спробуйте ще раз. + + + OK + + + ПРОСТРОЧЕНО + + + Спеціальні поля + + + Пункт призначення + + + Виберіть POI призначення, наприклад лікарню, укриття, транзитний центр або зону збору. + + + Чат диспетчеризації + + + Час диспетчеризації + + + Введіть повідомлення тут... + + + ETA + + + Перейти до диспетчеризації + + + Перейти на головну + + + Група + + + (Групу вибрано) + + + Внутрішнє розташування + + + Введіть дійсні числові значення широти та довготи. + + + Широта + + + Шир. + + + Довгота + + + Довг. + + + Сповіщення про примітки + + + Користувачі онлайн + + + Кількість персоналу + + + Друкований вигляд (експорт) + + + Основний + + + Текст протоколу для {0} + + + Питання для {0} + + + Видалити це посилання на виклик + + + Запит надіслано! + + + Ви впевнені, що хочете знову відкрити цей виклик? Це видалить усі пов’язані дані закриття, зокрема хто закрив виклик, коли його було закрито, статус закриття та будь-які примітки щодо закриття. + + + Ваш запит на повторне відкриття виклику обробляється. Перевірте сторінку панелі відкритих викликів, щоб переглянути виклик. + + + Маршрут від {0} + + + Шукати POI призначення... + + + Шукати внутрішні розташування... + + + Поставити позначку на мапі + + + Укомплектування + + + Незгруповані користувачі/підрозділи + + + What3Words не вдалося знайти місце для цих слів. Переконайтеся, що це 3 слова, розділені крапками. + + + what.three.words + + + Відповісти на запитання + + + Низький + + + Середній + + + Високий + + + Екстрений + + + Скасовано + + + Тип підрозділу + + + Вплив небезпечних матеріалів + + + Ротація сектору + + + Реабілітація + + + Файл + + + Неактивний + + + Виберіть дійсний POI призначення. + + + вийшов із системи. + + + Потрібно вибрати файл для вкладення. + + + Тип документа ({0}) не можна імпортувати. + + + Файл завеликий і має бути меншим за 10 МБ. + + + Немає місця + + + Загальний виклик + + + Виберіть контакт + + + Немає пункту призначення + + + POI + + + Перевизначення + + + Відстань + + + Тривалість + + + Крок + + + км + + + хв + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.ar.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.ar.resx index ed3a75a11..3bc39e499 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.ar.resx @@ -78,4 +78,7 @@ حالات الوحدات + + المسارات + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.de.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.de.resx index e97f8a49b..854c231de 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.de.resx @@ -83,4 +83,7 @@ Unit Statuses + + Routen + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.en.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.en.resx index fa91f9ef3..e1e1345c6 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.en.resx @@ -132,4 +132,7 @@ Unit Statuses - \ No newline at end of file + + Routes + + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.es.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.es.resx index 72f18230c..8d5c2edde 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.es.resx @@ -132,4 +132,7 @@ Estado de la unidad - \ No newline at end of file + + Rutas + + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.fr.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.fr.resx index 5764f89f0..4cf0d3d93 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.fr.resx @@ -83,4 +83,7 @@ Unit Statuses + + Itinéraires + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.it.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.it.resx index 32f753a6b..5faba56d7 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.it.resx @@ -83,4 +83,7 @@ Unit Statuses + + Percorsi + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.pl.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.pl.resx index 3270d3977..c2e715708 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.pl.resx @@ -83,4 +83,7 @@ Unit Statuses + + Trasy + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.sv.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.sv.resx index c8b1e003b..f9b252102 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.sv.resx @@ -83,4 +83,7 @@ Unit Statuses + + Rutter + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.uk.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.uk.resx index 87bc8e1a0..eff0beeec 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.uk.resx @@ -83,4 +83,7 @@ Unit Statuses + + Маршрути + diff --git a/Core/Resgrid.Localization/Common.ar.resx b/Core/Resgrid.Localization/Common.ar.resx index 3c2432455..fde95f7eb 100644 --- a/Core/Resgrid.Localization/Common.ar.resx +++ b/Core/Resgrid.Localization/Common.ar.resx @@ -187,4 +187,7 @@ نعم الحقول المعرَّفة من قبل المستخدم أقسامك + البلاغات + المحطات + نقاط الاهتمام diff --git a/Core/Resgrid.Localization/Common.de.resx b/Core/Resgrid.Localization/Common.de.resx index 9c4e86763..43b41f5f6 100644 --- a/Core/Resgrid.Localization/Common.de.resx +++ b/Core/Resgrid.Localization/Common.de.resx @@ -614,5 +614,14 @@ Ihre Abteilungen + + Einsätze + + + Stations + + + POIs + diff --git a/Core/Resgrid.Localization/Common.en.resx b/Core/Resgrid.Localization/Common.en.resx index c18f588d4..48a6331ff 100644 --- a/Core/Resgrid.Localization/Common.en.resx +++ b/Core/Resgrid.Localization/Common.en.resx @@ -171,6 +171,9 @@ Calls + + Calls + Call Timestamp @@ -447,6 +450,9 @@ Protocols + + POI + Push-To-Talk @@ -525,6 +531,12 @@ Stations & Groups + + Stations + + + POIs + Status diff --git a/Core/Resgrid.Localization/Common.es.resx b/Core/Resgrid.Localization/Common.es.resx index 004002097..084c25872 100644 --- a/Core/Resgrid.Localization/Common.es.resx +++ b/Core/Resgrid.Localization/Common.es.resx @@ -654,4 +654,13 @@ Tus Departamentos + + Llamadas + + + Estaciones + + + PDI + diff --git a/Core/Resgrid.Localization/Common.fr.resx b/Core/Resgrid.Localization/Common.fr.resx index 767327a2c..c69c50335 100644 --- a/Core/Resgrid.Localization/Common.fr.resx +++ b/Core/Resgrid.Localization/Common.fr.resx @@ -614,5 +614,14 @@ Vos départements + + Appels + + + Stations + + + POI + diff --git a/Core/Resgrid.Localization/Common.it.resx b/Core/Resgrid.Localization/Common.it.resx index 899d9d2aa..a0c62d5e5 100644 --- a/Core/Resgrid.Localization/Common.it.resx +++ b/Core/Resgrid.Localization/Common.it.resx @@ -614,5 +614,14 @@ I tuoi dipartimenti + + Chiamate + + + Stations + + + POI + diff --git a/Core/Resgrid.Localization/Common.pl.resx b/Core/Resgrid.Localization/Common.pl.resx index 6b5fd2939..b0fa14ff4 100644 --- a/Core/Resgrid.Localization/Common.pl.resx +++ b/Core/Resgrid.Localization/Common.pl.resx @@ -614,5 +614,14 @@ Twoje oddziały + + Zgłoszenia + + + Stations + + + POI + diff --git a/Core/Resgrid.Localization/Common.sv.resx b/Core/Resgrid.Localization/Common.sv.resx index 48ef5e066..ab731ac51 100644 --- a/Core/Resgrid.Localization/Common.sv.resx +++ b/Core/Resgrid.Localization/Common.sv.resx @@ -614,5 +614,14 @@ Dina avdelningar + + Larm + + + Stations + + + POI + diff --git a/Core/Resgrid.Localization/Common.uk.resx b/Core/Resgrid.Localization/Common.uk.resx index 514430bd2..60bf4fc8d 100644 --- a/Core/Resgrid.Localization/Common.uk.resx +++ b/Core/Resgrid.Localization/Common.uk.resx @@ -614,5 +614,14 @@ Ваші підрозділи + + Виклики + + + Stations + + + POI + diff --git a/Core/Resgrid.Model/ActionLog.cs b/Core/Resgrid.Model/ActionLog.cs index ec68f9f06..1dd998cd3 100644 --- a/Core/Resgrid.Model/ActionLog.cs +++ b/Core/Resgrid.Model/ActionLog.cs @@ -177,6 +177,24 @@ public bool HasLocation() public static class ActionLogExtensions { + public static DestinationEntityTypes GetEffectiveDestinationType(this ActionLog actionLog) + { + if (actionLog == null) + return DestinationEntityTypes.None; + + var explicitDestinationType = actionLog.DestinationType.ToDestinationEntityType(); + if (explicitDestinationType != DestinationEntityTypes.None) + return explicitDestinationType; + + if (actionLog.ActionTypeId == (int)ActionTypes.RespondingToScene) + return DestinationEntityTypes.Call; + + if (actionLog.ActionTypeId == (int)ActionTypes.AvailableStation || actionLog.ActionTypeId == (int)ActionTypes.RespondingToStation) + return DestinationEntityTypes.Station; + + return DestinationEntityTypes.None; + } + public static int GetWeightForAction(this ActionLog actionLog) { if (actionLog == null) diff --git a/Core/Resgrid.Model/Call.cs b/Core/Resgrid.Model/Call.cs index 0bf24acda..f099dcf36 100644 --- a/Core/Resgrid.Model/Call.cs +++ b/Core/Resgrid.Model/Call.cs @@ -73,6 +73,9 @@ public class Call : IEntity [ProtoMember(14)] public string GeoLocationData { get; set; } + [ProtoMember(34)] + public int? DestinationPoiId { get; set; } + [ProtoMember(15)] public DateTime LoggedOn { get; set; } diff --git a/Core/Resgrid.Model/Coordinates.cs b/Core/Resgrid.Model/Coordinates.cs index 6b533b887..c8aacadeb 100644 --- a/Core/Resgrid.Model/Coordinates.cs +++ b/Core/Resgrid.Model/Coordinates.cs @@ -9,5 +9,7 @@ public class Coordinates public double? Longitude { get; set; } [ProtoMember(2)] public double? Latitude { get; set; } + [ProtoMember(3)] + public string Name { get; set; } } -} \ No newline at end of file +} diff --git a/Core/Resgrid.Model/CustomStateDetailTypes.cs b/Core/Resgrid.Model/CustomStateDetailTypes.cs index d112b0a29..8fa448a87 100644 --- a/Core/Resgrid.Model/CustomStateDetailTypes.cs +++ b/Core/Resgrid.Model/CustomStateDetailTypes.cs @@ -5,6 +5,52 @@ public enum CustomStateDetailTypes None = 0, Stations = 1, Calls = 2, - CallsAndStations = 3 + CallsAndStations = 3, + Pois = 4, + CallsAndPois = 5, + StationsAndPois = 6, + CallsStationsAndPois = 7 } -} \ No newline at end of file + + public static class CustomStateDetailTypeExtensions + { + public static bool SupportsCalls(this int detailType) + { + return ((CustomStateDetailTypes)detailType).SupportsCalls(); + } + + public static bool SupportsStations(this int detailType) + { + return ((CustomStateDetailTypes)detailType).SupportsStations(); + } + + public static bool SupportsPois(this int detailType) + { + return ((CustomStateDetailTypes)detailType).SupportsPois(); + } + + public static bool SupportsCalls(this CustomStateDetailTypes detailType) + { + return detailType == CustomStateDetailTypes.Calls + || detailType == CustomStateDetailTypes.CallsAndStations + || detailType == CustomStateDetailTypes.CallsAndPois + || detailType == CustomStateDetailTypes.CallsStationsAndPois; + } + + public static bool SupportsStations(this CustomStateDetailTypes detailType) + { + return detailType == CustomStateDetailTypes.Stations + || detailType == CustomStateDetailTypes.CallsAndStations + || detailType == CustomStateDetailTypes.StationsAndPois + || detailType == CustomStateDetailTypes.CallsStationsAndPois; + } + + public static bool SupportsPois(this CustomStateDetailTypes detailType) + { + return detailType == CustomStateDetailTypes.Pois + || detailType == CustomStateDetailTypes.CallsAndPois + || detailType == CustomStateDetailTypes.StationsAndPois + || detailType == CustomStateDetailTypes.CallsStationsAndPois; + } + } +} diff --git a/Core/Resgrid.Model/DestinationEntityTypes.cs b/Core/Resgrid.Model/DestinationEntityTypes.cs new file mode 100644 index 000000000..195011c2d --- /dev/null +++ b/Core/Resgrid.Model/DestinationEntityTypes.cs @@ -0,0 +1,39 @@ +using System; +using Microsoft.Extensions.Localization; + +namespace Resgrid.Model +{ + public enum DestinationEntityTypes + { + None = 0, + Station = 1, + Call = 2, + Poi = 3 + } + + public static class DestinationEntityTypeExtensions + { + public static DestinationEntityTypes ToDestinationEntityType(this int? destinationType) + { + if (!destinationType.HasValue || !Enum.IsDefined(typeof(DestinationEntityTypes), destinationType.Value)) + return DestinationEntityTypes.None; + + return (DestinationEntityTypes)destinationType.Value; + } + + public static string GetDisplayName(this DestinationEntityTypes destinationType, IStringLocalizer localizer = null) + { + switch (destinationType) + { + case DestinationEntityTypes.Station: + return localizer != null ? localizer["Station"] : "Station"; + case DestinationEntityTypes.Call: + return localizer != null ? localizer["Call"] : "Call"; + case DestinationEntityTypes.Poi: + return localizer != null ? localizer["POI"] : "POI"; + default: + return string.Empty; + } + } + } +} diff --git a/Core/Resgrid.Model/Helpers/DestinationResolutionHelper.cs b/Core/Resgrid.Model/Helpers/DestinationResolutionHelper.cs new file mode 100644 index 000000000..dfa729de5 --- /dev/null +++ b/Core/Resgrid.Model/Helpers/DestinationResolutionHelper.cs @@ -0,0 +1,118 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Localization; + +namespace Resgrid.Model.Helpers +{ + public class ResolvedDestinationData + { + public int? DestinationId { get; set; } + public int? DestinationType { get; set; } + public string Name { get; set; } + public string Address { get; set; } + public string TypeName { get; set; } + } + + public static class DestinationResolutionHelper + { + public static ResolvedDestinationData Resolve(int? destinationId, int? destinationType, int? detailType, IEnumerable activeCalls, IEnumerable groups, IEnumerable pois, IStringLocalizer localizer = null, bool allowCrossTypeFallback = false) + { + var result = new ResolvedDestinationData + { + DestinationId = destinationId, + DestinationType = destinationType + }; + + if (!destinationId.HasValue || destinationId.Value <= 0) + return result; + + var effectiveDestinationType = destinationType.ToDestinationEntityType(); + + if (effectiveDestinationType != DestinationEntityTypes.None) + return ResolveByType(destinationId.Value, effectiveDestinationType, activeCalls, groups, pois, localizer); + + if (detailType.HasValue) + { + var stateDetailType = (CustomStateDetailTypes)detailType.Value; + + if (stateDetailType.SupportsStations()) + { + var stationResult = ResolveByType(destinationId.Value, DestinationEntityTypes.Station, activeCalls, groups, pois, localizer); + if (!string.IsNullOrWhiteSpace(stationResult.Name)) + return stationResult; + } + + if (stateDetailType.SupportsCalls()) + { + var callResult = ResolveByType(destinationId.Value, DestinationEntityTypes.Call, activeCalls, groups, pois, localizer); + if (!string.IsNullOrWhiteSpace(callResult.Name)) + return callResult; + } + + if (stateDetailType.SupportsPois()) + { + var poiResult = ResolveByType(destinationId.Value, DestinationEntityTypes.Poi, activeCalls, groups, pois, localizer); + if (!string.IsNullOrWhiteSpace(poiResult.Name)) + return poiResult; + } + + } + + if (!allowCrossTypeFallback) + return result; + + var fallbackStation = ResolveByType(destinationId.Value, DestinationEntityTypes.Station, activeCalls, groups, pois); + if (!string.IsNullOrWhiteSpace(fallbackStation.Name)) + return fallbackStation; + + var fallbackCall = ResolveByType(destinationId.Value, DestinationEntityTypes.Call, activeCalls, groups, pois, localizer); + if (!string.IsNullOrWhiteSpace(fallbackCall.Name)) + return fallbackCall; + + return ResolveByType(destinationId.Value, DestinationEntityTypes.Poi, activeCalls, groups, pois, localizer); + } + + private static ResolvedDestinationData ResolveByType(int destinationId, DestinationEntityTypes destinationType, IEnumerable activeCalls, IEnumerable groups, IEnumerable pois, IStringLocalizer localizer = null) + { + var result = new ResolvedDestinationData + { + DestinationId = destinationId, + DestinationType = (int)destinationType + }; + + switch (destinationType) + { + case DestinationEntityTypes.Station: + var station = groups?.FirstOrDefault(x => x.DepartmentGroupId == destinationId); + if (station != null) + { + result.Name = station.Name; + result.Address = station.Address?.FormatAddress(); + result.TypeName = destinationType.GetDisplayName(localizer); + } + break; + case DestinationEntityTypes.Call: + var call = activeCalls?.FirstOrDefault(x => x.CallId == destinationId); + if (call != null) + { + var identifier = call.GetIdentifier(); + result.Name = string.IsNullOrWhiteSpace(call.Name) ? identifier : $"{identifier}: {call.Name}"; + result.Address = call.Address; + result.TypeName = destinationType.GetDisplayName(localizer); + } + break; + case DestinationEntityTypes.Poi: + var poi = pois?.FirstOrDefault(x => x.PoiId == destinationId); + if (poi != null) + { + result.Name = PoiDisplayHelper.GetDisplayName(poi); + result.Address = poi.Address; + result.TypeName = PoiDisplayHelper.GetTypeName(poi, destinationType.GetDisplayName(localizer), localizer); + } + break; + } + + return result; + } + } +} diff --git a/Core/Resgrid.Model/Helpers/PoiDisplayHelper.cs b/Core/Resgrid.Model/Helpers/PoiDisplayHelper.cs new file mode 100644 index 000000000..bf0b847c2 --- /dev/null +++ b/Core/Resgrid.Model/Helpers/PoiDisplayHelper.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Localization; + +namespace Resgrid.Model.Helpers +{ + public static class PoiDisplayHelper + { + public static string GetDisplayName(Poi poi, string fallbackTypeName = null) + { + if (poi == null) + return GetTypeName(null, fallbackTypeName); + + if (!string.IsNullOrWhiteSpace(poi.Name)) + return poi.Name; + + if (!string.IsNullOrWhiteSpace(poi.Address)) + return poi.Address; + + if (!string.IsNullOrWhiteSpace(poi.Note)) + return poi.Note; + + if (!string.IsNullOrWhiteSpace(poi.Type?.Name)) + return poi.Type.Name; + + return GetTypeName(poi, fallbackTypeName); + } + + public static string GetSelectionLabel(Poi poi, string fallbackTypeName = null) + { + if (poi == null) + return GetTypeName(null, fallbackTypeName); + + if (!string.IsNullOrWhiteSpace(poi.Name) && !string.IsNullOrWhiteSpace(poi.Address) && !string.Equals(poi.Name.Trim(), poi.Address.Trim(), StringComparison.OrdinalIgnoreCase)) + return $"{poi.Name} - {poi.Address}"; + + var displayName = GetDisplayName(poi, fallbackTypeName); + if (!string.IsNullOrWhiteSpace(displayName) && string.IsNullOrWhiteSpace(poi.Name) && string.IsNullOrWhiteSpace(poi.Address) && string.IsNullOrWhiteSpace(poi.Note) && poi.PoiId > 0) + return $"{displayName} #{poi.PoiId}"; + + return displayName; + } + + public static string GetTypeName(Poi poi, string fallbackTypeName = null, IStringLocalizer localizer = null) + { + if (!string.IsNullOrWhiteSpace(poi?.Type?.Name)) + return poi.Type.Name; + + if (!string.IsNullOrWhiteSpace(fallbackTypeName)) + return fallbackTypeName; + + return localizer != null ? localizer["POI"] : "POI"; + } + + public static List GetDisplayRows(Poi poi, string fallbackTypeName = null) + { + var rows = new List(); + if (poi == null) + return rows; + + var title = GetDisplayName(poi, fallbackTypeName); + if (!string.IsNullOrWhiteSpace(title)) + rows.Add(title); + + var typeName = GetTypeName(poi, fallbackTypeName); + if (!string.IsNullOrWhiteSpace(typeName) && !string.Equals(title, typeName, StringComparison.OrdinalIgnoreCase)) + rows.Add(typeName); + + if (!string.IsNullOrWhiteSpace(poi.Address) && !string.Equals(title, poi.Address, StringComparison.OrdinalIgnoreCase)) + rows.Add(poi.Address); + + if (!string.IsNullOrWhiteSpace(poi.Note) && !string.Equals(title, poi.Note, StringComparison.OrdinalIgnoreCase)) + rows.Add(poi.Note); + + return rows; + } + } +} diff --git a/Core/Resgrid.Model/Poi.cs b/Core/Resgrid.Model/Poi.cs index c0a1f5296..0d697e645 100644 --- a/Core/Resgrid.Model/Poi.cs +++ b/Core/Resgrid.Model/Poi.cs @@ -23,6 +23,10 @@ public class Poi : IEntity public double Latitude { get; set; } + public string Name { get; set; } + + public string Address { get; set; } + public string Note { get; set; } [NotMapped] diff --git a/Core/Resgrid.Model/Resgrid.Model.csproj b/Core/Resgrid.Model/Resgrid.Model.csproj index c5432d59a..1c6041b39 100644 --- a/Core/Resgrid.Model/Resgrid.Model.csproj +++ b/Core/Resgrid.Model/Resgrid.Model.csproj @@ -79,6 +79,7 @@ + diff --git a/Core/Resgrid.Model/Services/IMappingService.cs b/Core/Resgrid.Model/Services/IMappingService.cs index c9489466d..b82e730af 100644 --- a/Core/Resgrid.Model/Services/IMappingService.cs +++ b/Core/Resgrid.Model/Services/IMappingService.cs @@ -29,6 +29,14 @@ public interface IMappingService /// Task<List<PoiType>>. Task> GetPOITypesForDepartmentAsync(int departmentId); + Task> GetPOIsForDepartmentAsync(int departmentId); + + Task> GetDestinationPOIsForDepartmentAsync(int departmentId); + + Task GetPOIByIdAsync(int poiId); + + Task GetDestinationPOIByIdAsync(int departmentId, int poiId); + /// /// Gets the type by identifier asynchronous. /// @@ -44,6 +52,8 @@ public interface IMappingService /// Task<System.Boolean>. Task DeletePOITypeAsync(int poiTypeId, CancellationToken cancellationToken = default(CancellationToken)); + Task DeletePOIAsync(int poiId, CancellationToken cancellationToken = default(CancellationToken)); + Task SaveMapLayerAsync(MapLayer mapLayer); Task> GetMapLayersForTypeDepartmentAsync(int departmentId, MapLayerTypes type); diff --git a/Core/Resgrid.Model/UnitState.cs b/Core/Resgrid.Model/UnitState.cs index 330d75f3c..c83b9ff59 100644 --- a/Core/Resgrid.Model/UnitState.cs +++ b/Core/Resgrid.Model/UnitState.cs @@ -30,6 +30,8 @@ public class UnitState : IEntity public int? DestinationId { get; set; } + public int? DestinationType { get; set; } + public DateTime? LocalTimestamp { get; set; } public string Note { get; set; } diff --git a/Core/Resgrid.Services/ActionLogsService.cs b/Core/Resgrid.Services/ActionLogsService.cs index 6f2e3d87a..b2341a772 100644 --- a/Core/Resgrid.Services/ActionLogsService.cs +++ b/Core/Resgrid.Services/ActionLogsService.cs @@ -58,7 +58,7 @@ public async Task> GetAllActionLogsForDepartmentAsync(int depart if (actionLog.User == null) actionLog.User = _usersService.GetUserById(actionLog.UserId, false); - if (actionLog.DestinationType.GetValueOrDefault() == 1 || actionLog.DestinationType.GetValueOrDefault() == 2 || + if (actionLog.DestinationType.ToDestinationEntityType() != DestinationEntityTypes.None || actionLog.ActionTypeId == (int)ActionTypes.RespondingToScene || actionLog.ActionTypeId == (int)ActionTypes.RespondingToStation) actionLog.Eta = await _geoService.GetPersonnelEtaInSecondsAsync(actionLog); } @@ -93,7 +93,7 @@ async Task> getActionLogs() foreach (var v in values) { - if (v.DestinationId.HasValue && (v.DestinationType.GetValueOrDefault() == 1 || v.DestinationType.GetValueOrDefault() == 2 || v.ActionTypeId == (int) ActionTypes.RespondingToScene || v.ActionTypeId == (int) ActionTypes.RespondingToStation)) + if (v.DestinationId.HasValue && (v.DestinationType.ToDestinationEntityType() != DestinationEntityTypes.None || v.ActionTypeId == (int) ActionTypes.RespondingToScene || v.ActionTypeId == (int) ActionTypes.RespondingToStation)) { v.Eta = await _geoService.GetPersonnelEtaInSecondsAsync(v); v.EtaPulledOn = DateTime.UtcNow; @@ -423,7 +423,7 @@ public async Task> GetActionLogsForCallAsync(int departmentId, i where state.Details != null select state; - callEnabledStates.AddRange(from state in nonNullStates from detail in state.Details where detail.DetailType == (int)CustomStateDetailTypes.Calls || detail.DetailType == (int)CustomStateDetailTypes.CallsAndStations select detail.CustomStateDetailId); + callEnabledStates.AddRange(from state in nonNullStates from detail in state.Details where detail.DetailType.SupportsCalls() select detail.CustomStateDetailId); var items = await _actionLogsRepository.GetActionLogsForCallAndTypesAsync(callId, callEnabledStates); return items.ToList(); diff --git a/Core/Resgrid.Services/GeoService.cs b/Core/Resgrid.Services/GeoService.cs index ea0b75d5c..64f749f8d 100644 --- a/Core/Resgrid.Services/GeoService.cs +++ b/Core/Resgrid.Services/GeoService.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.Threading.Tasks; using Resgrid.Model; using Resgrid.Model.Providers; @@ -12,13 +13,15 @@ public class GeoService : IGeoService private readonly ICallsService _callsService; private readonly IDepartmentGroupsService _departmentGroupsService; private readonly IAddressService _addressService; + private readonly IMappingService _mappingService; - public GeoService(IGeoLocationProvider geoLocationProvider, ICallsService callsService, IDepartmentGroupsService departmentGroupsService, IAddressService addressService) + public GeoService(IGeoLocationProvider geoLocationProvider, ICallsService callsService, IDepartmentGroupsService departmentGroupsService, IAddressService addressService, IMappingService mappingService) { _geoLocationProvider = geoLocationProvider; _callsService = callsService; _departmentGroupsService = departmentGroupsService; _addressService = addressService; + _mappingService = mappingService; } public async Task GetPersonnelEtaInSecondsAsync(ActionLog log) @@ -29,7 +32,7 @@ public async Task GetPersonnelEtaInSecondsAsync(ActionLog log) if (log.DestinationId.HasValue) { RouteInformation route = null; - if (log.DestinationType.GetValueOrDefault() == 1 || log.ActionTypeId == (int)ActionTypes.RespondingToStation) // Department Group + if (log.DestinationType.ToDestinationEntityType() == DestinationEntityTypes.Station || log.ActionTypeId == (int)ActionTypes.RespondingToStation) // Department Group { var group = await _departmentGroupsService.GetGroupByIdAsync(log.DestinationId.Value, false); @@ -49,15 +52,25 @@ public async Task GetPersonnelEtaInSecondsAsync(ActionLog log) route = await _geoLocationProvider.GetRoute(log.GeoLocationData, string.Format("{0},{1}", group.Latitude, group.Longitude)); } } - else if (log.DestinationType.GetValueOrDefault() == 2 || log.ActionTypeId == (int)ActionTypes.RespondingToScene) // Call + else if (log.DestinationType.ToDestinationEntityType() == DestinationEntityTypes.Call || log.ActionTypeId == (int)ActionTypes.RespondingToScene) // Call { var call = await _callsService.GetCallByIdAsync(log.DestinationId.Value, false); - if (!String.IsNullOrWhiteSpace(call.GeoLocationData) && call.GeoLocationData.Length > 1) - route = await _geoLocationProvider.GetRoute(log.GeoLocationData, call.GeoLocationData); - else + if (call != null && !String.IsNullOrWhiteSpace(call.GeoLocationData)) route = await _geoLocationProvider.GetRoute(log.GeoLocationData, call.GeoLocationData); } + else if (log.DestinationType.ToDestinationEntityType() == DestinationEntityTypes.Poi) + { + var poi = await _mappingService.GetPOIByIdAsync(log.DestinationId.Value); + + if (poi != null) + { + if (!String.IsNullOrWhiteSpace(poi.Address)) + route = await _geoLocationProvider.GetRoute(log.GeoLocationData, poi.Address); + else + route = await _geoLocationProvider.GetRoute(log.GeoLocationData, String.Format(CultureInfo.InvariantCulture, "{0},{1}", poi.Latitude, poi.Longitude)); + } + } if (route != null) { diff --git a/Core/Resgrid.Services/MappingService.cs b/Core/Resgrid.Services/MappingService.cs index 957021594..96d1394c9 100644 --- a/Core/Resgrid.Services/MappingService.cs +++ b/Core/Resgrid.Services/MappingService.cs @@ -44,6 +44,56 @@ public async Task> GetPOITypesForDepartmentAsync(int departmentId) return types.ToList(); } + public async Task> GetPOIsForDepartmentAsync(int departmentId) + { + var poiTypes = await GetPOITypesForDepartmentAsync(departmentId); + var pois = new List(); + + foreach (var poiType in poiTypes) + { + if (poiType.Pois == null || !poiType.Pois.Any()) + continue; + + foreach (var poi in poiType.Pois) + { + poi.Type = poiType; + pois.Add(poi); + } + } + + return pois; + } + + public async Task> GetDestinationPOIsForDepartmentAsync(int departmentId) + { + var poiTypes = await GetPOITypesForDepartmentAsync(departmentId); + var pois = new List(); + + foreach (var poiType in poiTypes.Where(x => x.IsDestination)) + { + if (poiType.Pois == null || !poiType.Pois.Any()) + continue; + + foreach (var poi in poiType.Pois) + { + poi.Type = poiType; + pois.Add(poi); + } + } + + return pois; + } + + public async Task GetPOIByIdAsync(int poiId) + { + return await _poisRepository.GetByIdAsync(poiId); + } + + public async Task GetDestinationPOIByIdAsync(int departmentId, int poiId) + { + return (await GetDestinationPOIsForDepartmentAsync(departmentId)).FirstOrDefault(x => x.PoiId == poiId); + } + public async Task GetTypeByIdAsync(int poiTypeId) { return await _poiTypesRepository.GetPoiTypeByTypeIdAsync(poiTypeId); @@ -61,6 +111,18 @@ public async Task GetTypeByIdAsync(int poiTypeId) return false; } + public async Task DeletePOIAsync(int poiId, CancellationToken cancellationToken = default(CancellationToken)) + { + var poi = await GetPOIByIdAsync(poiId); + + if (poi != null) + { + return await _poisRepository.DeleteAsync(poi, cancellationToken); + } + + return false; + } + public async Task SaveMapLayerAsync(MapLayer mapLayer) { if (Config.DataConfig.DocDatabaseType == Config.DatabaseTypes.Postgres) diff --git a/Core/Resgrid.Services/UnitsService.cs b/Core/Resgrid.Services/UnitsService.cs index 000e7b48c..41e8789e2 100644 --- a/Core/Resgrid.Services/UnitsService.cs +++ b/Core/Resgrid.Services/UnitsService.cs @@ -517,7 +517,7 @@ public async Task> GetUnitStatesForCallAsync(int departmentId, i callEnabledStates.AddRange(from state in nonNullStates from detail in state.Details - where detail.DetailType == (int)CustomStateDetailTypes.Calls || detail.DetailType == (int)CustomStateDetailTypes.CallsAndStations + where detail.DetailType.SupportsCalls() select detail.CustomStateDetailId); var unitStates = (from us in await _unitStatesRepository.GetAllStatesByCallIdAsync(callId) diff --git a/Providers/Resgrid.Providers.Geo/KmlProvider.cs b/Providers/Resgrid.Providers.Geo/KmlProvider.cs index 65e05c729..f52676ed7 100644 --- a/Providers/Resgrid.Providers.Geo/KmlProvider.cs +++ b/Providers/Resgrid.Providers.Geo/KmlProvider.cs @@ -32,9 +32,8 @@ public List ImportFile(Stream input, bool isKmz) { foreach (var placemark in kml.Flatten().OfType()) { - Console.WriteLine(placemark.Name); - var coords = new Coordinates(); + coords.Name = placemark.Name; coords.Latitude = placemark.CalculateBounds().Center.Latitude; coords.Longitude = placemark.CalculateBounds().Center.Longitude; @@ -50,4 +49,4 @@ public List ImportFile(Stream input, bool isKmz) return coordinates; } } -} \ No newline at end of file +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0066_AddingPoiDestinations.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0066_AddingPoiDestinations.cs new file mode 100644 index 000000000..b10e1b4de --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0066_AddingPoiDestinations.cs @@ -0,0 +1,27 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(66)] + public class M0066_AddingPoiDestinations : Migration + { + public override void Up() + { + Alter.Table("Pois") + .AddColumn("Address").AsString(500).Nullable(); + + Alter.Table("Calls") + .AddColumn("DestinationPoiId").AsInt32().Nullable(); + + Alter.Table("UnitStates") + .AddColumn("DestinationType").AsInt32().Nullable(); + } + + public override void Down() + { + Delete.Column("Address").FromTable("Pois"); + Delete.Column("DestinationPoiId").FromTable("Calls"); + Delete.Column("DestinationType").FromTable("UnitStates"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0067_AddingPoiName.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0067_AddingPoiName.cs new file mode 100644 index 000000000..fa018d292 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0067_AddingPoiName.cs @@ -0,0 +1,19 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(67)] + public class M0067_AddingPoiName : Migration + { + public override void Up() + { + Alter.Table("Pois") + .AddColumn("Name").AsString(250).Nullable(); + } + + public override void Down() + { + Delete.Column("Name").FromTable("Pois"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0066_AddingPoiDestinationsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0066_AddingPoiDestinationsPg.cs new file mode 100644 index 000000000..6ebdb07ca --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0066_AddingPoiDestinationsPg.cs @@ -0,0 +1,27 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(66)] + public class M0066_AddingPoiDestinationsPg : Migration + { + public override void Up() + { + Alter.Table("Pois".ToLower()) + .AddColumn("Address".ToLower()).AsString(500).Nullable(); + + Alter.Table("Calls".ToLower()) + .AddColumn("DestinationPoiId".ToLower()).AsInt32().Nullable(); + + Alter.Table("UnitStates".ToLower()) + .AddColumn("DestinationType".ToLower()).AsInt32().Nullable(); + } + + public override void Down() + { + Delete.Column("Address".ToLower()).FromTable("Pois".ToLower()); + Delete.Column("DestinationPoiId".ToLower()).FromTable("Calls".ToLower()); + Delete.Column("DestinationType".ToLower()).FromTable("UnitStates".ToLower()); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0067_AddingPoiNamePg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0067_AddingPoiNamePg.cs new file mode 100644 index 000000000..7b31afa7f --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0067_AddingPoiNamePg.cs @@ -0,0 +1,19 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(67)] + public class M0067_AddingPoiNamePg : Migration + { + public override void Up() + { + Alter.Table("Pois".ToLower()) + .AddColumn("Name".ToLower()).AsCustom("citext").Nullable(); + } + + public override void Down() + { + Delete.Column("Name".ToLower()).FromTable("Pois".ToLower()); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs index 353bfd41b..edcf60256 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs @@ -83,7 +83,9 @@ public PostgreSqlConfiguration() SELECT al.*, u.* FROM %SCHEMA%.%ACTIONLOGSTABLE% al INNER JOIN %SCHEMA%.%ASPNETUSERSTABLE% u ON u.Id = al.UserId - WHERE al.DestinationId = %CALLID% AND (al.ActionTypeId IS NULL OR al.ActionTypeId IN (%TYPES%))"; + WHERE al.DestinationId = %CALLID% + AND (al.DestinationType IS NULL OR al.DestinationType = 2) + AND (al.ActionTypeId IS NULL OR al.ActionTypeId IN (%TYPES%))"; SelectPreviousActionLogsByUserQuery = @" SELECT a1.*, u.* FROM %SCHEMA%.%ACTIONLOGSTABLE% a1 @@ -677,7 +679,8 @@ group by 1 SELECT us.*, u.* FROM %SCHEMA%.%UNITSTATESTABLE% us INNER JOIN %SCHEMA%.%UNITSTABLE% u ON u.UnitId = us.UnitId - WHERE us.DestinationId = %CALLID%"; + WHERE us.DestinationId = %CALLID% + AND (us.DestinationType IS NULL OR us.DestinationType = 2)"; SelectUnitByDIdTypeQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE DepartmentId = %DID% AND Type = %TYPE%"; SelectLastUnitStatesByDidQuery = @" diff --git a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs index 5b0b963b1..c93c8150e 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs @@ -81,7 +81,9 @@ public SqlServerConfiguration() SELECT al.*, u.* FROM %SCHEMA%.%ACTIONLOGSTABLE% al INNER JOIN %SCHEMA%.%ASPNETUSERSTABLE% u ON u.Id = al.UserId - WHERE al.[DestinationId] = %CALLID% AND (al.[ActionTypeId] IS NULL OR al.[ActionTypeId] IN (%TYPES%))"; + WHERE al.[DestinationId] = %CALLID% + AND (al.[DestinationType] IS NULL OR al.[DestinationType] = 2) + AND (al.[ActionTypeId] IS NULL OR al.[ActionTypeId] IN (%TYPES%))"; SelectPreviousActionLogsByUserQuery = @" SELECT TOP 1 a1.*, u.* FROM %SCHEMA%.%ACTIONLOGSTABLE% a1 @@ -674,7 +676,8 @@ FROM [dbo].[Units] u SELECT us.*, u.* FROM %SCHEMA%.%UNITSTATESTABLE% us INNER JOIN %SCHEMA%.%UNITSTABLE% u ON u.[UnitId] = us.[UnitId] - WHERE us.[DestinationId] = %CALLID%"; + WHERE us.[DestinationId] = %CALLID% + AND (us.[DestinationType] IS NULL OR us.[DestinationType] = 2)"; SelectUnitByDIdTypeQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [DepartmentId] = %DID% AND [Type] = %TYPE%"; SelectLastUnitStatesByDidQuery = @" diff --git a/Tests/Resgrid.Tests/Models/DestinationResolutionHelperTests.cs b/Tests/Resgrid.Tests/Models/DestinationResolutionHelperTests.cs new file mode 100644 index 000000000..cd36d39ae --- /dev/null +++ b/Tests/Resgrid.Tests/Models/DestinationResolutionHelperTests.cs @@ -0,0 +1,87 @@ +using System.Collections.Generic; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Helpers; + +namespace Resgrid.Tests.Models +{ + [TestFixture] + public class DestinationResolutionHelperTests + { + [Test] + public void Resolve_ShouldNotProbeAcrossEntityTypes_WhenTypeInfoIsMissing() + { + var calls = new List + { + new Call + { + CallId = 42, + Name = "Collision Call", + Number = "C-42", + Address = "123 Call St" + } + }; + var stations = new List + { + new DepartmentGroup + { + DepartmentGroupId = 42, + Name = "Collision Station" + } + }; + var pois = new List(); + + var result = DestinationResolutionHelper.Resolve(42, null, null, calls, stations, pois); + + result.DestinationId.Should().Be(42); + result.DestinationType.Should().BeNull(); + result.Name.Should().BeNull(); + result.Address.Should().BeNull(); + result.TypeName.Should().BeNull(); + } + + [Test] + public void Resolve_ShouldUseBlindFallback_WhenExplicitlyOptedIn() + { + var stations = new List + { + new DepartmentGroup + { + DepartmentGroupId = 42, + Name = "Collision Station" + } + }; + + var result = DestinationResolutionHelper.Resolve(42, null, null, new List(), stations, new List(), allowCrossTypeFallback: true); + + result.DestinationId.Should().Be(42); + result.DestinationType.Should().Be((int)DestinationEntityTypes.Station); + result.Name.Should().Be("Collision Station"); + result.TypeName.Should().Be(DestinationEntityTypes.Station.GetDisplayName()); + } + + [Test] + public void GetEffectiveDestinationType_ShouldInferCallForLegacyRespondingToSceneActions() + { + var actionLog = new ActionLog + { + ActionTypeId = (int)ActionTypes.RespondingToScene + }; + + actionLog.GetEffectiveDestinationType().Should().Be(DestinationEntityTypes.Call); + } + + [Test] + public void GetEffectiveDestinationType_ShouldPreferExplicitDestinationType() + { + var actionLog = new ActionLog + { + ActionTypeId = (int)ActionTypes.RespondingToScene, + DestinationType = (int)DestinationEntityTypes.Poi + }; + + actionLog.GetEffectiveDestinationType().Should().Be(DestinationEntityTypes.Poi); + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs index ce1c4a1ed..19d944a88 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs @@ -51,6 +51,7 @@ public class CallsController : V4AuthenticatedApiControllerbase private readonly ICustomStateService _customStateService; private readonly IDepartmentSettingsService _departmentSettingsService; private readonly IShiftsService _shiftsService; + private readonly IMappingService _mappingService; private readonly IUserDefinedFieldsService _userDefinedFieldsService; private readonly ICommunicationService _communicationService; private readonly IWeatherAlertService _weatherAlertService; @@ -72,6 +73,7 @@ public CallsController( ICustomStateService customStateService, IDepartmentSettingsService departmentSettingsService, IShiftsService shiftsService, + IMappingService mappingService, IUserDefinedFieldsService userDefinedFieldsService, ICommunicationService communicationService, IWeatherAlertService weatherAlertService @@ -93,6 +95,7 @@ IWeatherAlertService weatherAlertService _customStateService = customStateService; _departmentSettingsService = departmentSettingsService; _shiftsService = shiftsService; + _mappingService = mappingService; _userDefinedFieldsService = userDefinedFieldsService; _communicationService = communicationService; _weatherAlertService = weatherAlertService; @@ -111,6 +114,8 @@ public async Task> GetActiveCalls() var result = new ActiveCallsResult(); var calls = (await _callsService.GetActiveCallsByDepartmentAsync(DepartmentId)).OrderByDescending(x => x.LoggedOn); + var destinationPois = await _mappingService.GetPOIsForDepartmentAsync(DepartmentId); + var destinationPoiLookup = destinationPois.ToDictionary(x => x.PoiId); if (calls != null && calls.Any()) { @@ -129,7 +134,8 @@ public async Task> GetActiveCalls() else address = c.Address; - result.Data.Add(ConvertCall(callWithData, null, address, TimeZone)); + destinationPoiLookup.TryGetValue(callWithData.DestinationPoiId.GetValueOrDefault(), out var destinationPoi); + result.Data.Add(ConvertCall(callWithData, null, address, TimeZone, destinationPoi)); } result.PageSize = result.Data.Count(); result.Status = ResponseHelper.Success; @@ -173,6 +179,7 @@ public async Task> GetCall(string callId) return Unauthorized(); c = await _callsService.PopulateCallData(c, false, true, true, false, false, false, true, true, true); + var destinationPoi = await GetValidatedDestinationPoiAsync(c.DestinationPoiId); string address = ""; if (String.IsNullOrWhiteSpace(c.Address) && c.HasValidGeolocationData()) @@ -196,7 +203,7 @@ public async Task> GetCall(string callId) } } - result.Data = ConvertCall(c, protocols, address, TimeZone); + result.Data = ConvertCall(c, protocols, address, TimeZone, destinationPoi); // Populate UDF values var udfValues = await _userDefinedFieldsService.GetFieldValuesForEntityAsync(DepartmentId, (int)UdfEntityType.Call, c.CallId.ToString()); @@ -548,6 +555,10 @@ public async Task> SaveCall([FromBody] NewCallInput var groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(DepartmentId); var roles = await _personnelRolesService.GetAllRolesForDepartmentAsync(DepartmentId); var units = await _unitsService.GetUnitsForDepartmentAsync(DepartmentId); + var destinationPoi = await GetValidatedDestinationPoiAsync(newCallInput.DestinationPoiId); + + if (newCallInput.DestinationPoiId.HasValue && newCallInput.DestinationPoiId.Value > 0 && destinationPoi == null) + return BadRequest(); var call = new Call { @@ -576,6 +587,8 @@ public async Task> SaveCall([FromBody] NewCallInput if (!string.IsNullOrWhiteSpace(newCallInput.Address)) call.Address = newCallInput.Address; + call.DestinationPoiId = destinationPoi?.PoiId; + if (!string.IsNullOrWhiteSpace(newCallInput.What3Words)) call.W3W = newCallInput.What3Words; @@ -910,6 +923,10 @@ public async Task> EditCall([FromBody] EditCallInpu var groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(DepartmentId); var roles = await _personnelRolesService.GetAllRolesForDepartmentAsync(DepartmentId); var units = await _unitsService.GetUnitsForDepartmentAsync(DepartmentId); + var destinationPoi = await GetValidatedDestinationPoiAsync(editCallInput.DestinationPoiId); + + if (editCallInput.DestinationPoiId.HasValue && editCallInput.DestinationPoiId.Value > 0 && destinationPoi == null) + return BadRequest(); call.Priority = editCallInput.Priority; call.Name = editCallInput.Name; @@ -933,6 +950,8 @@ public async Task> EditCall([FromBody] EditCallInpu if (!string.IsNullOrWhiteSpace(editCallInput.Address)) call.Address = editCallInput.Address; + call.DestinationPoiId = destinationPoi?.PoiId; + if (!string.IsNullOrWhiteSpace(editCallInput.What3Words)) call.W3W = editCallInput.What3Words; @@ -1454,6 +1473,8 @@ public async Task> GetAllPendingScheduledCall var calls = (await _callsService.GetAllNonDispatchedScheduledCallsByDepartmentIdAsync(DepartmentId)).OrderBy(x => x.DispatchOn); var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); + var destinationPois = await _mappingService.GetPOIsForDepartmentAsync(DepartmentId); + var destinationPoiLookup = destinationPois.ToDictionary(x => x.PoiId); if (calls != null && calls.Any()) { @@ -1470,7 +1491,8 @@ public async Task> GetAllPendingScheduledCall else address = c.Address; - result.Data.Add(ConvertCall(c, null, address, TimeZone)); + destinationPoiLookup.TryGetValue(c.DestinationPoiId.GetValueOrDefault(), out var destinationPoi); + result.Data.Add(ConvertCall(c, null, address, TimeZone, destinationPoi)); } result.PageSize = result.Data.Count(); result.Status = ResponseHelper.Success; @@ -1710,6 +1732,8 @@ public async Task> GetCalls(DateTime startDate, var result = new ActiveCallsResult(); var calls = (await _callsService.GetAllCallsByDepartmentDateRangeAsync(DepartmentId, startDate, endDate)).OrderByDescending(x => x.LoggedOn); + var destinationPois = await _mappingService.GetPOIsForDepartmentAsync(DepartmentId); + var destinationPoiLookup = destinationPois.ToDictionary(x => x.PoiId); if (calls != null && calls.Any()) { @@ -1734,7 +1758,8 @@ public async Task> GetCalls(DateTime startDate, else address = c.Address; - result.Data.Add(ConvertCall(callWithData, null, address, TimeZone)); + destinationPoiLookup.TryGetValue(callWithData.DestinationPoiId.GetValueOrDefault(), out var destinationPoi); + result.Data.Add(ConvertCall(callWithData, null, address, TimeZone, destinationPoi)); } result.PageSize = result.Data.Count(); result.Status = ResponseHelper.Success; @@ -1749,7 +1774,7 @@ public async Task> GetCalls(DateTime startDate, return Ok(result); } - public static CallResultData ConvertCall(Call call, List protocol, string geoLocationAddress, string timeZone) + public static CallResultData ConvertCall(Call call, List protocol, string geoLocationAddress, string timeZone, Poi destinationPoi = null) { var callResult = new CallResultData(); @@ -1786,6 +1811,18 @@ public static CallResultData ConvertCall(Call call, List proto else callResult.Address = call.Address; + callResult.DestinationPoiId = call.DestinationPoiId; + + if (destinationPoi != null) + { + callResult.DestinationName = PoiDisplayHelper.GetDisplayName(destinationPoi, destinationPoi.Type?.Name); + callResult.DestinationAddress = destinationPoi.Address; + callResult.DestinationTypeName = PoiDisplayHelper.GetTypeName(destinationPoi); + callResult.DestinationPoiTypeId = destinationPoi.Type?.PoiTypeId; + callResult.DestinationLatitude = destinationPoi.Latitude; + callResult.DestinationLongitude = destinationPoi.Longitude; + } + callResult.Geolocation = call.GeoLocationData; callResult.LoggedOn = call.LoggedOn.TimeConverter(new Department() { TimeZone = timeZone }); callResult.LoggedOnUtc = call.LoggedOn; @@ -1819,5 +1856,13 @@ public static CallResultData ConvertCall(Call call, List proto return callResult; } + + private async Task GetValidatedDestinationPoiAsync(int? destinationPoiId) + { + if (!destinationPoiId.HasValue || destinationPoiId.Value <= 0) + return null; + + return await _mappingService.GetDestinationPOIByIdAsync(DepartmentId, destinationPoiId.Value); + } } } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/DispatchController.cs b/Web/Resgrid.Web.Services/Controllers/v4/DispatchController.cs index c605950e8..ed25cc67e 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/DispatchController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/DispatchController.cs @@ -12,6 +12,7 @@ using Resgrid.Web.Services.Models.v4.Personnel; using Resgrid.Web.Services.Models.v4.Dispatch; using Resgrid.Web.Services.Models.v4.Groups; +using Resgrid.Web.Services.Models.v4.Mapping; using Resgrid.Web.Services.Models.v4.Units; using Resgrid.Web.Services.Models.v4.CallTypes; using Resgrid.Web.Services.Models.v4.CallPriorities; @@ -50,6 +51,7 @@ public class DispatchController : V4AuthenticatedApiControllerbase private readonly ITemplatesService _templatesService; private readonly IFormsService _formsService; private readonly Model.Services.IAuthorizationService _authorizationService; + private readonly IMappingService _mappingService; public DispatchController( IUsersService usersService, @@ -67,6 +69,7 @@ public DispatchController( IDepartmentSettingsService departmentSettingsService, ITemplatesService templatesService, IFormsService formsService, + IMappingService mappingService, Model.Services.IAuthorizationService authorizationService ) { @@ -85,6 +88,7 @@ Model.Services.IAuthorizationService authorizationService _departmentSettingsService = departmentSettingsService; _templatesService = templatesService; _formsService = formsService; + _mappingService = mappingService; _authorizationService = authorizationService; } #endregion Members and Constructors @@ -109,6 +113,8 @@ public async Task> GetNewCallData() result.UnitRoles = new List(); result.Priorities = new List(); result.CallTypes = new List(); + result.PoiTypes = new List(); + result.DestinationPois = new List(); var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); var users = await _departmentsService.GetAllUsersForDepartmentAsync(DepartmentId); @@ -122,6 +128,8 @@ public async Task> GetNewCallData() var callPriorites = await _callsService.GetActiveCallPrioritiesForDepartmentAsync(DepartmentId); var callTypes = await _callsService.GetCallTypesForDepartmentAsync(DepartmentId); var activeCalls = await _callsService.GetActiveCallsByDepartmentAsync(DepartmentId); + var poiTypes = await _mappingService.GetPOITypesForDepartmentAsync(DepartmentId); + var pois = await _mappingService.GetPOIsForDepartmentAsync(DepartmentId); var canViewPII = await _authorizationService.CanUserViewPIIAsync(UserId, DepartmentId); foreach (var user in users) @@ -178,7 +186,7 @@ public async Task> GetNewCallData() var latestUnitLocation = await _unitsService.GetLatestUnitLocationAsync(us.UnitId, us.Timestamp); var group = allGroups.FirstOrDefault(x => x.DepartmentGroupId == us.Unit.StationGroupId); - result.UnitStatuses.Add(UnitStatusController.ConvertUnitStatusData(us.Unit, us, latestUnitLocation, customState, group, TimeZone, activeCalls, allGroups)); + result.UnitStatuses.Add(UnitStatusController.ConvertUnitStatusData(us.Unit, us, latestUnitLocation, customState, group, TimeZone, activeCalls, allGroups, pois)); } foreach (var role in allRoles) @@ -216,6 +224,22 @@ public async Task> GetNewCallData() } } + if (poiTypes != null && poiTypes.Any()) + { + foreach (var poiType in poiTypes) + { + result.PoiTypes.Add(MappingController.ConvertPoiTypeData(poiType)); + + if (poiType.IsDestination && poiType.Pois != null && poiType.Pois.Any()) + { + foreach (var poi in poiType.Pois) + { + result.DestinationPois.Add(MappingController.ConvertPoiData(poi, poiType)); + } + } + } + } + mainResult.Data = result; return mainResult; @@ -255,11 +279,14 @@ public async Task> GetSetUnitStatusData(stri result.Data.UnitName = unit.Name; result.Data.Stations = new List(); result.Data.Calls = new List(); + result.Data.DestinationPois = new List(); + result.Data.PoiTypes = new List(); result.Data.Statuses = new List(); var type = await _unitsService.GetUnitTypeByNameAsync(DepartmentId, unit.Type); var activeCalls = await _callsService.GetActiveCallsByDepartmentAsync(DepartmentId); var stations = await _departmentGroupsService.GetAllStationGroupsForDepartmentAsync(DepartmentId); + var poiTypes = await _mappingService.GetPOITypesForDepartmentAsync(DepartmentId); var callDefault = new CallResultData(); callDefault.CallId = "0"; @@ -287,6 +314,22 @@ public async Task> GetSetUnitStatusData(stri } } + if (poiTypes != null && poiTypes.Any()) + { + foreach (var poiType in poiTypes) + { + result.Data.PoiTypes.Add(MappingController.ConvertPoiTypeData(poiType)); + + if (poiType.IsDestination && poiType.Pois != null && poiType.Pois.Any()) + { + foreach (var poi in poiType.Pois) + { + result.Data.DestinationPois.Add(MappingController.ConvertPoiData(poi, poiType)); + } + } + } + } + if (type != null && type.CustomStatesId.HasValue) { var customState = await _customStateService.GetCustomSateByIdAsync(type.CustomStatesId.Value); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/MappingController.cs b/Web/Resgrid.Web.Services/Controllers/v4/MappingController.cs index 7ae7cf0c4..74b311df1 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/MappingController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/MappingController.cs @@ -18,6 +18,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; using GeoJSON.Net.Feature; +using Resgrid.Model.Helpers; namespace Resgrid.Web.Services.Controllers.v4 { @@ -107,6 +108,7 @@ public async Task> GetMapDataAndMarkers() var unitLocations = await _unitsService.GetLatestUnitLocationsAsync(DepartmentId); var unitTypes = await _unitsService.GetUnitTypesForDepartmentAsync(DepartmentId); var callTypes = await _callsService.GetCallTypesForDepartmentAsync(DepartmentId); + var poiTypes = await _mappingService.GetPOITypesForDepartmentAsync(DepartmentId); var personnelStates = await _actionLogsService.GetLastActionLogsForDepartmentAsync(DepartmentId); //var personnelNames = await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(DepartmentId); @@ -458,6 +460,27 @@ public async Task> GetMapDataAndMarkers() } } + if (poiTypes != null && poiTypes.Any()) + { + foreach (var poiType in poiTypes.Where(x => x.Pois != null && x.Pois.Any())) + { + result.Data.PoiLayers.Add(new PoiLayerData + { + PoiTypeId = poiType.PoiTypeId, + Name = poiType.Name, + Color = poiType.Color, + ImagePath = poiType.Image, + Marker = poiType.Marker, + IsDestination = poiType.IsDestination + }); + + foreach (var poi in poiType.Pois) + { + result.Data.MapMakerInfos.Add(ConvertPoiMapMarker(poi, poiType)); + } + } + } + result.PageSize = 1; result.Status = ResponseHelper.Success; ResponseHelper.PopulateV4ResponseData(result); @@ -495,6 +518,88 @@ public async Task> GetMayLayers(int type) return Ok(result); } + [HttpGet("GetPoiTypes")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Department_View)] + public async Task> GetPoiTypes() + { + var result = new PoiTypesResult(); + var poiTypes = await _mappingService.GetPOITypesForDepartmentAsync(DepartmentId); + + if (poiTypes != null && poiTypes.Any()) + { + foreach (var poiType in poiTypes) + { + result.Data.Add(ConvertPoiTypeData(poiType)); + } + } + + result.PageSize = result.Data.Count; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); + } + + [HttpGet("GetPois")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Department_View)] + public async Task> GetPois(int? poiTypeId, bool destinationOnly = false) + { + var result = new PoisResult(); + var poiTypes = await _mappingService.GetPOITypesForDepartmentAsync(DepartmentId); + + if (poiTypes != null && poiTypes.Any()) + { + var filteredPoiTypes = poiTypes.AsEnumerable(); + + if (poiTypeId.HasValue) + filteredPoiTypes = filteredPoiTypes.Where(x => x.PoiTypeId == poiTypeId.Value); + + if (destinationOnly) + filteredPoiTypes = filteredPoiTypes.Where(x => x.IsDestination); + + foreach (var poiType in filteredPoiTypes.Where(x => x.Pois != null && x.Pois.Any())) + { + foreach (var poi in poiType.Pois) + { + result.Data.Add(ConvertPoiData(poi, poiType)); + } + } + } + + result.PageSize = result.Data.Count; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); + } + + [HttpGet("GetPoi/{poiId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Department_View)] + public async Task> GetPoi(int poiId) + { + var result = new PoiResult(); + var poi = await _mappingService.GetPOIByIdAsync(poiId); + + if (poi == null) + return NotFound(); + + var poiType = await _mappingService.GetTypeByIdAsync(poi.PoiTypeId); + + if (poiType == null || poiType.DepartmentId != DepartmentId) + return NotFound(); + + result.Data = ConvertPoiData(poi, poiType); + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); + } + public static GetMapLayersData ConvertMapLayerData(MapLayer layer) { var result = new GetMapLayersData(); @@ -518,6 +623,82 @@ public static GetMapLayersData ConvertMapLayerData(MapLayer layer) return result; } + public static PoiTypeResultData ConvertPoiTypeData(PoiType poiType) + { + return new PoiTypeResultData + { + PoiTypeId = poiType.PoiTypeId, + Name = poiType.Name, + Color = poiType.Color, + ImagePath = poiType.Image, + Marker = poiType.Marker, + IsDestination = poiType.IsDestination + }; + } + + public static PoiResultData ConvertPoiData(Poi poi, PoiType poiType) + { + return new PoiResultData + { + PoiId = poi.PoiId, + PoiTypeId = poiType.PoiTypeId, + PoiTypeName = poiType.Name, + Name = poi.Name, + Address = poi.Address, + Note = poi.Note, + Latitude = poi.Latitude, + Longitude = poi.Longitude, + Color = poiType.Color, + ImagePath = poiType.Image, + Marker = poiType.Marker, + IsDestination = poiType.IsDestination + }; + } + + private static MapMakerInfoData ConvertPoiMapMarker(Poi poi, PoiType poiType) + { + return new MapMakerInfoData + { + Id = $"poi{poi.PoiId}", + Longitude = poi.Longitude, + Latitude = poi.Latitude, + Title = GetPoiTitle(poi, poiType), + InfoWindowContent = GetPoiInfoWindowContent(poi, poiType), + ImagePath = poiType.Image, + Marker = poiType.Marker, + Color = poiType.Color, + Type = 4, + PoiTypeId = poiType.PoiTypeId, + PoiTypeName = poiType.Name, + Address = poi.Address, + Note = poi.Note, + LayerId = GetPoiLayerId(poiType), + LayerName = poiType.Name + }; + } + + private static string GetPoiTitle(Poi poi, PoiType poiType) + { + return PoiDisplayHelper.GetDisplayName(poi, poiType.Name); + } + + private static string GetPoiInfoWindowContent(Poi poi, PoiType poiType) + { + var rows = PoiDisplayHelper.GetDisplayRows(poi, poiType.Name); + if (!rows.Any()) + return String.Empty; + + var encodedRows = rows.Select(System.Net.WebUtility.HtmlEncode).ToList(); + encodedRows[0] = $"{encodedRows[0]}"; + + return String.Join("
", encodedRows); + } + + private static string GetPoiLayerId(PoiType poiType) + { + return $"poi-type-{poiType.PoiTypeId}"; + } + /// /// Gets all indoor maps for the department. /// diff --git a/Web/Resgrid.Web.Services/Controllers/v4/PersonnelStatusesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/PersonnelStatusesController.cs index a98e72bea..a62dbe265 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/PersonnelStatusesController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/PersonnelStatusesController.cs @@ -31,6 +31,8 @@ public class PersonnelStatusesController : V4AuthenticatedApiControllerbase private readonly IUserProfileService _userProfileService; private readonly IUserStateService _userStateService; private readonly IDepartmentGroupsService _departmentGroupsService; + private readonly ICallsService _callsService; + private readonly IMappingService _mappingService; private readonly IPersonnelRolesService _personnelRolesService; private readonly IDepartmentSettingsService _departmentSettingsService; private readonly Model.Services.IAuthorizationService _authorizationService; @@ -42,6 +44,8 @@ public PersonnelStatusesController( IUserProfileService userProfileService, IUserStateService userStateService, IDepartmentGroupsService departmentGroupsService, + ICallsService callsService, + IMappingService mappingService, IPersonnelRolesService personnelRolesService, IDepartmentSettingsService departmentSettingsService, Model.Services.IAuthorizationService authorizationService @@ -53,6 +57,8 @@ Model.Services.IAuthorizationService authorizationService _userProfileService = userProfileService; _userStateService = userStateService; _departmentGroupsService = departmentGroupsService; + _callsService = callsService; + _mappingService = mappingService; _personnelRolesService = personnelRolesService; _departmentSettingsService = departmentSettingsService; _authorizationService = authorizationService; @@ -76,10 +82,13 @@ public async Task> GetCurrentStatus(string var action = await _actionLogsService.GetLastActionLogForUserAsync(userId, DepartmentId); var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); + var activeCalls = await _callsService.GetActiveCallsByDepartmentAsync(DepartmentId); + var stations = await _departmentGroupsService.GetAllStationGroupsForDepartmentAsync(DepartmentId); + var pois = await _mappingService.GetPOIsForDepartmentAsync(DepartmentId); if (action != null) { - result.Data = ConvertPersonStatus(action, department, userId); + result.Data = ConvertPersonStatus(action, department, userId, activeCalls, stations, pois); result.PageSize = 1; result.Status = ResponseHelper.Success; } @@ -142,7 +151,17 @@ public async Task> SavePersonStatus(SavePer if (String.IsNullOrWhiteSpace(input.RespondingTo) || input.RespondingTo == "0") log = await _actionLogsService.SetUserActionAsync(input.UserId, DepartmentId, int.Parse(input.Type), geolocation, cancellationToken); else - log = await _actionLogsService.SetUserActionAsync(input.UserId, DepartmentId, int.Parse(input.Type), geolocation, int.Parse(input.RespondingTo), input.Note, cancellationToken); + { + if (!int.TryParse(input.RespondingTo, out var destinationId)) + return BadRequest(); + + var destinationType = input.RespondingToType ?? (int)DestinationEntityTypes.Call; + + if (!await IsValidDestinationAsync(destinationId, destinationType)) + return BadRequest(); + + log = await _actionLogsService.SetUserActionAsync(input.UserId, DepartmentId, int.Parse(input.Type), geolocation, destinationId, destinationType, input.Note, cancellationToken); + } result.Id = log.ActionLogId.ToString(); result.PageSize = 0; @@ -201,7 +220,17 @@ public async Task> SavePersonsStatuses(S if (String.IsNullOrWhiteSpace(input.RespondingTo) || input.RespondingTo == "0") log = await _actionLogsService.SetUserActionAsync(userId, DepartmentId, int.Parse(input.Type), geolocation, cancellationToken); else - log = await _actionLogsService.SetUserActionAsync(userId, DepartmentId, int.Parse(input.Type), geolocation, int.Parse(input.RespondingTo), input.Note, cancellationToken); + { + if (!int.TryParse(input.RespondingTo, out var destinationId)) + continue; + + var destinationType = input.RespondingToType ?? (int)DestinationEntityTypes.Call; + + if (!await IsValidDestinationAsync(destinationId, destinationType)) + continue; + + log = await _actionLogsService.SetUserActionAsync(userId, DepartmentId, int.Parse(input.Type), geolocation, destinationId, destinationType, input.Note, cancellationToken); + } logIds.Add(log.ActionLogId.ToString()); } @@ -214,7 +243,7 @@ public async Task> SavePersonsStatuses(S return Created($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/api/v4/Statuses/GetAllStatusesForPersonnel", result); } - public static GetCurrentStatusResultData ConvertPersonStatus(ActionLog actionLog, Department department, string userId) + public static GetCurrentStatusResultData ConvertPersonStatus(ActionLog actionLog, Department department, string userId, List activeCalls, List stations, List pois) { var statusResult = new GetCurrentStatusResultData { @@ -238,16 +267,36 @@ public static GetCurrentStatusResultData ConvertPersonStatus(ActionLog actionLog if (actionLog.DestinationId.HasValue) { - statusResult.DestinationId = actionLog.DestinationId.Value; - - if (actionLog.DestinationType.HasValue) - statusResult.DestinationType = actionLog.DestinationType.Value; - else - statusResult.DestinationType = 2; // Call (1 = Group) + var destinationType = actionLog.GetEffectiveDestinationType(); + var destination = DestinationResolutionHelper.Resolve(actionLog.DestinationId, actionLog.DestinationType, null, activeCalls, stations, pois); + statusResult.DestinationId = destination.DestinationId; + statusResult.DestinationType = destination.DestinationType; + statusResult.DestinationName = destination.Name; + statusResult.DestinationAddress = destination.Address; + statusResult.DestinationTypeName = destination.TypeName; } } return statusResult; } + + private async Task IsValidDestinationAsync(int destinationId, int destinationType) + { + var entityType = (DestinationEntityTypes)destinationType; + + switch (entityType) + { + case DestinationEntityTypes.Station: + var station = await _departmentGroupsService.GetGroupByIdAsync(destinationId); + return station != null && station.DepartmentId == DepartmentId; + case DestinationEntityTypes.Call: + var call = await _callsService.GetCallByIdAsync(destinationId); + return call != null && call.DepartmentId == DepartmentId; + case DestinationEntityTypes.Poi: + return await _mappingService.GetDestinationPOIByIdAsync(DepartmentId, destinationId) != null; + default: + return false; + } + } } } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/UnitStatusController.cs b/Web/Resgrid.Web.Services/Controllers/v4/UnitStatusController.cs index 93d4000a1..e91be9d9d 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/UnitStatusController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/UnitStatusController.cs @@ -34,13 +34,15 @@ public class UnitStatusController : V4AuthenticatedApiControllerbase private readonly IDepartmentGroupsService _departmentGroupsService; private readonly IDepartmentSettingsService _departmentSettingsService; private readonly IActionLogsService _actionLogsService; + private readonly IMappingService _mappingService; public UnitStatusController( ICallsService callsService, IUnitsService unitsService, IDepartmentGroupsService departmentGroupsService, IDepartmentSettingsService departmentSettingsService, - IActionLogsService actionLogsService + IActionLogsService actionLogsService, + IMappingService mappingService ) { _callsService = callsService; @@ -48,6 +50,7 @@ IActionLogsService actionLogsService _departmentGroupsService = departmentGroupsService; _departmentSettingsService = departmentSettingsService; _actionLogsService = actionLogsService; + _mappingService = mappingService; } #endregion Members and Constructors @@ -69,6 +72,7 @@ public async Task> GetAllUnitStatuses() var unitStates = await _unitsService.GetAllLatestStatusForUnitsByDepartmentIdAsync(DepartmentId); var activeCalls = await _callsService.GetActiveCallsByDepartmentAsync(DepartmentId); var groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(DepartmentId); + var pois = await _mappingService.GetPOIsForDepartmentAsync(DepartmentId); var sortedUnits = from u in units let station = u.StationGroup @@ -92,12 +96,12 @@ public async Task> GetAllUnitStatuses() var customState = await CustomStatesHelper.GetCustomUnitState(stateFound); var latestUnitLocation = await _unitsService.GetLatestUnitLocationAsync(unit.Unit.UnitId, timestamp); - result.Data.Add(ConvertUnitStatusData(unit.Unit, stateFound, latestUnitLocation, customState, unit.Station, TimeZone, activeCalls, groups)); + result.Data.Add(ConvertUnitStatusData(unit.Unit, stateFound, latestUnitLocation, customState, unit.Station, TimeZone, activeCalls, groups, pois)); } else { var latestUnitLocation = await _unitsService.GetLatestUnitLocationAsync(unit.Unit.UnitId, timestamp); - result.Data.Add(ConvertUnitStatusData(unit.Unit, stateFound, latestUnitLocation, null, unit.Station, TimeZone, activeCalls, groups)); + result.Data.Add(ConvertUnitStatusData(unit.Unit, stateFound, latestUnitLocation, null, unit.Station, TimeZone, activeCalls, groups, pois)); } } @@ -142,6 +146,7 @@ public async Task> GetUnitStatus(string unitId) var activeCalls = await _callsService.GetActiveCallsByDepartmentAsync(DepartmentId); var groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(DepartmentId); + var pois = await _mappingService.GetPOIsForDepartmentAsync(DepartmentId); var status = await _unitsService.GetLastUnitStateByUnitIdAsync(int.Parse(unitId)); DepartmentGroup group = null; @@ -155,12 +160,12 @@ public async Task> GetUnitStatus(string unitId) var customState = await CustomStatesHelper.GetCustomUnitState(status); var latestUnitLocation = await _unitsService.GetLatestUnitLocationAsync(status.UnitId, timestamp); - result.Data = ConvertUnitStatusData(unit, status, latestUnitLocation, customState, group, TimeZone, activeCalls, groups); + result.Data = ConvertUnitStatusData(unit, status, latestUnitLocation, customState, group, TimeZone, activeCalls, groups, pois); } else { - var latestUnitLocation = await _unitsService.GetLatestUnitLocationAsync(status.UnitId, timestamp); - result.Data = ConvertUnitStatusData(unit, status, latestUnitLocation, null, group, TimeZone, activeCalls, groups); + var latestUnitLocation = await _unitsService.GetLatestUnitLocationAsync(unit.UnitId, timestamp); + result.Data = ConvertUnitStatusData(unit, null, latestUnitLocation, null, group, TimeZone, activeCalls, groups, pois); } result.PageSize = 1; @@ -249,7 +254,16 @@ public async Task> GetUnitStatus(string unitId) } if (!string.IsNullOrWhiteSpace(stateInput.RespondingTo) && int.Parse(stateInput.RespondingTo) > 0) - state.DestinationId = int.Parse(stateInput.RespondingTo); + { + var destinationType = stateInput.RespondingToType ?? (int)DestinationEntityTypes.Call; + var destinationId = int.Parse(stateInput.RespondingTo); + + if (!await IsValidDestinationAsync(destinationId, destinationType)) + return BadRequest(); + + state.DestinationId = destinationId; + state.DestinationType = destinationType; + } var savedState = await _unitsService.SetUnitStateAsync(state, DepartmentId); @@ -327,15 +341,18 @@ public async Task> GetUnitStatus(string unitId) public static UnitStatusResultData ConvertUnitStatusData(Unit unit, UnitState stateFound, UnitsLocation latestUnitLocation, - CustomStateDetail customState, DepartmentGroup group, string timeZone, List activeCalls, List groups) + CustomStateDetail customState, DepartmentGroup group, string timeZone, List activeCalls, List groups, List pois) { var state = "Unknown"; var stateCss = ""; var stateStyle = ""; int? destinationId = 0; + int? destinationType = 0; decimal? latitude = 0; decimal? longitude = 0; var destinationName = ""; + var destinationAddress = ""; + var destinationTypeName = ""; DateTime timestamp = DateTime.UtcNow; if (stateFound != null) @@ -345,49 +362,6 @@ public static UnitStatusResultData ConvertUnitStatusData(Unit unit, UnitState st state = customState.ButtonText; stateCss = customState.ButtonColor; stateStyle = customState.ButtonColor; - - if (customState.DetailType == (int)CustomStateDetailTypes.Calls) - { - if (activeCalls != null && activeCalls.Any()) - { - var call = activeCalls.FirstOrDefault(x => x.CallId == stateFound.DestinationId); - if (call != null) - { - destinationName = call.Number; - } - } - } - else if (customState.DetailType == (int)CustomStateDetailTypes.Stations) - { - if (groups != null && groups.Any()) - { - var station = groups.FirstOrDefault(x => x.DepartmentGroupId == stateFound.DestinationId); - if (station != null) - { - destinationName = station.Name; - } - } - } - else if (customState.DetailType == (int)CustomStateDetailTypes.CallsAndStations) - { - if (groups != null && groups.Any() && activeCalls != null && activeCalls.Any()) - { - // First try and get the station, as a station can get a call (based on Id) but the inverse is hard - var station = groups.FirstOrDefault(x => x.DepartmentGroupId == stateFound.DestinationId); - if (station != null) - { - destinationName = station.Name; - } - else - { - var call = activeCalls.FirstOrDefault(x => x.CallId == stateFound.DestinationId); - if (call != null) - { - destinationName = call.Number; - } - } - } - } } else { @@ -395,7 +369,12 @@ public static UnitStatusResultData ConvertUnitStatusData(Unit unit, UnitState st stateCss = stateFound.ToStateCss(); } - destinationId = stateFound.DestinationId; + var resolvedDestination = DestinationResolutionHelper.Resolve(stateFound.DestinationId, stateFound.DestinationType, customState?.DetailType, activeCalls, groups, pois); + destinationId = resolvedDestination.DestinationId; + destinationType = resolvedDestination.DestinationType; + destinationName = resolvedDestination.Name; + destinationAddress = resolvedDestination.Address; + destinationTypeName = resolvedDestination.TypeName; latitude = stateFound.Latitude; longitude = stateFound.Longitude; timestamp = stateFound.Timestamp; @@ -426,14 +405,36 @@ public static UnitStatusResultData ConvertUnitStatusData(Unit unit, UnitState st TimestampUtc = timestamp, Timestamp = timestamp.TimeConverter(new Department() { TimeZone = timeZone }), DestinationId = destinationId, + DestinationType = destinationType, Latitude = latitude, Longitude = longitude, GroupId = groupId, GroupName = groupName, - DestinationName = destinationName + DestinationName = destinationName, + DestinationAddress = destinationAddress, + DestinationTypeName = destinationTypeName }; return unitViewModel; } + + private async Task IsValidDestinationAsync(int destinationId, int destinationType) + { + var entityType = (DestinationEntityTypes)destinationType; + + switch (entityType) + { + case DestinationEntityTypes.Station: + var station = await _departmentGroupsService.GetGroupByIdAsync(destinationId); + return station != null && station.DepartmentId == DepartmentId; + case DestinationEntityTypes.Call: + var call = await _callsService.GetCallByIdAsync(destinationId); + return call != null && call.DepartmentId == DepartmentId; + case DestinationEntityTypes.Poi: + return await _mappingService.GetDestinationPOIByIdAsync(DepartmentId, destinationId) != null; + default: + return false; + } + } } } diff --git a/Web/Resgrid.Web.Services/Models/v4/Calls/CallResult.cs b/Web/Resgrid.Web.Services/Models/v4/Calls/CallResult.cs index 5386c1de3..962827f9a 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Calls/CallResult.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Calls/CallResult.cs @@ -53,6 +53,43 @@ public class CallResultData /// public string Address { get; set; } + /// + /// Destination POI id if the call has a destination. + /// + public int? DestinationPoiId { get; set; } + + /// + /// Destination display name. + /// + public string DestinationName { get; set; } + + /// + /// Destination address. + /// + public string DestinationAddress { get; set; } + + /// + /// Localized display label for the destination type (e.g. "POI", "Station"). Not suitable + /// for programmatic branching; use as the + /// machine-readable POI type identifier instead. + /// + public string DestinationTypeName { get; set; } + + /// + /// Destination POI type id. + /// + public int? DestinationPoiTypeId { get; set; } + + /// + /// Destination latitude. + /// + public double? DestinationLatitude { get; set; } + + /// + /// Destination longitude. + /// + public double? DestinationLongitude { get; set; } + /// /// Geo location Coordinates /// diff --git a/Web/Resgrid.Web.Services/Models/v4/Calls/EditCallInput.cs b/Web/Resgrid.Web.Services/Models/v4/Calls/EditCallInput.cs index 6e7549c2a..080770931 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Calls/EditCallInput.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Calls/EditCallInput.cs @@ -43,6 +43,11 @@ public class EditCallInput /// public string Address { get; set; } + /// + /// Optional destination POI id for transfers, transports, and relocations. + /// + public int? DestinationPoiId { get; set; } + /// /// Geolocation data "lat,lon" /// diff --git a/Web/Resgrid.Web.Services/Models/v4/Calls/NewCallInput.cs b/Web/Resgrid.Web.Services/Models/v4/Calls/NewCallInput.cs index 6655142e9..0e4e7b5e6 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Calls/NewCallInput.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Calls/NewCallInput.cs @@ -38,6 +38,11 @@ public class NewCallInput /// public string Address { get; set; } + /// + /// Optional destination POI id for transfers, transports, and relocations. + /// + public int? DestinationPoiId { get; set; } + /// /// Geolocation data "lat,lon" in decimal format /// diff --git a/Web/Resgrid.Web.Services/Models/v4/Dispatch/GetSetUnitStateResult.cs b/Web/Resgrid.Web.Services/Models/v4/Dispatch/GetSetUnitStateResult.cs index df59915c9..87c267f72 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Dispatch/GetSetUnitStateResult.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Dispatch/GetSetUnitStateResult.cs @@ -1,6 +1,7 @@ -using Resgrid.Web.Services.Models.v4.Calls; +using Resgrid.Web.Services.Models.v4.Calls; using Resgrid.Web.Services.Models.v4.CustomStatuses; using Resgrid.Web.Services.Models.v4.Groups; +using Resgrid.Web.Services.Models.v4.Mapping; using System.Collections.Generic; namespace Resgrid.Web.Services.Models.v4.Dispatch @@ -41,6 +42,16 @@ public class GetSetUnitStateResultData /// public List Calls { get; set; } + /// + /// Destination POIs the unit can respond to. + /// + public List DestinationPois { get; set; } + + /// + /// POI types available to the department. + /// + public List PoiTypes { get; set; } + /// /// Status types /// diff --git a/Web/Resgrid.Web.Services/Models/v4/Dispatch/NewCallFormResult.cs b/Web/Resgrid.Web.Services/Models/v4/Dispatch/NewCallFormResult.cs index 5f40deb7c..e415be77c 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Dispatch/NewCallFormResult.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Dispatch/NewCallFormResult.cs @@ -1,7 +1,8 @@ -using Resgrid.Web.Services.Models.v4.CallPriorities; +using Resgrid.Web.Services.Models.v4.CallPriorities; using Resgrid.Web.Services.Models.v4.CallTypes; using Resgrid.Web.Services.Models.v4.CustomStatuses; using Resgrid.Web.Services.Models.v4.Groups; +using Resgrid.Web.Services.Models.v4.Mapping; using Resgrid.Web.Services.Models.v4.Personnel; using Resgrid.Web.Services.Models.v4.Roles; using Resgrid.Web.Services.Models.v4.UnitRoles; @@ -36,5 +37,7 @@ public class NewCallResultData public List UnitRoles { get; set; } public List Priorities { get; set; } public List CallTypes { get; set; } + public List PoiTypes { get; set; } + public List DestinationPois { get; set; } } } diff --git a/Web/Resgrid.Web.Services/Models/v4/Mapping/GetMapDataResult.cs b/Web/Resgrid.Web.Services/Models/v4/Mapping/GetMapDataResult.cs index 287b768a0..06f65350d 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Mapping/GetMapDataResult.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Mapping/GetMapDataResult.cs @@ -23,12 +23,14 @@ public class GetMapDataResultData public GetMapDataResultData() { MapMakerInfos = new List(); + PoiLayers = new List(); } public double CenterLat { get; set; } public double CenterLon { get; set; } public int ZoomLevel { get; set; } public List MapMakerInfos { get; set; } + public List PoiLayers { get; set; } } public class MapMakerInfoData @@ -42,5 +44,22 @@ public class MapMakerInfoData public string InfoWindowContent { get; set; } public string Color { get; set; } public int Type { get; set; } + public string Marker { get; set; } + public int? PoiTypeId { get; set; } + public string PoiTypeName { get; set; } + public string Address { get; set; } + public string Note { get; set; } + public string LayerId { get; set; } + public string LayerName { get; set; } + } + + public class PoiLayerData + { + public int PoiTypeId { get; set; } + public string Name { get; set; } + public string Color { get; set; } + public string ImagePath { get; set; } + public string Marker { get; set; } + public bool IsDestination { get; set; } } } diff --git a/Web/Resgrid.Web.Services/Models/v4/Mapping/PoiResultModels.cs b/Web/Resgrid.Web.Services/Models/v4/Mapping/PoiResultModels.cs new file mode 100644 index 000000000..c37a1d966 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Mapping/PoiResultModels.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; + +namespace Resgrid.Web.Services.Models.v4.Mapping +{ + public class PoiTypeResultData + { + public int PoiTypeId { get; set; } + public string Name { get; set; } + public string Color { get; set; } + public string ImagePath { get; set; } + public string Marker { get; set; } + public bool IsDestination { get; set; } + } + + public class PoiResultData + { + public int PoiId { get; set; } + public int PoiTypeId { get; set; } + public string PoiTypeName { get; set; } + public string Name { get; set; } + public string Address { get; set; } + public string Note { get; set; } + public double Latitude { get; set; } + public double Longitude { get; set; } + public string Color { get; set; } + public string ImagePath { get; set; } + public string Marker { get; set; } + public bool IsDestination { get; set; } + } + + public class PoiTypesResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + public class PoisResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + public class PoiResult : StandardApiResponseV4Base + { + public PoiResultData Data { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/PersonnelStatuses/GetCurrentStatusResult.cs b/Web/Resgrid.Web.Services/Models/v4/PersonnelStatuses/GetCurrentStatusResult.cs index 2824277ad..5098e1a3d 100644 --- a/Web/Resgrid.Web.Services/Models/v4/PersonnelStatuses/GetCurrentStatusResult.cs +++ b/Web/Resgrid.Web.Services/Models/v4/PersonnelStatuses/GetCurrentStatusResult.cs @@ -49,10 +49,28 @@ public class GetCurrentStatusResultData public int? DestinationId { get; set; } /// - /// Destination type for the action log + /// Machine-readable destination type for the action log (None = 0, Station = 1, Call = 2, POI = 3). + /// Use this for programmatic branching; see for the localized display label. /// public int? DestinationType { get; set; } + /// + /// Destination display name. + /// + public string DestinationName { get; set; } + + /// + /// Destination address. + /// + public string DestinationAddress { get; set; } + + /// + /// Localized display label for the destination type (e.g. "Station", "Call", "POI"). Not + /// suitable for programmatic branching; use as the + /// machine-readable discriminator instead. + /// + public string DestinationTypeName { get; set; } + /// /// Geolocation for this status /// diff --git a/Web/Resgrid.Web.Services/Models/v4/PersonnelStatuses/SavePersonStatusInput.cs b/Web/Resgrid.Web.Services/Models/v4/PersonnelStatuses/SavePersonStatusInput.cs index 45f201bf9..bd6313b1b 100644 --- a/Web/Resgrid.Web.Services/Models/v4/PersonnelStatuses/SavePersonStatusInput.cs +++ b/Web/Resgrid.Web.Services/Models/v4/PersonnelStatuses/SavePersonStatusInput.cs @@ -25,6 +25,11 @@ public class SavePersonStatusInput /// public string RespondingTo { get; set; } + /// + /// Destination type for RespondingTo (Station = 1, Call = 2, POI = 3). + /// + public int? RespondingToType { get; set; } + /// /// The timestamp of the status event in UTC /// diff --git a/Web/Resgrid.Web.Services/Models/v4/PersonnelStatuses/SavePersonsStatusesInput.cs b/Web/Resgrid.Web.Services/Models/v4/PersonnelStatuses/SavePersonsStatusesInput.cs index 5af4a3478..d871d5a9e 100644 --- a/Web/Resgrid.Web.Services/Models/v4/PersonnelStatuses/SavePersonsStatusesInput.cs +++ b/Web/Resgrid.Web.Services/Models/v4/PersonnelStatuses/SavePersonsStatusesInput.cs @@ -26,6 +26,11 @@ public class SavePersonsStatusesInput /// public string RespondingTo { get; set; } + /// + /// Destination type for RespondingTo (Station = 1, Call = 2, POI = 3). + /// + public int? RespondingToType { get; set; } + /// /// The timestamp of the status event in UTC /// diff --git a/Web/Resgrid.Web.Services/Models/v4/UnitStatus/UnitStatusInput.cs b/Web/Resgrid.Web.Services/Models/v4/UnitStatus/UnitStatusInput.cs index b720759da..489dd4a8d 100644 --- a/Web/Resgrid.Web.Services/Models/v4/UnitStatus/UnitStatusInput.cs +++ b/Web/Resgrid.Web.Services/Models/v4/UnitStatus/UnitStatusInput.cs @@ -27,6 +27,11 @@ public class UnitStatusInput /// public string RespondingTo { get; set; } + /// + /// Destination type for RespondingTo (Station = 1, Call = 2, POI = 3). + /// + public int? RespondingToType { get; set; } + /// /// The timestamp of the status event in UTC /// diff --git a/Web/Resgrid.Web.Services/Models/v4/UnitStatus/UnitStatusResult.cs b/Web/Resgrid.Web.Services/Models/v4/UnitStatus/UnitStatusResult.cs index b9b7d2b99..63831d230 100644 --- a/Web/Resgrid.Web.Services/Models/v4/UnitStatus/UnitStatusResult.cs +++ b/Web/Resgrid.Web.Services/Models/v4/UnitStatus/UnitStatusResult.cs @@ -63,11 +63,28 @@ public class UnitStatusResultData /// public int? DestinationId { get; set; } + /// + /// Destination type (Station, Call, or POI). + /// + public int? DestinationType { get; set; } + /// /// Name of the Desination (Call or Station) /// public string DestinationName { get; set; } + /// + /// Destination address. + /// + public string DestinationAddress { get; set; } + + /// + /// Localized display label for the destination type (e.g. "Station", "Call", "POI"). Not + /// suitable for programmatic branching; use as the + /// machine-readable discriminator instead. + /// + public string DestinationTypeName { get; set; } + /// /// Note for the State /// diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 6138833b4..69c5e2869 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -5100,6 +5100,43 @@ Call Address + + + Destination POI id if the call has a destination. + + + + + Destination display name. + + + + + Destination address. + + + + + Localized display label for the destination type (e.g. "POI", "Station"). Not suitable + for programmatic branching; use as the + machine-readable POI type identifier instead. + + + + + Destination POI type id. + + + + + Destination latitude. + + + + + Destination longitude. + + Geo location Coordinates @@ -5285,6 +5322,11 @@ Address + + + Optional destination POI id for transfers, transports, and relocations. + + Geolocation data "lat,lon" @@ -5395,6 +5437,11 @@ Address + + + Optional destination POI id for transfers, transports, and relocations. + + Geolocation data "lat,lon" in decimal format @@ -6559,6 +6606,16 @@ Calls the unit can respond to + + + Destination POIs the unit can respond to. + + + + + POI types available to the department. + + Status types @@ -7569,7 +7626,25 @@ - Destination type for the action log + Machine-readable destination type for the action log (None = 0, Station = 1, Call = 2, POI = 3). + Use this for programmatic branching; see for the localized display label. + + + + + Destination display name. + + + + + Destination address. + + + + + Localized display label for the destination type (e.g. "Station", "Call", "POI"). Not + suitable for programmatic branching; use as the + machine-readable discriminator instead. @@ -7602,6 +7677,11 @@ The Call/Station the unit is responding to + + + Destination type for RespondingTo (Station = 1, Call = 2, POI = 3). + + The timestamp of the status event in UTC @@ -7687,6 +7767,11 @@ The Call/Station the unit is responding to + + + Destination type for RespondingTo (Station = 1, Call = 2, POI = 3). + + The timestamp of the status event in UTC @@ -9252,6 +9337,11 @@ The Call/Station the unit is responding to + + + Destination type for RespondingTo (Station = 1, Call = 2, POI = 3). + + The timestamp of the status event in UTC @@ -9402,11 +9492,28 @@ Destination Id (Station or Call) + + + Destination type (Station, Call, or POI). + + Name of the Desination (Call or Station) + + + Destination address. + + + + + Localized display label for the destination type (e.g. "Station", "Call", "POI"). Not + suitable for programmatic branching; use as the + machine-readable discriminator instead. + + Note for the State diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/map/LeafletMapView.tsx b/Web/Resgrid.Web/Areas/User/Apps/src/components/map/LeafletMapView.tsx index cb761af75..6c2de6d9d 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/map/LeafletMapView.tsx +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/map/LeafletMapView.tsx @@ -5,6 +5,8 @@ import 'leaflet/dist/leaflet.css'; import { getLayerColor, getMarkerIconUrl, + getPoiMarkerShapePath, + isPoiMarker, type MapMarkerInfo, type MapRendererProps, } from './mapTypes'; @@ -15,11 +17,35 @@ interface MarkerState { longitude: number; title: string; imagePath: string; + markerShape: string; + color: string; + markerType: number; infoWindowContent: string; hideLabels: boolean; } -function createMarkerIcon(marker: MapMarkerInfo): L.Icon { +function createMarkerIcon(marker: MapMarkerInfo): L.Icon | L.DivIcon { + if (isPoiMarker(marker)) { + const iconClass = typeof marker.ImagePath === 'string' && marker.ImagePath.length > 0 + ? marker.ImagePath + : 'map-icon-map-pin'; + const color = marker.Color || '#2563eb'; + + return L.divIcon({ + className: 'rg-map__poi-marker-wrapper', + html: `
+ + +
`, + iconSize: [36, 48], + iconAnchor: [18, 48], + popupAnchor: [0, -42], + tooltipAnchor: [0, 20], + }); + } + return L.icon({ iconUrl: getMarkerIconUrl(marker), iconSize: [32, 37], @@ -143,10 +169,13 @@ export default function LeafletMapView({ const existingMarkerState = markerRefs.current.get(markerInfo.Id); const appearanceChanged = !existingMarkerState || - existingMarkerState.title !== markerInfo.Title || - existingMarkerState.imagePath !== markerInfo.ImagePath || - existingMarkerState.infoWindowContent !== markerInfo.InfoWindowContent || - existingMarkerState.hideLabels !== hideLabels; + existingMarkerState.title !== markerInfo.Title || + existingMarkerState.imagePath !== markerInfo.ImagePath || + existingMarkerState.markerShape !== (markerInfo.Marker ?? '') || + existingMarkerState.color !== (markerInfo.Color ?? '') || + existingMarkerState.markerType !== markerInfo.Type || + existingMarkerState.infoWindowContent !== markerInfo.InfoWindowContent || + existingMarkerState.hideLabels !== hideLabels; if (appearanceChanged) { existingMarkerState?.marker.remove(); @@ -160,6 +189,9 @@ export default function LeafletMapView({ longitude: markerInfo.Longitude, title: markerInfo.Title, imagePath: markerInfo.ImagePath, + markerShape: markerInfo.Marker ?? '', + color: markerInfo.Color ?? '', + markerType: markerInfo.Type, infoWindowContent: markerInfo.InfoWindowContent, hideLabels, }); diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/map/MapElement.tsx b/Web/Resgrid.Web/Areas/User/Apps/src/components/map/MapElement.tsx index d9f8c7196..c64b41ee5 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/map/MapElement.tsx +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/map/MapElement.tsx @@ -8,7 +8,9 @@ import { type UnitLocationUpdate, } from '../../runtime/signalr'; import { + getPoiLayerId, isMapboxRendererEnabled, + isPoiMarker, mapMarkerTypes, normalizeMapLayers, resolveMapConfig, @@ -31,6 +33,36 @@ function getErrorMessage(error: unknown, fallbackMessage: string): string { return fallbackMessage; } +function readBooleanPreference(storageKey: string, fallbackValue: boolean): boolean { + if (typeof window === 'undefined') { + return fallbackValue; + } + + try { + const storedValue = window.localStorage.getItem(storageKey); + + if (storedValue === null) { + return fallbackValue; + } + + return storedValue === 'true'; + } catch { + return fallbackValue; + } +} + +function writeBooleanPreference(storageKey: string, value: boolean): void { + if (typeof window === 'undefined') { + return; + } + + try { + window.localStorage.setItem(storageKey, value ? 'true' : 'false'); + } catch { + // Ignore storage failures and fall back to in-memory state. + } +} + export default function MapElement(props: MapElementProps) { const connectionRef = useRef(null); @@ -38,11 +70,13 @@ export default function MapElement(props: MapElementProps) { const [layers, setLayers] = useState([]); const [layerVisibility, setLayerVisibility] = useState>({}); const [filterText, setFilterText] = useState(''); - const [showCalls, setShowCalls] = useState(true); - const [showStations, setShowStations] = useState(true); - const [showUnits, setShowUnits] = useState(true); - const [showPersonnel, setShowPersonnel] = useState(true); - const [hideLabels, setHideLabels] = useState(false); + const [showCalls, setShowCalls] = useState(() => readBooleanPreference('rg-map:show-calls', true)); + const [showStations, setShowStations] = useState(() => readBooleanPreference('rg-map:show-stations', true)); + const [showUnits, setShowUnits] = useState(() => readBooleanPreference('rg-map:show-units', true)); + const [showPersonnel, setShowPersonnel] = useState(() => readBooleanPreference('rg-map:show-personnel', true)); + const [showPois, setShowPois] = useState(() => readBooleanPreference('rg-map:show-pois', true)); + const [poiLayerVisibility, setPoiLayerVisibility] = useState>({}); + const [hideLabels, setHideLabels] = useState(() => readBooleanPreference('rg-map:hide-labels', false)); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [lastUpdated, setLastUpdated] = useState(() => new Date().toString()); @@ -71,6 +105,7 @@ export default function MapElement(props: MapElementProps) { [resolvedMapConfig], ); const hasMapSource = useMapboxRenderer || resolvedMapConfig.tileUrl.length > 0; + const poiLayers = useMemo(() => mapData?.PoiLayers ?? [], [mapData]); const visibleMarkers = useMemo(() => { const normalizedFilter = filterText.trim().toLowerCase(); @@ -90,14 +125,17 @@ export default function MapElement(props: MapElementProps) { }; }) .filter((marker) => { + const markerTitle = typeof marker.Title === 'string' ? marker.Title.toLowerCase() : ''; + const markerType = typeof marker.Type === 'string' ? Number.parseInt(marker.Type, 10) : marker.Type; + const isPoi = isPoiMarker(marker); const matchesFilter = - normalizedFilter.length === 0 || marker.Title.toLowerCase().includes(normalizedFilter); + normalizedFilter.length === 0 || markerTitle.includes(normalizedFilter); if (!matchesFilter) { return false; } - switch (marker.Type) { + switch (markerType) { case mapMarkerTypes.call: return showCalls; case mapMarkerTypes.unit: @@ -106,11 +144,42 @@ export default function MapElement(props: MapElementProps) { return showStations; case mapMarkerTypes.personnel: return showPersonnel; + case mapMarkerTypes.poi: + return showPois && (poiLayerVisibility[getPoiLayerId(marker)] ?? true); default: - return true; + return !isPoi || (showPois && (poiLayerVisibility[getPoiLayerId(marker)] ?? true)); } }); - }, [filterText, mapData, markerPositionOverrides, showCalls, showPersonnel, showStations, showUnits]); + }, [ + filterText, + mapData, + markerPositionOverrides, + poiLayerVisibility, + showCalls, + showPersonnel, + showPois, + showStations, + showUnits, + ]); + + useEffect(() => writeBooleanPreference('rg-map:show-calls', showCalls), [showCalls]); + useEffect(() => writeBooleanPreference('rg-map:show-stations', showStations), [showStations]); + useEffect(() => writeBooleanPreference('rg-map:show-units', showUnits), [showUnits]); + useEffect(() => writeBooleanPreference('rg-map:show-personnel', showPersonnel), [showPersonnel]); + useEffect(() => writeBooleanPreference('rg-map:show-pois', showPois), [showPois]); + useEffect(() => writeBooleanPreference('rg-map:hide-labels', hideLabels), [hideLabels]); + + useEffect(() => { + Object.entries(layerVisibility).forEach(([layerId, isVisible]) => { + writeBooleanPreference(`rg-map:layer:${layerId}`, isVisible); + }); + }, [layerVisibility]); + + useEffect(() => { + Object.entries(poiLayerVisibility).forEach(([layerId, isVisible]) => { + writeBooleanPreference(`rg-map:poi-layer:${layerId}`, isVisible); + }); + }, [poiLayerVisibility]); useEffect(() => { let cancelled = false; @@ -177,7 +246,16 @@ export default function MapElement(props: MapElementProps) { setLayers(normalizedLayers); setLayerVisibility( normalizedLayers.reduce>((result, layer) => { - result[layer.id] = layer.isOnByDefault; + result[layer.id] = readBooleanPreference(`rg-map:layer:${layer.id}`, layer.isOnByDefault); + return result; + }, {}), + ); + + const normalizedPoiLayers = mapResponse.Data?.PoiLayers ?? []; + setPoiLayerVisibility( + normalizedPoiLayers.reduce>((result, poiLayer) => { + const layerId = getPoiLayerId(poiLayer); + result[layerId] = readBooleanPreference(`rg-map:poi-layer:${layerId}`, true); return result; }, {}), ); @@ -280,6 +358,29 @@ export default function MapElement(props: MapElementProps) { const combinedError = error ?? rendererError; + const handleShowPoisChanged = (checked: boolean) => { + setShowPois(checked); + + if (!checked) { + return; + } + + setPoiLayerVisibility((currentVisibility) => { + const hasVisiblePoiLayer = poiLayers.some( + (poiLayer) => currentVisibility[getPoiLayerId(poiLayer)] ?? true, + ); + + if (hasVisiblePoiLayer) { + return currentVisibility; + } + + return poiLayers.reduce>((nextVisibility, poiLayer) => { + nextVisibility[getPoiLayerId(poiLayer)] = true; + return nextVisibility; + }, { ...currentVisibility }); + }); + }; + return (
{showButtons && ( @@ -339,6 +440,15 @@ export default function MapElement(props: MapElementProps) { /> Show personnel + +
)} @@ -354,32 +464,72 @@ export default function MapElement(props: MapElementProps) { markers={visibleMarkers} layers={layers} layerVisibility={layerVisibility} + poiLayers={poiLayers} + poiLayerVisibility={poiLayerVisibility} hideLabels={hideLabels} resolvedMapConfig={resolvedMapConfig} fitBoundsKey={fitBoundsKey} /> )} - {layers.length > 0 && ( + {(layers.length > 0 || poiLayers.length > 0) && (
-
Map layers
-
- {layers.map((layer) => ( - - ))} -
+ {layers.length > 0 && ( +
+
Map layers
+
+ {layers.map((layer) => ( + + ))} +
+
+ )} + + {poiLayers.length > 0 && ( +
+
POI layers
+
+ {poiLayers.map((poiLayer) => { + const layerId = getPoiLayerId(poiLayer); + + return ( + + ); + })} +
+
+ )}
)} diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/map/MapboxMapView.tsx b/Web/Resgrid.Web/Areas/User/Apps/src/components/map/MapboxMapView.tsx index 65996c936..752a023ef 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/map/MapboxMapView.tsx +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/map/MapboxMapView.tsx @@ -3,6 +3,8 @@ import 'mapbox-gl/dist/mapbox-gl.css'; import { getLayerColor, getMarkerIconUrl, + getPoiMarkerShapePath, + isPoiMarker, type MapMarkerInfo, type MapRendererProps, } from './mapTypes'; @@ -17,6 +19,9 @@ interface MarkerState { longitude: number; title: string; imagePath: string; + markerShape: string; + color: string; + markerType: number; infoWindowContent: string; hideLabels: boolean; } @@ -26,11 +31,31 @@ function createMarkerElement(markerInfo: MapMarkerInfo, hideLabels: boolean): HT wrapper.className = 'rg-map__marker'; wrapper.title = hideLabels ? '' : markerInfo.Title; - const icon = document.createElement('img'); - icon.className = 'rg-map__marker-icon'; - icon.src = getMarkerIconUrl(markerInfo); - icon.alt = ''; - wrapper.appendChild(icon); + if (isPoiMarker(markerInfo)) { + wrapper.classList.add('rg-map__marker--poi'); + wrapper.style.setProperty('--rg-map-poi-color', markerInfo.Color || '#2563eb'); + + const markerShape = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + markerShape.setAttribute('viewBox', '-24 -48 48 48'); + markerShape.setAttribute('class', 'rg-map__poi-marker-shape'); + + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', getPoiMarkerShapePath(markerInfo.Marker)); + markerShape.appendChild(path); + + const icon = document.createElement('span'); + icon.className = `map-icon ${markerInfo.ImagePath || 'map-icon-map-pin'} rg-map__poi-marker-icon`; + icon.setAttribute('aria-hidden', 'true'); + + wrapper.appendChild(markerShape); + wrapper.appendChild(icon); + } else { + const icon = document.createElement('img'); + icon.className = 'rg-map__marker-icon'; + icon.src = getMarkerIconUrl(markerInfo); + icon.alt = ''; + wrapper.appendChild(icon); + } if (!hideLabels && markerInfo.Title) { const label = document.createElement('div'); @@ -259,10 +284,13 @@ export default function MapboxMapView({ const existingMarkerState = markerRefs.current.get(markerInfo.Id); const appearanceChanged = !existingMarkerState || - existingMarkerState.title !== markerInfo.Title || - existingMarkerState.imagePath !== markerInfo.ImagePath || - existingMarkerState.infoWindowContent !== markerInfo.InfoWindowContent || - existingMarkerState.hideLabels !== hideLabels; + existingMarkerState.title !== markerInfo.Title || + existingMarkerState.imagePath !== markerInfo.ImagePath || + existingMarkerState.markerShape !== (markerInfo.Marker ?? '') || + existingMarkerState.color !== (markerInfo.Color ?? '') || + existingMarkerState.markerType !== markerInfo.Type || + existingMarkerState.infoWindowContent !== markerInfo.InfoWindowContent || + existingMarkerState.hideLabels !== hideLabels; if (appearanceChanged) { existingMarkerState?.marker.remove(); @@ -284,6 +312,9 @@ export default function MapboxMapView({ longitude: markerInfo.Longitude, title: markerInfo.Title, imagePath: markerInfo.ImagePath, + markerShape: markerInfo.Marker ?? '', + color: markerInfo.Color ?? '', + markerType: markerInfo.Type, infoWindowContent: markerInfo.InfoWindowContent, hideLabels, }); diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/map/map.css b/Web/Resgrid.Web/Areas/User/Apps/src/components/map/map.css index 8adb2234a..20f5ae033 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/map/map.css +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/map/map.css @@ -91,6 +91,20 @@ gap: 6px; } +.rg-map__layer-section + .rg-map__layer-section { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid #e5e7eb; +} + +.rg-map__layer-swatch { + width: 12px; + height: 12px; + border: 1px solid rgba(17, 24, 39, 0.2); + border-radius: 999px; + flex: 0 0 auto; +} + .rg-map__overlay { position: absolute; inset: 0; @@ -127,6 +141,13 @@ cursor: pointer; } +.rg-map__marker--poi { + position: relative; + width: 36px; + min-height: 48px; + justify-content: flex-start; +} + .rg-map__marker-icon { width: 32px; height: 37px; @@ -134,6 +155,44 @@ pointer-events: none; } +.rg-map__poi-marker-wrapper { + background: transparent; + border: 0; +} + +.rg-map__poi-marker { + position: relative; + width: 36px; + height: 48px; +} + +.rg-map__poi-marker-shape { + width: 36px; + height: 48px; + fill: var(--rg-map-poi-color, #2563eb); + filter: drop-shadow(0 1px 2px rgba(17, 24, 39, 0.35)); +} + +.rg-map__poi-marker-icon { + position: absolute; + top: 10px; + left: 50%; + transform: translateX(-50%); + color: #ffffff; + font-size: 14px; + line-height: 1; + pointer-events: none; +} + +.rg-map__marker--poi .rg-map__poi-marker-shape { + width: 36px; + height: 48px; +} + +.rg-map__marker--poi .rg-map__poi-marker-icon { + top: 10px; +} + .rg-map__marker-label { max-width: 180px; padding: 2px 6px; diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/map/mapTypes.ts b/Web/Resgrid.Web/Areas/User/Apps/src/components/map/mapTypes.ts index 4e85c7c07..280660ee8 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/map/mapTypes.ts +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/map/mapTypes.ts @@ -5,6 +5,7 @@ export const mapMarkerTypes = { unit: 1, station: 2, personnel: 3, + poi: 4, } as const; export interface MapMarkerInfo { @@ -16,7 +17,23 @@ export interface MapMarkerInfo { ImagePath: string; InfoWindowContent: string; Color: string; - Type: number; + Type: number | string; + Marker?: string; + PoiTypeId?: number | null; + PoiTypeName?: string; + Address?: string; + Note?: string; + LayerId?: string; + LayerName?: string; +} + +export interface PoiLayerInfo { + PoiTypeId: number; + Name: string; + Color: string; + ImagePath: string; + Marker: string; + IsDestination: boolean; } export interface GetMapDataResult { @@ -25,6 +42,7 @@ export interface GetMapDataResult { CenterLon: number; ZoomLevel: number; MapMakerInfos: MapMarkerInfo[]; + PoiLayers?: PoiLayerInfo[]; }; } @@ -94,11 +112,23 @@ export interface MapRendererProps { markers: MapMarkerInfo[]; layers: NormalizedMapLayer[]; layerVisibility: Record; + poiLayers: PoiLayerInfo[]; + poiLayerVisibility: Record; hideLabels: boolean; resolvedMapConfig: ResolvedMapConfig; fitBoundsKey: string; } +const defaultPoiMarkerShape = 'MAP_PIN'; + +const poiMarkerPaths: Record = { + MAP_PIN: 'M0-48c-9.8 0-17.7 7.8-17.7 17.4 0 15.5 17.7 30.6 17.7 30.6s17.7-15.4 17.7-30.6c0-9.6-7.9-17.4-17.7-17.4z', + SHIELD: 'M18.8-31.8c.3-3.4 1.3-6.6 3.2-9.5l-7-6.7c-2.2 1.8-4.8 2.8-7.6 3-2.6.2-5.1-.2-7.5-1.4-2.4 1.1-4.9 1.6-7.5 1.4-2.7-.2-5.1-1.1-7.3-2.7l-7.1 6.7c1.7 2.9 2.7 6 2.9 9.2.1 1.5-.3 3.5-1.3 6.1-.5 1.5-.9 2.7-1.2 3.8-.2 1-.4 1.9-.5 2.5 0 2.8.8 5.3 2.5 7.5 1.3 1.6 3.5 3.4 6.5 5.4 3.3 1.6 5.8 2.6 7.6 3.1.5.2 1 .4 1.5.7l1.5.6c1.2.7 2 1.4 2.4 2.1.5-.8 1.3-1.5 2.4-2.1.7-.3 1.3-.5 1.9-.8.5-.2.9-.4 1.1-.5.4-.1.9-.3 1.5-.6.6-.2 1.3-.5 2.2-.8 1.7-.6 3-1.1 3.8-1.6 2.9-2 5.1-3.8 6.4-5.3 1.7-2.2 2.6-4.8 2.5-7.6-.1-1.3-.7-3.3-1.7-6.1-.9-2.8-1.3-4.9-1.2-6.4z', + ROUTE: 'M24-28.3c-.2-13.3-7.9-18.5-8.3-18.7l-1.2-.8-1.2.8c-2 1.4-4.1 2-6.1 2-3.4 0-5.8-1.9-5.9-1.9l-1.3-1.1-1.3 1.1c-.1.1-2.5 1.9-5.9 1.9-2.1 0-4.1-.7-6.1-2l-1.2-.8-1.2.8c-.8.6-8 5.9-8.2 18.7-.2 1.1 2.9 22.2 23.9 28.3 22.9-6.7 24.1-26.9 24-28.3z', + SQUARE: 'M-24-48h48v48h-48z', + SQUARE_ROUNDED: 'M24-8c0 4.4-3.6 8-8 8h-32c-4.4 0-8-3.6-8-8v-32c0-4.4 3.6-8 8-8h32c4.4 0 8 3.6 8 8v32z', +}; + function getStringValue(...candidates: unknown[]): string { for (const candidate of candidates) { if (typeof candidate === 'string' && candidate.trim().length > 0) { @@ -160,6 +190,54 @@ export function normalizeMapLayers(result: GetMapLayersResult | null): Normalize })); } +function getMarkerTypeValue(marker: Pick): number | null { + if (typeof marker.Type === 'number') { + return marker.Type; + } + + if (typeof marker.Type === 'string') { + const parsedType = Number.parseInt(marker.Type, 10); + return Number.isNaN(parsedType) ? null : parsedType; + } + + return null; +} + +export function isPoiMarker( + marker: Pick, +): boolean { + if (getMarkerTypeValue(marker) === mapMarkerTypes.poi) { + return true; + } + + if (typeof marker.PoiTypeId === 'number' && marker.PoiTypeId > 0) { + return true; + } + + if (typeof marker.LayerId === 'string' && marker.LayerId.startsWith('poi-type-')) { + return true; + } + + return typeof marker.ImagePath === 'string' + && marker.ImagePath.trim().toLowerCase().startsWith('map-icon-'); +} + +export function getPoiLayerId(layer: Pick | Pick): string { + if ('LayerId' in layer && typeof layer.LayerId === 'string' && layer.LayerId.length > 0) { + return layer.LayerId; + } + + return `poi-type-${layer.PoiTypeId ?? 0}`; +} + +export function getPoiMarkerShapePath(markerShape?: string): string { + const normalizedShape = typeof markerShape === 'string' && markerShape.trim().length > 0 + ? markerShape.trim().toUpperCase() + : defaultPoiMarkerShape; + + return poiMarkerPaths[normalizedShape] ?? poiMarkerPaths[defaultPoiMarkerShape]; +} + export function getLayerColor(layer: NormalizedMapLayer): string { const feature = layer.featureCollection.features[0]; const properties = feature?.properties as GeoJsonProperties | null | undefined; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs index 47578b90d..15d03637c 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.Extensions.Localization; using Newtonsoft.Json; using Resgrid.Framework; using Resgrid.Model; @@ -63,10 +64,13 @@ public class DispatchController : SecureBaseController private readonly IFormsService _formsService; private readonly IShiftsService _shiftsService; private readonly IContactsService _contactsService; + private readonly IMappingService _mappingService; private readonly IUserDefinedFieldsService _userDefinedFieldsService; private readonly IUdfRenderingService _udfRenderingService; private readonly ICheckInTimerService _checkInTimerService; private readonly IWeatherAlertService _weatherAlertService; + private readonly IStringLocalizer _dispatchLocalizer; + private readonly IStringLocalizer _commonLocalizer; public DispatchController(IDepartmentsService departmentsService, IUsersService usersService, ICallsService callsService, IDepartmentGroupsService departmentGroupsService, ICommunicationService communicationService, IQueueService queueService, @@ -74,9 +78,10 @@ public DispatchController(IDepartmentsService departmentsService, IUsersService IPersonnelRolesService personnelRolesService, IDepartmentSettingsService departmentSettingsService, IUserProfileService userProfileService, IUnitsService unitsService, IActionLogsService actionLogsService, IEventAggregator eventAggregator, ICustomStateService customStateService, ITemplatesService templatesService, IPdfProvider pdfProvider, IProtocolsService protocolsService, IFormsService formsService, - IShiftsService shiftsService, IContactsService contactsService, + IShiftsService shiftsService, IContactsService contactsService, IMappingService mappingService, IUserDefinedFieldsService userDefinedFieldsService, IUdfRenderingService udfRenderingService, - ICheckInTimerService checkInTimerService, IWeatherAlertService weatherAlertService) + ICheckInTimerService checkInTimerService, IWeatherAlertService weatherAlertService, + IStringLocalizer dispatchLocalizer, IStringLocalizer commonLocalizer) { _departmentsService = departmentsService; _usersService = usersService; @@ -100,10 +105,13 @@ public DispatchController(IDepartmentsService departmentsService, IUsersService _formsService = formsService; _shiftsService = shiftsService; _contactsService = contactsService; + _mappingService = mappingService; _userDefinedFieldsService = userDefinedFieldsService; _udfRenderingService = udfRenderingService; _checkInTimerService = checkInTimerService; _weatherAlertService = weatherAlertService; + _dispatchLocalizer = dispatchLocalizer; + _commonLocalizer = commonLocalizer; } #endregion Private Members and Constructors @@ -212,6 +220,10 @@ public async Task NewCall(NewCallView model, IFormCollection coll return Unauthorized(); model = await FillNewCallView(model); + var destinationPoi = await GetValidatedDestinationPoiAsync(model.Call?.DestinationPoiId); + + if (model.Call?.DestinationPoiId.HasValue == true && model.Call.DestinationPoiId.Value > 0 && destinationPoi == null) + ModelState.AddModelError("Call.DestinationPoiId", _dispatchLocalizer["InvalidDestinationPoi"].Value); if (ModelState.IsValid) { @@ -226,7 +238,9 @@ public async Task NewCall(NewCallView model, IFormCollection coll if (!String.IsNullOrWhiteSpace(model.What3Word)) model.Call.W3W = model.What3Word; - if (model.Call.Type == "No Type") + model.Call.DestinationPoiId = destinationPoi?.PoiId; + + if (model.Call.Type == _dispatchLocalizer["NoType"].Value) model.Call.Type = null; if (!String.IsNullOrEmpty(model.Latitude) && !String.IsNullOrEmpty(model.Longitude)) @@ -598,6 +612,10 @@ public async Task UpdateCall(UpdateCallView model, IFormCollectio return Unauthorized(); model = await FillUpdateCallView(model); + var destinationPoi = await GetValidatedDestinationPoiAsync(model.Call?.DestinationPoiId); + + if (model.Call?.DestinationPoiId.HasValue == true && model.Call.DestinationPoiId.Value > 0 && destinationPoi == null) + ModelState.AddModelError("Call.DestinationPoiId", _dispatchLocalizer["InvalidDestinationPoi"].Value); if (ModelState.IsValid) { @@ -619,6 +637,7 @@ public async Task UpdateCall(UpdateCallView model, IFormCollectio call.IncidentNumber = model.Call.IncidentNumber; call.Address = model.Call.Address; call.W3W = model.What3Word; + call.DestinationPoiId = destinationPoi?.PoiId; call.Type = model.Call.Type; if (!string.IsNullOrEmpty(model.Call.Address)) @@ -1079,6 +1098,11 @@ public async Task ViewCall(int callId) model.Protocols = await _protocolsService.GetAllProtocolsForDepartmentAsync(DepartmentId); model.ChildCalls = await _callsService.GetChildCallsForCallAsync(callId); model.Call = await _callsService.PopulateCallData(model.Call, true, true, true, true, true, true, true, true, true, true); + var destinationPoi = await GetValidatedDestinationPoiAsync(model.Call.DestinationPoiId); + var destinationInfo = BuildDestinationInfo(destinationPoi); + model.DestinationName = destinationInfo.Name; + model.DestinationAddress = destinationInfo.Address; + model.DestinationTypeName = destinationInfo.TypeName; if (model.Stations == null) model.Stations = new List(); @@ -1130,6 +1154,10 @@ public async Task AddArchivedCall(NewCallView model, IFormCollect model.CallStates = model.CallState.ToSelectList(); model.Call.LoggedOn = DateTime.UtcNow.TimeConverter(model.Department); model.Call.ReportingUserId = UserId; + var destinationPoi = await GetValidatedDestinationPoiAsync(model.Call?.DestinationPoiId); + + if (model.Call?.DestinationPoiId.HasValue == true && model.Call.DestinationPoiId.Value > 0 && destinationPoi == null) + ModelState.AddModelError("Call.DestinationPoiId", _dispatchLocalizer["InvalidDestinationPoi"].Value); if (ModelState.IsValid) { @@ -1146,7 +1174,9 @@ public async Task AddArchivedCall(NewCallView model, IFormCollect if (!String.IsNullOrWhiteSpace(model.What3Word)) model.Call.W3W = model.What3Word; - if (model.Call.Type == "No Type") + model.Call.DestinationPoiId = destinationPoi?.PoiId; + + if (model.Call.Type == _dispatchLocalizer["NoType"].Value) model.Call.Type = null; if (!String.IsNullOrEmpty(model.Latitude) && !String.IsNullOrEmpty(model.Longitude)) @@ -1756,7 +1786,7 @@ public async Task GetCallNotes(int callId) } else { - note.Location = "No Location"; + note.Location = _dispatchLocalizer["NoLocation"].Value; } callNotes.Add(note); @@ -1782,7 +1812,8 @@ public async Task GetCheckInTimerStatuses(int callId) var result = statuses.Select(s => { - string targetName = ((CheckInTimerTargetType)s.TargetType).ToString(); + string targetTypeText = DispatchDisplayHelper.GetLocalizedCheckInTimerTargetType((CheckInTimerTargetType)s.TargetType, _dispatchLocalizer, _commonLocalizer); + string targetName = targetTypeText; if (s.TargetType == (int)CheckInTimerTargetType.Personnel) { @@ -1816,7 +1847,7 @@ public async Task GetCheckInTimerStatuses(int callId) return new { s.TargetType, - TargetTypeName = ((CheckInTimerTargetType)s.TargetType).ToString(), + TargetTypeName = targetTypeText, TargetName = targetName, s.UnitId, s.LastCheckIn, @@ -1857,7 +1888,7 @@ public async Task GetCheckInHistory(int callId) r.CheckInRecordId, r.CallId, r.CheckInType, - CheckInTypeName = ((CheckInTimerTargetType)r.CheckInType).ToString(), + CheckInTypeName = DispatchDisplayHelper.GetLocalizedCheckInTimerTargetType((CheckInTimerTargetType)r.CheckInType, _dispatchLocalizer, _commonLocalizer), PerformedBy = personName?.Name ?? r.UserId, UnitName = unitName, Timestamp = r.Timestamp.TimeConverterToString(department), @@ -1914,6 +1945,11 @@ public async Task CallExport(int callId) model.Groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(DepartmentId); model.Units = await _unitsService.GetUnitsForDepartmentAsync(DepartmentId); model.Call = await _callsService.PopulateCallData(model.Call, true, true, true, true, true, true, true, true, true); + var callDestination = await GetValidatedDestinationPoiAsync(model.Call.DestinationPoiId); + var callDestinationInfo = BuildDestinationInfo(callDestination); + model.DestinationName = callDestinationInfo.Name; + model.DestinationAddress = callDestinationInfo.Address; + model.DestinationTypeName = callDestinationInfo.TypeName; model.Names = await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(DepartmentId); model.ChildCalls = await _callsService.GetChildCallsForCallAsync(callId); model.Contacts = await _contactsService.GetAllContactsForDepartmentAsync(DepartmentId); @@ -1960,6 +1996,11 @@ public async Task CallExportEx(string query) var model = new CallExportView(); model.Call = await _callsService.PopulateCallData(call, true, true, true, true, true, true, true, true, true); + var destinationPoi = await GetValidatedDestinationPoiAsync(model.Call.DestinationPoiId); + var destinationInfo = BuildDestinationInfo(destinationPoi); + model.DestinationName = destinationInfo.Name; + model.DestinationAddress = destinationInfo.Address; + model.DestinationTypeName = destinationInfo.TypeName; model.CallLogs = await _workLogsService.GetCallLogsForCallAsync(call.CallId); model.Department = await _departmentsService.GetDepartmentByIdAsync(call.DepartmentId, false); model.UnitStates = (await _unitsService.GetUnitStatesForCallAsync(call.DepartmentId, call.CallId)).OrderBy(x => x.UnitId).OrderBy(y => y.Timestamp).ToList(); @@ -1991,6 +2032,11 @@ public async Task CallExportEx(string query) var model = new CallExportView(); model.Call = await _callsService.PopulateCallData(call, true, true, true, true, true, true, true, true, true); + var destinationPoi = await GetValidatedDestinationPoiAsync(model.Call.DestinationPoiId); + var destinationInfo = BuildDestinationInfo(destinationPoi); + model.DestinationName = destinationInfo.Name; + model.DestinationAddress = destinationInfo.Address; + model.DestinationTypeName = destinationInfo.TypeName; model.CallLogs = await _workLogsService.GetCallLogsForCallAsync(call.CallId); model.Department = await _departmentsService.GetDepartmentByIdAsync(call.DepartmentId, false); model.UnitStates = (await _unitsService.GetUnitStatesForCallAsync(call.DepartmentId, call.CallId)).OrderBy(x => x.UnitId).OrderBy(y => y.Timestamp).ToList(); @@ -2118,8 +2164,8 @@ public async Task GetActiveCallsForGrid() var genericCall = new CallJson() { DispatchTime = DateTime.UtcNow, - Priority = "Low", - Name = "Generic Call" + Priority = _dispatchLocalizer["CallPriorityLow"].Value, + Name = _dispatchLocalizer["GenericCall"].Value }; calls.Add(genericCall); @@ -2128,7 +2174,7 @@ public async Task GetActiveCallsForGrid() var jsonCall = new CallJson(); jsonCall.CallId = call.CallId; jsonCall.DispatchTime = call.LoggedOn; - jsonCall.Priority = call.GetPriorityText(); + jsonCall.Priority = await DispatchDisplayHelper.GetLocalizedCallPriorityAsync(DepartmentId, call.Priority, _dispatchLocalizer); jsonCall.Name = call.Name; calls.Add(jsonCall); @@ -2150,9 +2196,9 @@ public async Task GetAllCallsForGrid() var jsonCall = new CallJson(); jsonCall.CallId = call.CallId; jsonCall.DispatchTime = call.LoggedOn; - jsonCall.Priority = call.GetPriorityText(); + jsonCall.Priority = await DispatchDisplayHelper.GetLocalizedCallPriorityAsync(DepartmentId, call.Priority, _dispatchLocalizer); jsonCall.Name = call.Name; - jsonCall.State = ((CallStates)call.State).ToString(); + jsonCall.State = DispatchDisplayHelper.GetLocalizedCallState(call.State, _dispatchLocalizer, _commonLocalizer); calls.Add(jsonCall); } @@ -2184,10 +2230,10 @@ public async Task GetCallById(int callId) call.CallId = savedCall.CallId; call.DispatchTime = savedCall.LoggedOn.TimeConverter(savedCall.Department); - call.Priority = savedCall.GetPriorityText(); + call.Priority = await DispatchDisplayHelper.GetLocalizedCallPriorityAsync(savedCall.DepartmentId, savedCall.Priority, _dispatchLocalizer); call.PriorityEnum = (CallPriority)savedCall.Priority; call.Name = savedCall.Name; - call.State = ((CallStates)savedCall.State).ToString(); + call.State = DispatchDisplayHelper.GetLocalizedCallState(savedCall.State, _dispatchLocalizer, _commonLocalizer); call.Nature = savedCall.NatureOfCall; call.Address = savedCall.Address; @@ -2373,7 +2419,7 @@ public async Task CallsTypesInRange(string startDate, string endD var groupedCalls = calls.GroupBy(x => x.Type); foreach (var grouppedCall in groupedCalls) { - string key = "No Type"; + string key = _dispatchLocalizer["NoType"].Value; if (!String.IsNullOrWhiteSpace(grouppedCall.Key)) key = grouppedCall.Key; @@ -2404,7 +2450,7 @@ public async Task CallsStatesInRange(string startDate, string end var groupedCallStates = calls.GroupBy(x => x.State); foreach (var grouppedCall in groupedCallStates) { - callTypes.Add(new CallTypesJson() { Count = grouppedCall.ToList().Count, Type = ((CallStates)grouppedCall.Key).ToString() }); + callTypes.Add(new CallTypesJson() { Count = grouppedCall.ToList().Count, Type = DispatchDisplayHelper.GetLocalizedCallState(grouppedCall.Key, _dispatchLocalizer, _commonLocalizer) }); } } @@ -2427,11 +2473,11 @@ public async Task GetActiveCallsList() callJson.CallId = call.CallId; callJson.Number = call.Number; callJson.Name = call.Name; - callJson.State = _callsService.CallStateToString((CallStates)call.State); + callJson.State = DispatchDisplayHelper.GetLocalizedCallState(call.State, _dispatchLocalizer, _commonLocalizer); callJson.StateColor = _callsService.CallStateToColor((CallStates)call.State); callJson.Timestamp = call.LoggedOn.TimeConverterToString(department); callJson.LoggedOn = new DateTimeOffset(DateTime.SpecifyKind(call.LoggedOn, DateTimeKind.Utc)).ToUnixTimeSeconds(); - callJson.Priority = await _callsService.CallPriorityToStringAsync(call.Priority, DepartmentId); + callJson.Priority = await DispatchDisplayHelper.GetLocalizedCallPriorityAsync(DepartmentId, call.Priority, _dispatchLocalizer); callJson.Color = await _callsService.CallPriorityToColorAsync(call.Priority, DepartmentId); callJson.CanDeleteCall = await _authorizationService.CanUserDeleteCallAsync(UserId, call.CallId, DepartmentId); callJson.CanCloseCall = await _authorizationService.CanUserCloseCallAsync(UserId, call.CallId, DepartmentId); @@ -2468,10 +2514,10 @@ public async Task GetArchivedCallsList(string year) callJson.CallId = call.CallId; callJson.Number = call.Number; callJson.Name = call.Name; - callJson.State = _callsService.CallStateToString((CallStates)call.State); + callJson.State = DispatchDisplayHelper.GetLocalizedCallState(call.State, _dispatchLocalizer, _commonLocalizer); callJson.StateColor = _callsService.CallStateToColor((CallStates)call.State); callJson.Timestamp = call.LoggedOn.TimeConverterToString(department); - callJson.Priority = await _callsService.CallPriorityToStringAsync(call.Priority, DepartmentId); + callJson.Priority = await DispatchDisplayHelper.GetLocalizedCallPriorityAsync(DepartmentId, call.Priority, _dispatchLocalizer); callJson.Color = await _callsService.CallPriorityToColorAsync(call.Priority, DepartmentId); if (ClaimsAuthorizationHelper.IsUserDepartmentAdmin() || call.ReportingUserId == UserId) @@ -2489,7 +2535,7 @@ public async Task GetArchivedCallsList(string year) public async Task AttachCallFile(FileAttachInput model, IFormFile fileToUpload, CancellationToken cancellationToken) { if (fileToUpload == null || fileToUpload.Length <= 0) - ModelState.AddModelError("fileToUpload", "You must select a file to attach."); + ModelState.AddModelError("fileToUpload", _dispatchLocalizer["AttachFileRequired"].Value); else { var extenion = FileHelper.GetFileExtensionWithoutDot(fileToUpload.FileName); @@ -2498,10 +2544,10 @@ public async Task AttachCallFile(FileAttachInput model, IFormFile && extenion != "docx" && extenion != "ppt" && extenion != "pptx" && extenion != "pps" && extenion != "ppsx" && extenion != "odt" && extenion != "xls" && extenion != "xlsx" && extenion != "mp3" && extenion != "m4a" && extenion != "ogg" && extenion != "wav" && extenion != "mp4" && extenion != "m4v" && extenion != "mov" && extenion != "wmv" && extenion != "avi" && extenion != "mpg" && extenion != "txt") - ModelState.AddModelError("fileToUpload", string.Format("Document type ({0}) is not importable.", extenion)); + ModelState.AddModelError("fileToUpload", _dispatchLocalizer["DocumentTypeNotImportable", extenion].Value); if (fileToUpload.Length > 10000000) - ModelState.AddModelError("fileToUpload", "File is too large, must be smaller then 10MB."); + ModelState.AddModelError("fileToUpload", _dispatchLocalizer["FileTooLarge10Mb"].Value); } if (ModelState.IsValid) @@ -2680,7 +2726,7 @@ public async Task GetCallsForSelectList(string term) { CallSelectListJsonResult json = new CallSelectListJsonResult(); json.id = call.CallId; - json.text = $"{call.Number} - {call.GetStateText()} - {call.LoggedOn.FormatForDepartment(department)} - {call.Name}"; + json.text = $"{call.Number} - {DispatchDisplayHelper.GetLocalizedCallState(call.State, _dispatchLocalizer, _commonLocalizer)} - {call.LoggedOn.FormatForDepartment(department)} - {call.Name}"; if (String.IsNullOrWhiteSpace(term) || json.text.Contains(term)) callSelectJson.results.Add(json); @@ -2707,7 +2753,7 @@ private async Task FillNewCallView(NewCallView model) model.UnGroupedUsers = new List(); List types = new List(); - types.Add(new CallType { CallTypeId = 0, Type = "No Type" }); + types.Add(new CallType { CallTypeId = 0, Type = _dispatchLocalizer["NoType"].Value }); types.AddRange(await _callsService.GetCallTypesForDepartmentAsync(DepartmentId)); model.CallTypes = new SelectList(types, "Type", "Type"); @@ -2746,7 +2792,7 @@ private async Task FillNewCallView(NewCallView model) model.Contacts = await _contactsService.GetAllContactsForDepartmentAsync(DepartmentId); if (model.Contacts != null && model.Contacts.Any()) { - SelectListItem selListItem = new SelectListItem() { Value = "", Text = "Select Contact" }; + SelectListItem selListItem = new SelectListItem() { Value = "", Text = _dispatchLocalizer["SelectContact"].Value }; List newList = new List(); newList.Add(selListItem); newList.AddRange(new SelectList(model.Contacts, "ContactId", "Name")); @@ -2757,6 +2803,8 @@ private async Task FillNewCallView(NewCallView model) //model.ContactsList = new SelectList(model.Contacts, "ContactId", "Name"); } + model.DestinationPois = await GetDestinationPoiSelectListAsync(model.Call?.DestinationPoiId); + var udfDefinition = await _userDefinedFieldsService.GetActiveDefinitionAsync(DepartmentId, (int)UdfEntityType.Call); if (udfDefinition != null) { @@ -2783,7 +2831,7 @@ private async Task FillUpdateCallView(UpdateCallView model) model.UnGroupedUsers = new List(); List types = new List(); - types.Add(new CallType { CallTypeId = 0, Type = "No Type" }); + types.Add(new CallType { CallTypeId = 0, Type = _dispatchLocalizer["NoType"].Value }); types.AddRange(await _callsService.GetCallTypesForDepartmentAsync(DepartmentId)); model.CallTypes = new SelectList(types, "Type", "Type"); @@ -2819,7 +2867,7 @@ private async Task FillUpdateCallView(UpdateCallView model) model.Contacts = await _contactsService.GetAllContactsForDepartmentAsync(DepartmentId); if (model.Contacts != null && model.Contacts.Any()) { - SelectListItem selListItem = new SelectListItem() { Value = "", Text = "Select Contact" }; + SelectListItem selListItem = new SelectListItem() { Value = "", Text = _dispatchLocalizer["SelectContact"].Value }; List newList = new List(); newList.Add(selListItem); newList.AddRange(new SelectList(model.Contacts, "ContactId", "Name")); @@ -2848,6 +2896,8 @@ private async Task FillUpdateCallView(UpdateCallView model) } } + model.DestinationPois = await GetDestinationPoiSelectListAsync(model.Call?.DestinationPoiId); + if (model.Call != null && model.Call.CallId > 0) { var udfDefinition = await _userDefinedFieldsService.GetActiveDefinitionAsync(DepartmentId, (int)UdfEntityType.Call); @@ -2916,6 +2966,12 @@ private async Task FillViewCallView(ViewCallView model) else model.Contacts = new List(); + var destinationPoi = await GetValidatedDestinationPoiAsync(model.Call?.DestinationPoiId); + var destinationInfo = BuildDestinationInfo(destinationPoi); + model.DestinationName = destinationInfo.Name; + model.DestinationAddress = destinationInfo.Address; + model.DestinationTypeName = destinationInfo.TypeName; + var udfDefinition = await _userDefinedFieldsService.GetActiveDefinitionAsync(DepartmentId, (int)UdfEntityType.Call); if (udfDefinition != null) { @@ -2931,6 +2987,61 @@ private async Task FillViewCallView(ViewCallView model) return model; } + private async Task> GetDestinationPoiSelectListAsync(int? selectedPoiId) + { + var selectListItems = new List + { + new SelectListItem + { + Value = "", + Text = _dispatchLocalizer["NoDestination"].Value + } + }; + + var destinationPois = await _mappingService.GetDestinationPOIsForDepartmentAsync(DepartmentId); + + if (destinationPois == null || !destinationPois.Any()) + return selectListItems; + + var groups = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var poi in destinationPois.OrderBy(x => x.Type?.Name).ThenBy(x => x.Name).ThenBy(x => x.Address).ThenBy(x => x.Note)) + { + var groupName = !String.IsNullOrWhiteSpace(poi.Type?.Name) ? poi.Type.Name : _dispatchLocalizer["Pois"].Value; + + if (!groups.ContainsKey(groupName)) + groups[groupName] = new SelectListGroup { Name = groupName }; + + var title = PoiDisplayHelper.GetSelectionLabel(poi, groupName); + + selectListItems.Add(new SelectListItem + { + Value = poi.PoiId.ToString(), + Text = title, + Selected = selectedPoiId.HasValue && selectedPoiId.Value == poi.PoiId, + Group = groups[groupName] + }); + } + + return selectListItems; + } + + private async Task GetValidatedDestinationPoiAsync(int? destinationPoiId) + { + if (!destinationPoiId.HasValue || destinationPoiId.Value <= 0) + return null; + + return await _mappingService.GetDestinationPOIByIdAsync(DepartmentId, destinationPoiId.Value); + } + + private static (string Name, string Address, string TypeName) BuildDestinationInfo(Poi destinationPoi) + { + if (destinationPoi == null) + return (null, null, null); + + return (PoiDisplayHelper.GetDisplayName(destinationPoi, destinationPoi.Type?.Name), destinationPoi.Address, PoiDisplayHelper.GetTypeName(destinationPoi)); + } + private async Task FillCloseCallView(CloseCallView model) { model.Department = await _departmentsService.GetDepartmentByUserIdAsync(UserId); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/LinksController.cs b/Web/Resgrid.Web/Areas/User/Controllers/LinksController.cs index 5caa6ba84..306d697a6 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/LinksController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/LinksController.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Localization; using Resgrid.Model; using Resgrid.Model.Services; using Resgrid.Web.Areas.User.Models.Dispatch; @@ -28,10 +29,13 @@ public class LinksController : SecureBaseController private readonly IUserStateService _userStateService; private readonly IPersonnelRolesService _personnelRolesService; private readonly ILimitsService _limitsService; + private readonly IMappingService _mappingService; + private readonly IStringLocalizer _localizer; public LinksController(IDepartmentLinksService departmentLinksService, IDepartmentsService departmentsService, IEmailService emailService, ICallsService callsService, IUnitsService unitsService, IActionLogsService actionLogsService, IDepartmentGroupsService departmentGroupsService, - IUserStateService userStateService, IPersonnelRolesService personnelRolesService, ILimitsService limitsService) + IUserStateService userStateService, IPersonnelRolesService personnelRolesService, ILimitsService limitsService, IMappingService mappingService, + IStringLocalizer localizer) { _departmentLinksService = departmentLinksService; _departmentsService = departmentsService; @@ -43,6 +47,8 @@ public LinksController(IDepartmentLinksService departmentLinksService, IDepartme _userStateService = userStateService; _personnelRolesService = personnelRolesService; _limitsService = limitsService; + _mappingService = mappingService; + _localizer = localizer; } #endregion Private Members and Constructors @@ -294,6 +300,7 @@ public async Task GetPersonnelList(int linkId) var calls = await _callsService.GetActiveCallsByDepartmentAsync(link.DepartmentId); var stations = await _departmentGroupsService.GetAllStationGroupsForDepartmentAsync(link.DepartmentId); + var pois = await _mappingService.GetPOIsForDepartmentAsync(link.DepartmentId); var names = new Dictionary(); @@ -345,7 +352,7 @@ public async Task GetPersonnelList(int linkId) var group = await _departmentGroupsService.GetGroupForUserAsync(u.User.UserId, DepartmentId); string callNumber = ""; - if (al != null && al.ActionTypeId == (int)ActionTypes.RespondingToScene || (al != null && al.DestinationType.HasValue && al.DestinationType.Value == 2)) + if ((al != null && al.ActionTypeId == (int)ActionTypes.RespondingToScene) || (al != null && al.DestinationType.ToDestinationEntityType() == DestinationEntityTypes.Call)) { if (al.DestinationId.HasValue) { @@ -356,7 +363,9 @@ public async Task GetPersonnelList(int linkId) } } var respondingToDepartment = stations.Where(s => al != null && s.DepartmentGroupId == al.DestinationId).FirstOrDefault(); - var personnelViewModel = await Models.BigBoardX.PersonnelViewModel.Create(u.Name, al, us, department, respondingToDepartment, group, u.Roles, callNumber); + var destinationType = al.GetEffectiveDestinationType(); + var destination = DestinationResolutionHelper.Resolve(al?.DestinationId, al?.DestinationType, null, calls, stations, pois); + var personnelViewModel = await Models.BigBoardX.PersonnelViewModel.Create(u.Name, al, us, department, respondingToDepartment, group, u.Roles, callNumber, destination); personnelViewModels.Add(personnelViewModel); } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/MappingController.cs b/Web/Resgrid.Web/Areas/User/Controllers/MappingController.cs index 21faf719e..18fae5cb5 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/MappingController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/MappingController.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Text.Json; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using GeoJSON.Net.Feature; @@ -13,6 +14,7 @@ using Newtonsoft.Json; using Resgrid.Framework; using Resgrid.Model; +using Resgrid.Model.Helpers; using Resgrid.Model.Providers; using Resgrid.Model.Services; using Resgrid.Web.Areas.User.Models.Home; @@ -338,6 +340,7 @@ public async Task ImportPOIs(ImportPOIsView modal, IFormFile file { var poi = new Poi(); poi.PoiTypeId = modal.TypeId; + poi.Name = coordinate.Name; if (coordinate.Latitude.HasValue && coordinate.Longitude.HasValue) { @@ -360,6 +363,14 @@ public async Task AddPOIType(AddPOITypeView modal, CancellationTo modal.Type.DepartmentId = DepartmentId; modal.Type.Marker = modal.MarkerType; + if (!string.IsNullOrWhiteSpace(modal.Type.Color) && + !Regex.IsMatch(modal.Type.Color, @"^#[0-9a-fA-F]{3,8}$")) + ModelState.AddModelError("Type.Color", "Color must be a valid CSS hex color (e.g. #rgb, #rrggbb, #rrggbbaa)."); + + if (!string.IsNullOrWhiteSpace(modal.Type.Image) && + !Regex.IsMatch(modal.Type.Image, @"^[a-zA-Z0-9_-]+$")) + ModelState.AddModelError("Type.Image", "Image class name may only contain letters, digits, hyphens, and underscores."); + if (ModelState.IsValid) { await _mappingService.SavePOITypeAsync(modal.Type, cancellationToken); @@ -389,11 +400,18 @@ public async Task DeletePOIType(int poiTypeId, CancellationToken [HttpGet] public async Task AddPOI(int poiTypeId) { + var type = await _mappingService.GetTypeByIdAsync(poiTypeId); + + if (type == null) + return NotFound(); + + if (type.DepartmentId != DepartmentId) + return Unauthorized(); + var modal = new AddPOIView(); modal.TypeId = poiTypeId; modal.Poi = new Poi(); - return View(modal); } @@ -418,12 +436,99 @@ public async Task AddPOI(AddPOIView modal, CancellationToken canc modal.Poi.PoiTypeId = modal.TypeId; await _mappingService.SavePOIAsync(modal.Poi, cancellationToken); - return RedirectToAction("POIs"); + return RedirectToAction("ViewType", new { poiTypeId = modal.TypeId }); } return View(modal); } + [HttpGet] + public async Task EditPOI(int poiId) + { + var poi = await _mappingService.GetPOIByIdAsync(poiId); + + if (poi == null) + return NotFound(); + + var type = await _mappingService.GetTypeByIdAsync(poi.PoiTypeId); + + if (type == null) + return NotFound(); + + if (type.DepartmentId != DepartmentId) + return Unauthorized(); + + var model = new AddPOIView(); + model.TypeId = type.PoiTypeId; + model.Poi = poi; + + return View("AddPOI", model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task EditPOI(AddPOIView modal, CancellationToken cancellationToken) + { + if (modal?.Poi == null || modal.Poi.PoiId <= 0) + { + ModelState.AddModelError("", "Cannot edit POI. Please go back and try again."); + return View("AddPOI", modal); + } + + var existingPoi = await _mappingService.GetPOIByIdAsync(modal.Poi.PoiId); + + if (existingPoi == null) + return NotFound(); + + var type = await _mappingService.GetTypeByIdAsync(existingPoi.PoiTypeId); + + if (type == null) + return NotFound(); + + if (type.DepartmentId != DepartmentId) + return Unauthorized(); + + modal.TypeId = type.PoiTypeId; + + if (ModelState.IsValid) + { + existingPoi.Name = modal.Poi.Name; + existingPoi.Address = modal.Poi.Address; + existingPoi.Note = modal.Poi.Note; + existingPoi.Latitude = modal.Poi.Latitude; + existingPoi.Longitude = modal.Poi.Longitude; + + await _mappingService.SavePOIAsync(existingPoi, cancellationToken); + + return RedirectToAction("ViewType", new { poiTypeId = type.PoiTypeId }); + } + + modal.Poi.PoiTypeId = type.PoiTypeId; + return View("AddPOI", modal); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task DeletePOI(int poiId, CancellationToken cancellationToken) + { + var poi = await _mappingService.GetPOIByIdAsync(poiId); + + if (poi == null) + return NotFound(); + + var type = await _mappingService.GetTypeByIdAsync(poi.PoiTypeId); + + if (type == null) + return NotFound(); + + if (type.DepartmentId != DepartmentId) + return Unauthorized(); + + await _mappingService.DeletePOIAsync(poi.PoiId, cancellationToken); + + return RedirectToAction("ViewType", new { poiTypeId = type.PoiTypeId }); + } + [HttpGet] public async Task GetMapData(MapSettingsInput input) { @@ -609,8 +714,8 @@ await _geoLocationProvider.GetLatLonFromAddress(string.Format("{0} {1} {2} {3}", MapMakerInfo info = new MapMakerInfo(); info.ImagePath = poiType.Image; info.Marker = poiType.Marker; - info.Title = poiType.Name; - info.InfoWindowContent = ""; + info.Title = PoiDisplayHelper.GetDisplayName(poi, poiType.Name); + info.InfoWindowContent = BuildPoiInfoWindow(poi, poiType); info.Latitude = poi.Latitude; info.Longitude = poi.Longitude; info.Color = poiType.Color; @@ -641,8 +746,8 @@ public async Task GetTypesMapData(int poiTypeId) MapMakerInfo info = new MapMakerInfo(); info.ImagePath = poiType.Image; info.Marker = poiType.Marker; - info.Title = poiType.Name; - info.InfoWindowContent = ""; + info.Title = PoiDisplayHelper.GetDisplayName(poi, poiType.Name); + info.InfoWindowContent = BuildPoiInfoWindow(poi, poiType); info.Latitude = poi.Latitude; info.Longitude = poi.Longitude; info.Color = poiType.Color; @@ -671,8 +776,10 @@ public async Task GetPoisForType(int poiTypeId) var poiJson = new PoiJson(); poiJson.PoiId = poi.PoiId; poiJson.PoiTypeId = poi.PoiTypeId; + poiJson.Name = poi.Name; poiJson.Latitude = poi.Latitude; poiJson.Longitude = poi.Longitude; + poiJson.Address = poi.Address; poiJson.Note = poi.Note; poisJson.Add(poiJson); @@ -681,6 +788,18 @@ public async Task GetPoisForType(int poiTypeId) return Json(poisJson); } + private static string BuildPoiInfoWindow(Poi poi, PoiType poiType) + { + var rows = PoiDisplayHelper.GetDisplayRows(poi, poiType?.Name); + if (!rows.Any()) + return string.Empty; + + var encodedRows = rows.Select(System.Net.WebUtility.HtmlEncode).ToList(); + encodedRows[0] = $"{encodedRows[0]}"; + + return string.Join("
", encodedRows); + } + [HttpGet] public async Task LiveRouting(int callId) { diff --git a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs index 75072b844..b1100005e 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.Extensions.Localization; using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Events; @@ -21,6 +22,7 @@ using Microsoft.AspNetCore.Authorization; using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Localization; using Resgrid.Model.Identity; using Resgrid.WebCore.Areas.User.Models.Personnel; using IdentityUser = Resgrid.Model.Identity.IdentityUser; @@ -54,15 +56,18 @@ public class PersonnelController : SecureBaseController private readonly IDepartmentSettingsService _departmentSettingsService; private readonly ICallsService _callsService; private readonly IGeoLocationProvider _geoLocationProvider; + private readonly IMappingService _mappingService; private readonly IUserDefinedFieldsService _userDefinedFieldsService; private readonly IUdfRenderingService _udfRenderingService; + private readonly IStringLocalizer _localizer; public PersonnelController(IDepartmentsService departmentsService, IUsersService usersService, IActionLogsService actionLogsService, IEmailService emailService, IUserProfileService userProfileService, IDeleteService deleteService, Model.Services.IAuthorizationService authorizationService, ILimitsService limitsService, IPersonnelRolesService personnelRolesService, IDepartmentGroupsService departmentGroupsService, IUserStateService userStateService, IEventAggregator eventAggregator, IEmailMarketingProvider emailMarketingProvider, ICertificationService certificationService, ICustomStateService customStateService, IGeoService geoService, UserManager userManager, IDepartmentSettingsService departmentSettingsService, ICallsService callsService, - IGeoLocationProvider geoLocationProvider, IUserDefinedFieldsService userDefinedFieldsService, IUdfRenderingService udfRenderingService) + IGeoLocationProvider geoLocationProvider, IMappingService mappingService, IUserDefinedFieldsService userDefinedFieldsService, IUdfRenderingService udfRenderingService, + IStringLocalizer localizer) { _departmentsService = departmentsService; _usersService = usersService; @@ -84,8 +89,10 @@ public PersonnelController(IDepartmentsService departmentsService, IUsersService _departmentSettingsService = departmentSettingsService; _callsService = callsService; _geoLocationProvider = geoLocationProvider; + _mappingService = mappingService; _userDefinedFieldsService = userDefinedFieldsService; _udfRenderingService = udfRenderingService; + _localizer = localizer; } #endregion Private Members and Constructors @@ -1295,46 +1302,53 @@ public async Task GetPersonnelStatusDestinationHtmlForDropdown(in var state = activeDetails.FirstOrDefault(x => x.CustomStateDetailId == customStatusDetailId); var activeCalls = await _callsService.GetActiveCallsByDepartmentAsync(DepartmentId); var stations = await _departmentGroupsService.GetAllStationGroupsForDepartmentAsync(DepartmentId); + var destinationPois = await _mappingService.GetDestinationPOIsForDepartmentAsync(DepartmentId); + var noneText = HttpUtility.HtmlEncode(_localizer["None"].Value); + var callsLabel = HttpUtility.HtmlEncode(_localizer["Calls"].Value); + var stationsLabel = HttpUtility.HtmlEncode(_localizer["Stations"].Value); + var poisLabel = _localizer["Pois"].Value; + var callPrefix = _localizer["Call"].Value; + var stationPrefix = _localizer["Station"].Value; StringBuilder sb = new StringBuilder(); - sb.Append($""); + sb.Append($""); if (state != null) { - // No custom drop down options for responding to avoid confusion between Responding Station and Responding Scene - if (customStatusDetailId != (int)ActionTypes.Responding) + if (state.DetailType.SupportsCalls()) { - if (state.DetailType == (int)CustomStateDetailTypes.None) + sb.Append($""); + foreach (var call in activeCalls) { - + var callText = HttpUtility.HtmlEncode($"{callPrefix} {call.GetIdentifier()}:{call.Name}"); + sb.Append($""); } - else if (state.DetailType == (int)CustomStateDetailTypes.Calls) + sb.Append(""); + } + + if (state.DetailType.SupportsStations()) + { + sb.Append($""); + foreach (var station in stations) { - foreach (var call in activeCalls) - { - sb.Append($""); - } + var stationText = HttpUtility.HtmlEncode($"{stationPrefix}: {station.Name}"); + sb.Append($""); } - else if (state.DetailType == (int)CustomStateDetailTypes.Stations) - { - foreach (var station in stations) - { - sb.Append($""); - } + sb.Append(""); + } - sb.Append(""); - } - else if (state.DetailType == (int)CustomStateDetailTypes.CallsAndStations) + if (state.DetailType.SupportsPois()) + { + foreach (var poiGroup in destinationPois.GroupBy(x => !String.IsNullOrWhiteSpace(x.Type?.Name) ? x.Type.Name : string.Empty)) { - foreach (var call in activeCalls) - { - sb.Append($""); - } - - foreach (var station in stations) + var poiGroupLabel = !String.IsNullOrWhiteSpace(poiGroup.Key) ? poiGroup.Key : poisLabel; + sb.Append($""); + foreach (var poi in poiGroup.OrderBy(x => x.Name).ThenBy(x => x.Address).ThenBy(x => x.Note)) { - sb.Append($""); + var poiText = HttpUtility.HtmlEncode(PoiDisplayHelper.GetSelectionLabel(poi, poiGroupLabel)); + sb.Append($""); } + sb.Append(""); } } } @@ -1347,26 +1361,26 @@ public async Task GetPersonnelStatusDestinationHtmlForDropdown(in [HttpGet] [Authorize(Policy = ResgridResources.Personnel_View)] - public async Task SetActionForUser(string userId, int actionType, int destination, string note, CancellationToken cancellationToken) + public async Task SetActionForUser(string userId, int actionType, int destination, int type, string note, CancellationToken cancellationToken) { if (!await _authorizationService.CanUserViewPersonAsync(UserId, userId, DepartmentId)) return Unauthorized(); - var status = new ActionLog(); - status.UserId = userId; - status.Timestamp = DateTime.UtcNow; - status.ActionTypeId = actionType; - status.DepartmentId = DepartmentId; - - if (destination > 0) - status.DestinationId = destination; - - if (!String.IsNullOrWhiteSpace(note)) - status.Note = HttpUtility.UrlDecode(note); - try { - var savedState = await _actionLogsService.SaveActionLogAsync(status, cancellationToken); + var decodedNote = !String.IsNullOrWhiteSpace(note) ? HttpUtility.UrlDecode(note) : null; + + if (destination > 0) + { + if (!await IsValidDestinationAsync(destination, type)) + return BadRequest(); + + await _actionLogsService.SetUserActionAsync(userId, DepartmentId, actionType, null, destination, type, decodedNote, cancellationToken); + } + else + { + await _actionLogsService.SetUserActionAsync(userId, DepartmentId, actionType, null, decodedNote, cancellationToken); + } } catch (Exception ex) { @@ -1378,7 +1392,7 @@ public async Task SetActionForUser(string userId, int actionType, [HttpGet] [Authorize(Policy = ResgridResources.Personnel_View)] - public async Task SetUserActionForMultiple(string userIds, int actionType, int destination, string note, CancellationToken cancellationToken) + public async Task SetUserActionForMultiple(string userIds, int actionType, int destination, int type, string note, CancellationToken cancellationToken) { if (!String.IsNullOrWhiteSpace(userIds) && userIds.Split(char.Parse("|")).Any()) { @@ -1387,21 +1401,21 @@ public async Task SetUserActionForMultiple(string userIds, int ac if (!await _authorizationService.CanUserViewPersonAsync(UserId, userId, DepartmentId)) return Unauthorized(); - var status = new ActionLog(); - status.UserId = userId; - status.Timestamp = DateTime.UtcNow; - status.ActionTypeId = actionType; - status.DepartmentId = DepartmentId; - - if (destination > 0) - status.DestinationId = destination; - - if (!String.IsNullOrWhiteSpace(note)) - status.Note = HttpUtility.UrlDecode(note); - try { - var savedState = await _actionLogsService.SaveActionLogAsync(status, cancellationToken); + var decodedNote = !String.IsNullOrWhiteSpace(note) ? HttpUtility.UrlDecode(note) : null; + + if (destination > 0) + { + if (!await IsValidDestinationAsync(destination, type)) + return BadRequest(); + + await _actionLogsService.SetUserActionAsync(userId, DepartmentId, actionType, null, destination, type, decodedNote, cancellationToken); + } + else + { + await _actionLogsService.SetUserActionAsync(userId, DepartmentId, actionType, null, decodedNote, cancellationToken); + } } catch (Exception ex) { @@ -2136,6 +2150,9 @@ public async Task GeneratePersonnelEventsReport(IFormCollection f model.Rows = new List(); model.Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); model.RunOn = DateTime.UtcNow.TimeConverter(model.Department); + var stations = await _departmentGroupsService.GetAllStationGroupsForDepartmentAsync(DepartmentId); + var activeCalls = await _callsService.GetActiveCallsByDepartmentAsync(DepartmentId); + var pois = await _mappingService.GetPOIsForDepartmentAsync(DepartmentId); foreach (var eventId in eventIds) { @@ -2161,19 +2178,8 @@ public async Task GeneratePersonnelEventsReport(IFormCollection f personnelEvent.Timestamp = actionLog.Timestamp.TimeConverterToString(model.Department); personnelEvent.Note = actionLog.Note; - if (actionLog.DestinationId.HasValue && actionLog.DestinationType.HasValue) - { - if (actionLog.DestinationType.Value == 1) - { - var station = await _departmentGroupsService.GetGroupByIdAsync(actionLog.DestinationId.Value, false); - personnelEvent.DestinationName = station?.Name ?? "Station Not Found"; - } - else if (actionLog.DestinationType.Value == 2) - { - var call = await _callsService.GetCallByIdAsync(actionLog.DestinationId.Value, false); - personnelEvent.DestinationName = call?.Name ?? "Call Not Found"; - } - } + var destination = DestinationResolutionHelper.Resolve(actionLog.DestinationId, actionLog.DestinationType, statusDetail?.DetailType, activeCalls, stations, pois, _localizer); + personnelEvent.DestinationName = destination.Name; var coordinates = actionLog.GetCoordinates(); if (coordinates != null) @@ -2205,6 +2211,9 @@ public async Task GetPersonnelEvents(string userId) var personName = profile != null ? profile.FullName.AsFirstNameLastName : userId; var allEvents = await _actionLogsService.GetAllActionLogsForUser(userId); var events = allEvents.Where(al => al.DepartmentId == DepartmentId).ToList(); + var stations = await _departmentGroupsService.GetAllStationGroupsForDepartmentAsync(DepartmentId); + var activeCalls = await _callsService.GetActiveCallsByDepartmentAsync(DepartmentId); + var pois = await _mappingService.GetPOIsForDepartmentAsync(DepartmentId); foreach (var actionLog in events) { @@ -2218,25 +2227,8 @@ public async Task GetPersonnelEvents(string userId) personnelEvent.Timestamp = actionLog.Timestamp.TimeConverterToString(department); personnelEvent.Note = actionLog.Note; - if (actionLog.DestinationId.HasValue && actionLog.DestinationType.HasValue) - { - if (actionLog.DestinationType.Value == 1) // Station / Group - { - var station = await _departmentGroupsService.GetGroupByIdAsync(actionLog.DestinationId.Value, false); - if (station != null) - personnelEvent.DestinationName = station.Name; - else - personnelEvent.DestinationName = "Station Not Found"; - } - else if (actionLog.DestinationType.Value == 2) // Call - { - var call = await _callsService.GetCallByIdAsync(actionLog.DestinationId.Value, false); - if (call != null) - personnelEvent.DestinationName = call.Name; - else - personnelEvent.DestinationName = "Call Not Found"; - } - } + var destination = DestinationResolutionHelper.Resolve(actionLog.DestinationId, actionLog.DestinationType, statusDetail?.DetailType, activeCalls, stations, pois, _localizer); + personnelEvent.DestinationName = destination.Name; var coordinates = actionLog.GetCoordinates(); if (coordinates != null) @@ -2254,6 +2246,25 @@ public async Task GetPersonnelEvents(string userId) return Json(personnelEvents); } + private async Task IsValidDestinationAsync(int destinationId, int destinationType) + { + var entityType = (DestinationEntityTypes)destinationType; + + switch (entityType) + { + case DestinationEntityTypes.Station: + var station = await _departmentGroupsService.GetGroupByIdAsync(destinationId, false); + return station != null && station.DepartmentId == DepartmentId; + case DestinationEntityTypes.Call: + var call = await _callsService.GetCallByIdAsync(destinationId, false); + return call != null && call.DepartmentId == DepartmentId; + case DestinationEntityTypes.Poi: + return await _mappingService.GetDestinationPOIByIdAsync(DepartmentId, destinationId) != null; + default: + return false; + } + } + #endregion Personnel Events } } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/UnitsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/UnitsController.cs index 9b88a6c85..28054a237 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/UnitsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/UnitsController.cs @@ -3,6 +3,7 @@ using System.Linq; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Localization; using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Events; @@ -14,6 +15,7 @@ using Resgrid.Web.Areas.User.Models.Units; using Resgrid.Web.Helpers; using Microsoft.AspNetCore.Authorization; +using System.Globalization; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -43,13 +45,15 @@ public class UnitsController : SecureBaseController private readonly IDepartmentSettingsService _departmentSettingsService; private readonly IGeoLocationProvider _geoLocationProvider; private readonly INovuProvider _novuProvider; + private readonly IMappingService _mappingService; private readonly IUserDefinedFieldsService _userDefinedFieldsService; private readonly IUdfRenderingService _udfRenderingService; + private readonly IStringLocalizer _localizer; public UnitsController(IDepartmentsService departmentsService, IUsersService usersService, IUnitsService unitsService, Model.Services.IAuthorizationService authorizationService, ILimitsService limitsService, IDepartmentGroupsService departmentGroupsService, ICallsService callsService, IEventAggregator eventAggregator, ICustomStateService customStateService, - IGeoService geoService, IDepartmentSettingsService departmentSettingsService, IGeoLocationProvider geoLocationProvider, INovuProvider novuProvider, - IUserDefinedFieldsService userDefinedFieldsService, IUdfRenderingService udfRenderingService) + IGeoService geoService, IDepartmentSettingsService departmentSettingsService, IGeoLocationProvider geoLocationProvider, INovuProvider novuProvider, IMappingService mappingService, + IUserDefinedFieldsService userDefinedFieldsService, IUdfRenderingService udfRenderingService, IStringLocalizer localizer) { _departmentsService = departmentsService; _usersService = usersService; @@ -64,8 +68,10 @@ public UnitsController(IDepartmentsService departmentsService, IUsersService use _departmentSettingsService = departmentSettingsService; _geoLocationProvider = geoLocationProvider; _novuProvider = novuProvider; + _mappingService = mappingService; _userDefinedFieldsService = userDefinedFieldsService; _udfRenderingService = udfRenderingService; + _localizer = localizer; } #endregion Private Members and Constructors @@ -639,7 +645,13 @@ public async Task SetUnitStateWithDest(int unitId, int stateType, state.Timestamp = DateTime.UtcNow; if (destination > 0) + { + if (!await IsValidDestinationAsync(destination, type)) + return BadRequest(); + state.DestinationId = destination; + state.DestinationType = type; + } if (!String.IsNullOrWhiteSpace(note)) state.Note = HttpUtility.UrlDecode(note); @@ -680,7 +692,13 @@ public async Task SetUnitStateForMultiple(string unitIds, int sta state.Timestamp = DateTime.UtcNow; if (destination > 0) + { + if (!await IsValidDestinationAsync(destination, type)) + return BadRequest(); + state.DestinationId = destination; + state.DestinationType = type; + } if (!String.IsNullOrWhiteSpace(note)) state.Note = HttpUtility.UrlDecode(note); @@ -720,6 +738,10 @@ public async Task SetUnitStateWithDestForMultiple(string unitIds, state.State = stateType; state.Timestamp = DateTime.UtcNow; state.DestinationId = destination; + state.DestinationType = type; + + if (!await IsValidDestinationAsync(destination, type)) + return BadRequest(); try { @@ -918,6 +940,9 @@ public async Task GenerateReport(IFormCollection form) var model = new UnitEventsReportView(); model.Rows = new List(); model.Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); + var stations = await _departmentGroupsService.GetAllStationGroupsForDepartmentAsync(DepartmentId); + var activeCalls = await _callsService.GetActiveCallsByDepartmentAsync(DepartmentId); + var pois = await _mappingService.GetPOIsForDepartmentAsync(DepartmentId); foreach (var eventId in eventIds) { @@ -930,37 +955,9 @@ public async Task GenerateReport(IFormCollection form) eventJson.State = StringHelpers.GetDescription(((UnitStateTypes)eventRecord.State)); eventJson.Timestamp = eventRecord.Timestamp.TimeConverterToString(model.Department).ToString(); eventJson.Note = eventRecord.Note; - - if (((UnitStateTypes)eventRecord.State) == UnitStateTypes.Enroute) - { - if (eventRecord.DestinationId.HasValue) - { - var station = await _departmentGroupsService.GetGroupByIdAsync(eventRecord.DestinationId.Value, false); - - if (station != null) - eventJson.DestinationName = station.Name; - else - eventJson.DestinationName = "Station"; - } - else - { - eventJson.DestinationName = "Station"; - } - } - else if (((UnitStateTypes)eventRecord.State) == UnitStateTypes.Responding || ((UnitStateTypes)eventRecord.State) == UnitStateTypes.Committed - || ((UnitStateTypes)eventRecord.State) == UnitStateTypes.OnScene || ((UnitStateTypes)eventRecord.State) == UnitStateTypes.Staging - || ((UnitStateTypes)eventRecord.State) == UnitStateTypes.Released || ((UnitStateTypes)eventRecord.State) == UnitStateTypes.Cancelled) - { - if (eventRecord.DestinationId.HasValue) - { - var call = await _callsService.GetCallByIdAsync(eventRecord.DestinationId.Value, false); - - if (call != null) - eventJson.DestinationName = call.Name; - else - eventJson.DestinationName = "Scene"; - } - } + var customState = await _customStateService.GetCustomUnitStateAsync(eventRecord); + var destination = DestinationResolutionHelper.Resolve(eventRecord.DestinationId, eventRecord.DestinationType, customState?.DetailType, activeCalls, stations, pois, _localizer); + eventJson.DestinationName = destination.Name; if (eventRecord.LocalTimestamp.HasValue) eventJson.LocalTimestamp = eventRecord.LocalTimestamp.Value.ToString(); @@ -998,6 +995,9 @@ public async Task GetUnitEvents(int unitId) var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); var events = await _unitsService.GetAllStatesForUnitAsync(unitId); + var stations = await _departmentGroupsService.GetAllStationGroupsForDepartmentAsync(DepartmentId); + var activeCalls = await _callsService.GetActiveCallsByDepartmentAsync(DepartmentId); + var pois = await _mappingService.GetPOIsForDepartmentAsync(DepartmentId); foreach (var e in events) { @@ -1012,57 +1012,8 @@ public async Task GetUnitEvents(int unitId) unitEvent.Timestamp = e.Timestamp.TimeConverterToString(department); unitEvent.Note = e.Note; - if (customState?.DetailType == (int)CustomStateDetailTypes.Calls) - { - if (e.DestinationId.HasValue) - { - var call = await _callsService.GetCallByIdAsync(e.DestinationId.Value, false); - - if (call != null) - unitEvent.DestinationName = call.Name; - else - unitEvent.DestinationName = "Call Not Found"; - } - } - else if (customState?.DetailType == (int)CustomStateDetailTypes.Stations) - { - if (e.DestinationId.HasValue) - { - var station = await _departmentGroupsService.GetGroupByIdAsync(e.DestinationId.Value, false); - - if (station != null) - unitEvent.DestinationName = station.Name; - else - unitEvent.DestinationName = "Station Not Found"; - } - else - { - unitEvent.DestinationName = "Station Not Supplied"; - } - } - else if (customState?.DetailType == (int)CustomStateDetailTypes.CallsAndStations) - { - if (e.DestinationId.HasValue) - { - var station = await _departmentGroupsService.GetGroupByIdAsync(e.DestinationId.Value, false); - - if (station != null && station.DepartmentId == DepartmentId) - unitEvent.DestinationName = station.Name; - else - { - var call = await _callsService.GetCallByIdAsync(e.DestinationId.Value, false); - - if (call != null && call.DepartmentId == DepartmentId) - unitEvent.DestinationName = call.Name; - else - unitEvent.DestinationName = "Scene"; - } - } - else - { - unitEvent.DestinationName = "Call or Station"; - } - } + var destination = DestinationResolutionHelper.Resolve(e.DestinationId, e.DestinationType, customState?.DetailType, activeCalls, stations, pois, _localizer); + unitEvent.DestinationName = destination.Name; if (e.LocalTimestamp.HasValue) @@ -1381,7 +1332,7 @@ public async Task GetUnitStatusHtmlForDropdown(int unitId) foreach (var state in activeDetails.OrderBy(x => x.Order)) { - sb.Append($""); + sb.Append($""); } buttonHtml = sb.ToString(); @@ -1416,7 +1367,7 @@ public async Task GetUnitStatusHtmlForDropdownByStateId(int custo foreach (var state in activeDetails.OrderBy(x => x.Order)) { - sb.Append($""); + sb.Append($""); } buttonHtml = sb.ToString(); @@ -1429,71 +1380,21 @@ public async Task GetUnitStatusHtmlForDropdownByStateId(int custo [Authorize(Policy = ResgridResources.Unit_View)] public async Task GetUnitStatusDestinationHtmlForDropdown(int customStateId, int customStatusDetailId) { - string buttonHtml = string.Empty; - - CustomState customStates = null; - List activeDetails = null; - - if (customStateId > 25) - { - customStates = await _customStateService.GetCustomSateByIdAsync(customStateId); - - if (customStates != null) - { - activeDetails = customStates.GetActiveDetails(); - } - } - - if (activeDetails == null) - activeDetails = _customStateService.GetDefaultUnitStatuses(); - + var activeDetails = await GetActiveUnitStatusDetailsAsync(customStateId); var state = activeDetails.FirstOrDefault(x => x.CustomStateDetailId == customStatusDetailId); var activeCalls = await _callsService.GetActiveCallsByDepartmentAsync(DepartmentId); var stations = await _departmentGroupsService.GetAllStationGroupsForDepartmentAsync(DepartmentId); - StringBuilder sb = new StringBuilder(); + var destinationPois = await _mappingService.GetDestinationPOIsForDepartmentAsync(DepartmentId); + var sb = new StringBuilder(); - sb.Append($""); + sb.Append(""); if (state != null) { - if (state.DetailType == (int)CustomStateDetailTypes.None) - { - - } - else if (state.DetailType == (int)CustomStateDetailTypes.Calls) - { - foreach (var call in activeCalls) - { - sb.Append($""); - } - } - else if (state.DetailType == (int)CustomStateDetailTypes.Stations) - { - foreach (var station in stations) - { - sb.Append($""); - } - - sb.Append(""); - } - else if (state.DetailType == (int)CustomStateDetailTypes.CallsAndStations) - { - foreach (var call in activeCalls) - { - sb.Append($""); - } - - foreach (var station in stations) - { - sb.Append($""); - } - } + AppendDestinationOptions(sb, state, activeCalls, stations, destinationPois); } - buttonHtml = sb.ToString(); - - - return Content(buttonHtml); + return Content(sb.ToString()); } @@ -1514,85 +1415,21 @@ public async Task GetUnitOptionsDropdown(int unitId) var customStates = await _customStateService.GetCustomSateByIdAsync(type.CustomStatesId.Value); var activeCalls = await _callsService.GetActiveCallsByDepartmentAsync(DepartmentId); var stations = await _departmentGroupsService.GetAllStationGroupsForDepartmentAsync(DepartmentId); - - StringBuilder sb = new StringBuilder(); - sb.Append($""); - - buttonHtml = sb.ToString(); + buttonHtml = BuildUnitStateMenuHtml(unitId.ToString(CultureInfo.InvariantCulture), unitId, null, "SetUnitState", "SetUnitStateWithDest", activeDetails, activeCalls, stations, destinationPois); } if (String.IsNullOrWhiteSpace(buttonHtml)) { StringBuilder sb = new StringBuilder(); sb.Append($""); buttonHtml = sb.ToString(); @@ -1606,81 +1443,19 @@ public async Task GetUnitOptionsDropdown(int unitId) public async Task GetUnitOptionsDropdownForStates(int stateId, string units) { string buttonHtml = string.Empty; + var targetUnitIds = ParseUnitIds(units); if (stateId > 1) { var customStates = await _customStateService.GetCustomSateByIdAsync(stateId); var activeCalls = await _callsService.GetActiveCallsByDepartmentAsync(DepartmentId); var stations = await _departmentGroupsService.GetAllStationGroupsForDepartmentAsync(DepartmentId); - - StringBuilder sb = new StringBuilder(); - sb.Append($""); - - buttonHtml = sb.ToString(); + buttonHtml = BuildUnitStateMenuHtml(stateId.ToString(CultureInfo.InvariantCulture), null, targetUnitIds, "SetUnitStateForMultiple", "SetUnitStateWithDestForMultiple", activeDetails, activeCalls, stations, destinationPois); } } @@ -1688,11 +1463,11 @@ public async Task GetUnitOptionsDropdownForStates(int stateId, st { StringBuilder sb2 = new StringBuilder(); sb2.Append($""); buttonHtml = sb2.ToString(); @@ -1758,5 +1533,195 @@ public async Task GetActivePersonnelForUnitStaffingRoleJson(strin return Json(personsJson); } + + private async Task> GetActiveUnitStatusDetailsAsync(int customStateId) + { + if (customStateId > 25) + { + var customStates = await _customStateService.GetCustomSateByIdAsync(customStateId); + + if (customStates != null) + return customStates.GetActiveDetails(); + } + + return _customStateService.GetDefaultUnitStatuses(); + } + + private static void AppendDestinationOptions(StringBuilder sb, CustomStateDetail state, List activeCalls, List stations, List destinationPois) + { + if (state.DetailType.SupportsCalls()) + { + sb.Append(""); + foreach (var call in activeCalls) + { + sb.Append($""); + } + sb.Append(""); + } + + if (state.DetailType.SupportsStations()) + { + sb.Append(""); + foreach (var station in stations) + { + sb.Append($""); + } + sb.Append(""); + } + + if (state.DetailType.SupportsPois()) + { + foreach (var poiGroup in destinationPois.GroupBy(x => !String.IsNullOrWhiteSpace(x.Type?.Name) ? x.Type.Name : "POIs")) + { + sb.Append($""); + foreach (var poi in poiGroup.OrderBy(x => x.Name).ThenBy(x => x.Address).ThenBy(x => x.Note)) + { + sb.Append($""); + } + sb.Append(""); + } + } + } + + private string BuildUnitStateMenuHtml(string cssKey, int? unitId, IReadOnlyCollection unitIds, string actionWithoutDestination, string actionWithDestination, IEnumerable activeDetails, List activeCalls, List stations, List destinationPois) + { + var sb = new StringBuilder(); + sb.Append($""); + return sb.ToString(); + } + + private static IReadOnlyCollection ParseUnitIds(string units) + { + var parsedUnitIds = new List(); + + if (String.IsNullOrWhiteSpace(units)) + return parsedUnitIds; + + foreach (var unitIdValue in units.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries)) + { + if (int.TryParse(unitIdValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedUnitId) && + parsedUnitId > 0 && + !parsedUnitIds.Contains(parsedUnitId)) + { + parsedUnitIds.Add(parsedUnitId); + } + } + + return parsedUnitIds; + } + + private static string BuildUnitStateHref(string actionName, int? unitId, IReadOnlyCollection unitIds, int stateType, int? destinationType = null, int? destinationId = null) + { + var query = HttpUtility.ParseQueryString(string.Empty); + + if (unitId.HasValue && unitId.Value > 0) + query["unitId"] = unitId.Value.ToString(CultureInfo.InvariantCulture); + + if (unitIds != null && unitIds.Count > 0) + query["unitIds"] = string.Join("|", unitIds.Where(x => x > 0).Distinct().Select(x => x.ToString(CultureInfo.InvariantCulture))); + + query["stateType"] = stateType.ToString(CultureInfo.InvariantCulture); + + if (destinationType.HasValue) + query["type"] = destinationType.Value.ToString(CultureInfo.InvariantCulture); + + if (destinationId.HasValue) + query["destination"] = destinationId.Value.ToString(CultureInfo.InvariantCulture); + + var queryString = query.ToString(); + var href = $"/User/Units/{actionName}"; + + if (!String.IsNullOrWhiteSpace(queryString)) + href = $"{href}?{queryString}"; + + return HttpUtility.HtmlAttributeEncode(href); + } + + private static void AppendDestinationMenuEntries(StringBuilder sb, int? unitId, IReadOnlyCollection unitIds, string actionWithDestination, IEnumerable activeCalls, IEnumerable stations, IEnumerable destinationPois, CustomStateDetail state) + { + if (state.DetailType.SupportsCalls()) + { + sb.Append(""); + foreach (var call in activeCalls) + { + var callHref = BuildUnitStateHref(actionWithDestination, unitId, unitIds, state.CustomStateDetailId, (int)DestinationEntityTypes.Call, call.CallId); + var callText = HttpUtility.HtmlEncode($"{call.GetIdentifier()}:{call.Name}"); + sb.Append($"
  • {callText}
  • "); + } + } + + if (state.DetailType.SupportsStations()) + { + sb.Append(""); + foreach (var station in stations) + { + var stationHref = BuildUnitStateHref(actionWithDestination, unitId, unitIds, state.CustomStateDetailId, (int)DestinationEntityTypes.Station, station.DepartmentGroupId); + var stationText = HttpUtility.HtmlEncode(station.Name); + sb.Append($"
  • {stationText}
  • "); + } + } + + if (state.DetailType.SupportsPois()) + { + foreach (var poiGroup in destinationPois.GroupBy(x => !String.IsNullOrWhiteSpace(x.Type?.Name) ? x.Type.Name : "POIs")) + { + var poiGroupLabel = HttpUtility.HtmlEncode(poiGroup.Key); + sb.Append($""); + foreach (var poi in poiGroup.OrderBy(x => x.Name).ThenBy(x => x.Address).ThenBy(x => x.Note)) + { + var poiHref = BuildUnitStateHref(actionWithDestination, unitId, unitIds, state.CustomStateDetailId, (int)DestinationEntityTypes.Poi, poi.PoiId); + var poiText = HttpUtility.HtmlEncode(GetPoiDisplayText(poi)); + sb.Append($"
  • {poiText}
  • "); + } + } + } + } + + private static string GetPoiDisplayText(Poi poi) + { + return PoiDisplayHelper.GetSelectionLabel(poi); + } + + private async Task IsValidDestinationAsync(int destinationId, int destinationType) + { + var entityType = (DestinationEntityTypes)destinationType; + + switch (entityType) + { + case DestinationEntityTypes.Station: + var station = await _departmentGroupsService.GetGroupByIdAsync(destinationId, false); + return station != null && station.DepartmentId == DepartmentId; + case DestinationEntityTypes.Call: + var call = await _callsService.GetCallByIdAsync(destinationId, false); + return call != null && call.DepartmentId == DepartmentId; + case DestinationEntityTypes.Poi: + return await _mappingService.GetDestinationPOIByIdAsync(DepartmentId, destinationId) != null; + default: + return false; + } + } } } diff --git a/Web/Resgrid.Web/Areas/User/Models/Calls/NewCallView.cs b/Web/Resgrid.Web/Areas/User/Models/Calls/NewCallView.cs index 488d6748c..fd476d729 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Calls/NewCallView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Calls/NewCallView.cs @@ -39,11 +39,13 @@ public class NewCallView : BaseUserModel public SelectList ContactsList { get; set; } public string PrimaryContact { get; set; } public List AdditionalContacts { get; set; } + public List DestinationPois { get; set; } public NewCallView() { What3Words = new W3W(); AdditionalContacts = new List(); + DestinationPois = new List(); } } } diff --git a/Web/Resgrid.Web/Areas/User/Models/Calls/UpdateCallView.cs b/Web/Resgrid.Web/Areas/User/Models/Calls/UpdateCallView.cs index d206cfd5f..c174f0600 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Calls/UpdateCallView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Calls/UpdateCallView.cs @@ -35,5 +35,12 @@ public class UpdateCallView: BaseUserModel public SelectList CallTemplates { get; set; } public int CallTemplateId { get; set; } public string NewCallFormData { get; set; } + public List DestinationPois { get; set; } + + public UpdateCallView() + { + AdditionalContacts = new List(); + DestinationPois = new List(); + } } } diff --git a/Web/Resgrid.Web/Areas/User/Models/Calls/ViewCallView.cs b/Web/Resgrid.Web/Areas/User/Models/Calls/ViewCallView.cs index fd020a831..c23fb5392 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Calls/ViewCallView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Calls/ViewCallView.cs @@ -28,6 +28,9 @@ public class ViewCallView: BaseUserModel public List ChildCalls { get; set; } public List Contacts { get; set; } public List VideoFeeds { get; set; } = new List(); + public string DestinationName { get; set; } + public string DestinationAddress { get; set; } + public string DestinationTypeName { get; set; } public string IsMapTabActive() { diff --git a/Web/Resgrid.Web/Areas/User/Models/Dispatch/CallExportView.cs b/Web/Resgrid.Web/Areas/User/Models/Dispatch/CallExportView.cs index ed1c9dc19..4e736f3e1 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Dispatch/CallExportView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Dispatch/CallExportView.cs @@ -25,5 +25,8 @@ public class CallExportView : BaseUserModel public List Contacts { get; set; } public List CheckInRecords { get; set; } public List TimerConfigs { get; set; } + public string DestinationName { get; set; } + public string DestinationAddress { get; set; } + public string DestinationTypeName { get; set; } } } diff --git a/Web/Resgrid.Web/Areas/User/Models/Home/BigBoardModel.cs b/Web/Resgrid.Web/Areas/User/Models/Home/BigBoardModel.cs index 8aef2b364..1f87cea75 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Home/BigBoardModel.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Home/BigBoardModel.cs @@ -99,7 +99,7 @@ public class GroupViewModel public class PersonnelViewModel { - public static async Task Create(string name, ActionLog actionLog, UserState userState, Department department, DepartmentGroup respondingToDepartment, DepartmentGroup group, List roles, string callNumber) + public static async Task Create(string name, ActionLog actionLog, UserState userState, Department department, DepartmentGroup respondingToDepartment, DepartmentGroup group, List roles, string callNumber, ResolvedDestinationData destination) { DateTime updateDate = TimeConverterHelper.TimeConverter(DateTime.UtcNow, department); @@ -264,7 +264,9 @@ public static async Task Create(string name, ActionLog actio } else { - if (!String.IsNullOrWhiteSpace(callNumber)) + if (destination?.DestinationType == (int)DestinationEntityTypes.Poi && !String.IsNullOrWhiteSpace(destination.Name)) + status = string.Format("Responding to {0}", destination.Name); + else if (!String.IsNullOrWhiteSpace(callNumber)) status = string.Format("Responding to Call {0}", callNumber); else status = string.Format("Responding to Call {0}", actionLog.DestinationId); diff --git a/Web/Resgrid.Web/Areas/User/Models/Mapping/PoiJson.cs b/Web/Resgrid.Web/Areas/User/Models/Mapping/PoiJson.cs index 0f70ea29c..0fb63eeab 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Mapping/PoiJson.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Mapping/PoiJson.cs @@ -4,8 +4,10 @@ public class PoiJson { public int PoiId { get; set; } public int PoiTypeId { get; set; } + public string Name { get; set; } public double Longitude { get; set; } public double Latitude { get; set; } + public string Address { get; set; } public string Note { get; set; } } -} \ No newline at end of file +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/AddArchivedCall.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/AddArchivedCall.cshtml index e8153f16b..24bcb3ec4 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/AddArchivedCall.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/AddArchivedCall.cshtml @@ -47,10 +47,10 @@
    - @localizer["Template"] + @localizer["Template"] @if (!String.IsNullOrWhiteSpace(Model.NewCallFormData)) { - @localizer["CallForm"] + @localizer["CallForm"] }
    @@ -109,7 +109,7 @@
    - +
    @@ -118,7 +118,7 @@
    - +
    @@ -172,7 +172,7 @@
    - + @localizer["FindW3wLocation"] @@ -183,12 +183,12 @@
    - Lat - - Lng - + @localizer["LatitudeShort"] + + @localizer["LongitudeShort"] + - Set Pin on Map + @localizer["SetPinOnMap"]
    @@ -196,6 +196,13 @@
    +
    + +
    + @Html.DropDownListFor(x => x.Call.DestinationPoiId, Model.DestinationPois, new { @class = "form-control destination-poi-search", @style = "width:100%;" }) + @localizer["DestinationHelp"] +
    +
    @@ -422,6 +429,7 @@ } + @await Html.PartialAsync("_DispatchLocalizationScript") } diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/ArchivedCalls.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/ArchivedCalls.cshtml index 3b055df56..21a767f33 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/ArchivedCalls.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/ArchivedCalls.cshtml @@ -35,7 +35,7 @@
    -
    Archived Calls
    +
    @localizer["ArchivedCallsHeader"]
    @if (ClaimsAuthorizationHelper.CanCreateCall()) { @@ -47,7 +47,7 @@
    - Calls for Year: @Html.DropDownListFor(m => m.Year, Model.Years, new { @style = "margin-left: 4px; width: 60px;" }) + @localizer["CallsForYear"]: @Html.DropDownListFor(m => m.Year, Model.Years, new { @style = "margin-left: 4px; width: 60px;" })
    @@ -60,6 +60,6 @@ @section Scripts { - + @await Html.PartialAsync("_DispatchLocalizationScript") } diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallData.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallData.cshtml index d01f65179..36039fa65 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallData.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallData.cshtml @@ -84,7 +84,7 @@ } else { - Unknown + @commonLocalizer["Unknown"] } @if (a.Timestamp.HasValue) { @@ -95,7 +95,7 @@ @commonLocalizer["Unknown"] } - File + @localizer["File"] @if (a.Size.HasValue) { @@ -164,5 +164,6 @@ + @await Html.PartialAsync("_DispatchLocalizationScript") } diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExport.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExport.cshtml index 66d259a7d..8453ecc76 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExport.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExport.cshtml @@ -11,11 +11,11 @@ - + @localizer["CallExportHeader"] - +
    - + Resgrid

    @localizer["CallExportHeader"]

    @@ -129,7 +129,7 @@ @localizer["PrioirtyLabel"] - @(((CallPriority)Model.Call.Priority).ToString()) + @(await DispatchDisplayHelper.GetLocalizedCallPriorityAsync(Model.Department.DepartmentId, Model.Call.Priority, localizer)) @@ -195,6 +195,21 @@ @localizer["LocationLabel"] @Model.Call.Address + @if (!String.IsNullOrWhiteSpace(Model.DestinationName)) + { + @localizer["Destination"] + + @Model.DestinationName + @if (!String.IsNullOrWhiteSpace(Model.DestinationTypeName)) + { + (@Model.DestinationTypeName) + } + + @if (!String.IsNullOrWhiteSpace(Model.DestinationAddress) && Model.DestinationAddress != Model.DestinationName) + { + @Model.DestinationAddress + } + } @localizer["GPSLabel"] @@ -514,10 +529,10 @@ @foreach (var timer in Model.TimerConfigs) { - @((Resgrid.Model.CheckInTimerTargetType)timer.TargetType) + @DispatchDisplayHelper.GetLocalizedCheckInTimerTargetType((Resgrid.Model.CheckInTimerTargetType)timer.TargetType, localizer, commonLocalizer) @timer.DurationMinutes @timer.WarningThresholdMinutes - @(timer.IsFromOverride ? "Override" : "Default") + @DispatchDisplayHelper.GetLocalizedOverrideState(timer.IsFromOverride, localizer, commonLocalizer) } @@ -551,7 +566,7 @@ var unit = Model.Units.FirstOrDefault(u => u.UnitId == record.UnitId.Value); unitName = unit?.Name; } - var typeName = ((Resgrid.Model.CheckInTimerTargetType)record.CheckInType).ToString(); + var typeName = DispatchDisplayHelper.GetLocalizedCheckInTimerTargetType((Resgrid.Model.CheckInTimerTargetType)record.CheckInType, localizer, commonLocalizer); if (!string.IsNullOrEmpty(unitName)) { typeName += " - " + unitName; diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExportEx.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExportEx.cshtml index d9d15d46d..cbeb05e39 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExportEx.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExportEx.cshtml @@ -11,11 +11,11 @@ - + @localizer["CallExportHeader"] - + @localizer["PrioirtyLabel"] - @(((CallPriority)Model.Call.Priority).ToString()) + @(await DispatchDisplayHelper.GetLocalizedCallPriorityAsync(Model.Department.DepartmentId, Model.Call.Priority, localizer)) @@ -155,7 +155,7 @@ { @localizer["ClosedOnLabel"] - Not Closed + @localizer["NotClosed"] @localizer["ClosedByLabel"] @@ -170,6 +170,21 @@ @localizer["LocationLabel"] @Model.Call.Address + @if (!String.IsNullOrWhiteSpace(Model.DestinationName)) + { + @localizer["Destination"] + + @Model.DestinationName + @if (!String.IsNullOrWhiteSpace(Model.DestinationTypeName)) + { + (@Model.DestinationTypeName) + } + + @if (!String.IsNullOrWhiteSpace(Model.DestinationAddress) && Model.DestinationAddress != Model.DestinationName) + { + @Model.DestinationAddress + } + } @localizer["GPSLabel"] @@ -180,11 +195,11 @@ { @localizer["LocationLabel"] - Not Supplied + @localizer["NotSupplied"] @localizer["GPSLabel"] - Not Supplied + @localizer["NotSupplied"] } @@ -569,6 +584,11 @@ var startLon = @Model.StartLon; var endLat = @Model.EndLat; var endLon = @Model.EndLon; + var routeDistanceLabel = @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(localizer["RouteDistance"].Value, new Newtonsoft.Json.JsonSerializerSettings { StringEscapeHandling = Newtonsoft.Json.StringEscapeHandling.EscapeHtml })); + var routeDurationLabel = @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(localizer["RouteDuration"].Value, new Newtonsoft.Json.JsonSerializerSettings { StringEscapeHandling = Newtonsoft.Json.StringEscapeHandling.EscapeHtml })); + var routeStepLabel = @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(localizer["RouteStep"].Value, new Newtonsoft.Json.JsonSerializerSettings { StringEscapeHandling = Newtonsoft.Json.StringEscapeHandling.EscapeHtml })); + var routeUnitKilometers = @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(localizer["RouteUnitKilometers"].Value, new Newtonsoft.Json.JsonSerializerSettings { StringEscapeHandling = Newtonsoft.Json.StringEscapeHandling.EscapeHtml })); + var routeUnitMinutes = @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(localizer["RouteUnitMinutes"].Value, new Newtonsoft.Json.JsonSerializerSettings { StringEscapeHandling = Newtonsoft.Json.StringEscapeHandling.EscapeHtml })); $(document).ready(function () { var map = L.map('dvMap').setView([startLat, startLon], 9); @@ -595,7 +615,7 @@ var distKm = (routeData.distance / 1000).toFixed(1); var durationMin = Math.round(routeData.duration / 60); var dvDistance = document.getElementById('dvDistance'); - dvDistance.innerHTML = 'Distance: ' + distKm + ' km
    Duration: ' + durationMin + ' min'; + dvDistance.innerHTML = routeDistanceLabel + ': ' + distKm + ' ' + routeUnitKilometers + '
    ' + routeDurationLabel + ': ' + durationMin + ' ' + routeUnitMinutes; if (routeData.legs.length > 0) { var steps = routeData.legs[0].steps; @@ -603,8 +623,8 @@ for (var i = 0; i < steps.length; i++) { var s = steps[i]; if (s.maneuver && s.maneuver.type !== 'arrive') { - html += '
  • ' + s.maneuver.type + (s.name ? ' onto ' + s.name : '') + - ' (' + (s.distance / 1000).toFixed(1) + ' km)
  • '; + var stepTitle = s.name ? s.name : routeStepLabel + ' ' + (i + 1); + html += '
  • ' + stepTitle + ' (' + (s.distance / 1000).toFixed(1) + ' ' + routeUnitKilometers + ')
  • '; } } html += ''; diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/Chat.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/Chat.cshtml index 225b43c00..0b6de90ed 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/Chat.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/Chat.cshtml @@ -1,7 +1,7 @@ - +@inject IStringLocalizer localizer @model Resgrid.Web.Areas.User.Models.Dispatch.ChatView @{ - ViewBag.Title = "Resgrid | Dispatch Chat"; + ViewBag.Title = "Resgrid | " + @localizer["DispatchChat"]; Layout = "~/Areas/User/Views/Shared/_UserLayout.cshtml"; } @section Styles @@ -15,12 +15,12 @@
    -

    Dispatch

    +

    @localizer["DispatchLabel"]

    @@ -29,7 +29,7 @@ -
    Dispatch Chat
    +
    @localizer["DispatchChat"]
    @@ -41,15 +41,15 @@
    - + - +
    -
    Online Users
    +
    @localizer["OnlineUsers"]
    @@ -67,6 +67,7 @@ $(function () { // Reference the auto-generated proxy for the hub. var chatHub = $.connection.communicationHub; + var loggedOffMessage = @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(localizer["LoggedOff"].Value)); registerClientMethods(chatHub); @@ -138,7 +139,7 @@ var ctrId = 'private_' + id; $('#' + ctrId).remove(); - var disc = $('
    "' + userName + '" logged off.
    '); + var disc = $('
    "' + htmlEncode(userName) + '" ' + loggedOffMessage + '
    '); $(disc).hide(); $('#divusers').prepend(disc); diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/Dashboard.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/Dashboard.cshtml index a03f6bb65..52a263176 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/Dashboard.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/Dashboard.cshtml @@ -44,9 +44,9 @@
    @if (ClaimsAuthorizationHelper.CanViewRoutes()) { - Routes + @localizer["Routes"] } - @localizer["ArchivedCalls"] + @localizer["ArchivedCalls"] @if (ClaimsAuthorizationHelper.CanCreateCall()) { @localizer["NewCall"] @@ -88,7 +88,7 @@ @if (ClaimsAuthorizationHelper.CanCreateCall()) { }
    @@ -109,6 +109,7 @@ var centerLon = '@Model.Longitude'; + @await Html.PartialAsync("_DispatchLocalizationScript") } diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml index 567cbc9d4..c7dd379c0 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml @@ -5,6 +5,7 @@ Layout = "~/Areas/User/Views/Shared/_UserLayout.cshtml"; var mapConfig = SettingsHelper.GetDepartmentMapConfig(Resgrid.Config.InfoConfig.WebsiteKey); } + @section Styles {