diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx index 65f4d6b88..3d39277bb 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx @@ -208,7 +208,7 @@ إعدادات API - إعدادات استيراد البلاغات + إعدادات البلاغات ترتيب البلاغات diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx index e7e22593c..faf7749b3 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx @@ -150,7 +150,7 @@ API Settings - Einsatz-Importeinstellungen + Einsatzeinstellungen Call Sorting diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.el.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.el.resx index 3532feb07..eea7e8cbe 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.el.resx @@ -208,7 +208,7 @@ Ρυθμίσεις API - Ρυθμίσεις Εισαγωγής Κλήσεων + Ρυθμίσεις Κλήσεων Ταξινόμηση Κλήσεων diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx index ecad88249..36ae3e173 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx @@ -208,7 +208,7 @@ API Settings - Call Import Settings + Call Settings Call Sorting diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx index 433d01f6a..b517a20d8 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx @@ -208,7 +208,7 @@ Configuración de API - Configuración de importación de llamadas + Configuración de llamadas Clasificación de llamadas diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx index 7af236781..32be58d4e 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx @@ -150,7 +150,7 @@ API Settings - Paramètres d'importation d'appels + Paramètres des appels Call Sorting diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx index 02607c560..ade1871f0 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx @@ -150,7 +150,7 @@ API Settings - Impostazioni importazione chiamate + Impostazioni chiamate Call Sorting diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx index 51a0bf4dd..15ec83406 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx @@ -150,7 +150,7 @@ API Settings - Ustawienia importu zgłoszeń + Ustawienia zgłoszeń Call Sorting diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx index ed7a97008..be523a93c 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx @@ -150,7 +150,7 @@ API Settings - Inställningar för larmimport + Larminställningar Call Sorting diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx index d9b381cfe..46bed3c2f 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx @@ -150,7 +150,7 @@ API Settings - Налаштування імпорту викликів + Налаштування викликів Call Sorting diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx index 23692006c..8f43aa03e 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx @@ -128,7 +128,7 @@ يوم الشهر تُسمّى بالسنة التي تنتهي فيها تُسمّى بالسنة التي تبدأ فيها - للسجلات المرقّمة حسب سنة مالية، سواء كانت سنتك أو سنة المقاطعة أو الولاية التي ترفع إليها التقارير، اختر اليوم الذي تبدأ فيه. تبدأ الأرقام التي تتضمن السنة من 1 من جديد في ذلك اليوم، ويكتب {YYYY} و{YY} اسم السنة المالية. تُسمّى معظم السنوات المالية بالسنة التي تنتهي فيها، لذا فإن السنة التي تبدأ في 1 نوفمبر 2026 هي 2027. يمثّل 1 يناير السنة الميلادية. لأرقام البلاغات بداية سنة خاصة بها في صفحة إعدادات استيراد البلاغات. تتبع أرقام طلبات الاطلاع على السجلات وأرقام عمليات التفتيش والتصاريح والمباني والتحقيقات والأدلة السنة نفسها. + للسجلات المرقّمة حسب سنة مالية، سواء كانت سنتك أو سنة المقاطعة أو الولاية التي ترفع إليها التقارير، اختر اليوم الذي تبدأ فيه. تبدأ الأرقام التي تتضمن السنة من 1 من جديد في ذلك اليوم، ويكتب {YYYY} و{YY} اسم السنة المالية. تُسمّى معظم السنوات المالية بالسنة التي تنتهي فيها، لذا فإن السنة التي تبدأ في 1 نوفمبر 2026 هي 2027. يمثّل 1 يناير السنة الميلادية. لأرقام البلاغات بداية سنة خاصة بها في صفحة إعدادات البلاغات. تتبع أرقام طلبات الاطلاع على السجلات وأرقام عمليات التفتيش والتصاريح والمباني والتحقيقات والأدلة السنة نفسها. الأرقام التالية لعام {0} (من {1} إلى {2}) لم يُحفظ ترقيم السجلات. يجب أن تبدأ سنة الترقيم في يوم موجود في كل سنة، لذا لا يمكن استخدام 29 فبراير. أرقام سجلات أخرى diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx index b7500dd2a..58203734c 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx @@ -128,7 +128,7 @@ Tag des Monats Benannt nach dem Jahr, in dem es endet Benannt nach dem Jahr, in dem es beginnt - Für Akten, die nach einem Haushaltsjahr nummeriert werden – Ihrem eigenen oder dem eines Landkreises oder Landes, an den Sie berichten –, wählen Sie den Tag, an dem es beginnt. Nummern mit Jahr beginnen an diesem Tag wieder bei 1, und {YYYY} und {YY} schreiben den Namen des Haushaltsjahres. Die meisten Haushaltsjahre werden nach dem Jahr benannt, in dem sie enden; eines, das am 1. November 2026 beginnt, ist also 2027. Der 1. Januar ist das Kalenderjahr. Einsatznummern haben ihren eigenen Jahresbeginn auf der Seite Einsatz-Importeinstellungen. Nummern von Auskunftsersuchen sowie von Prüfungen, Genehmigungen, Objekten, Ermittlungen und Nachweisen folgen demselben Jahr. + Für Akten, die nach einem Haushaltsjahr nummeriert werden – Ihrem eigenen oder dem eines Landkreises oder Landes, an den Sie berichten –, wählen Sie den Tag, an dem es beginnt. Nummern mit Jahr beginnen an diesem Tag wieder bei 1, und {YYYY} und {YY} schreiben den Namen des Haushaltsjahres. Die meisten Haushaltsjahre werden nach dem Jahr benannt, in dem sie enden; eines, das am 1. November 2026 beginnt, ist also 2027. Der 1. Januar ist das Kalenderjahr. Einsatznummern haben ihren eigenen Jahresbeginn auf der Seite Einsatzeinstellungen. Nummern von Auskunftsersuchen sowie von Prüfungen, Genehmigungen, Objekten, Ermittlungen und Nachweisen folgen demselben Jahr. Nächste Nummern für {0} ({1} bis {2}) Die Aktennummerierung wurde nicht gespeichert. Das Nummerierungsjahr muss an einem Tag beginnen, den es in jedem Jahr gibt; der 29. Februar ist daher nicht möglich. Weitere Aktennummern diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx index 0e7af132e..1a34d7be9 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx @@ -128,7 +128,7 @@ Ημέρα του μήνα Ονομάζεται από το έτος στο οποίο λήγει Ονομάζεται από το έτος στο οποίο ξεκινά - Για αρχεία που αριθμούνται ανά οικονομικό έτος, το δικό σας ή μιας κομητείας ή πολιτείας στην οποία αναφέρετε, επιλέξτε την ημέρα που ξεκινά. Οι αριθμοί που περιέχουν το έτος ξεκινούν ξανά από το 1 εκείνη την ημέρα, και τα {YYYY} και {YY} γράφουν το όνομα του οικονομικού έτους. Τα περισσότερα οικονομικά έτη ονομάζονται από το έτος στο οποίο λήγουν, οπότε ένα που ξεκινά την 1η Νοεμβρίου 2026 είναι το 2027. Η 1η Ιανουαρίου είναι το ημερολογιακό έτος. Οι αριθμοί κλήσεων έχουν τη δική τους έναρξη έτους στη σελίδα Ρυθμίσεις Εισαγωγής Κλήσεων. Οι αριθμοί αιτημάτων πρόσβασης σε αρχεία και οι αριθμοί επιθεωρήσεων, αδειών, κτιρίων, ερευνών και τεκμηρίων ακολουθούν το ίδιο έτος. + Για αρχεία που αριθμούνται ανά οικονομικό έτος, το δικό σας ή μιας κομητείας ή πολιτείας στην οποία αναφέρετε, επιλέξτε την ημέρα που ξεκινά. Οι αριθμοί που περιέχουν το έτος ξεκινούν ξανά από το 1 εκείνη την ημέρα, και τα {YYYY} και {YY} γράφουν το όνομα του οικονομικού έτους. Τα περισσότερα οικονομικά έτη ονομάζονται από το έτος στο οποίο λήγουν, οπότε ένα που ξεκινά την 1η Νοεμβρίου 2026 είναι το 2027. Η 1η Ιανουαρίου είναι το ημερολογιακό έτος. Οι αριθμοί κλήσεων έχουν τη δική τους έναρξη έτους στη σελίδα Ρυθμίσεις Κλήσεων. Οι αριθμοί αιτημάτων πρόσβασης σε αρχεία και οι αριθμοί επιθεωρήσεων, αδειών, κτιρίων, ερευνών και τεκμηρίων ακολουθούν το ίδιο έτος. Επόμενοι αριθμοί για το {0} ({1} έως {2}) Η αρίθμηση αρχείων δεν αποθηκεύτηκε. Το έτος αρίθμησης πρέπει να ξεκινά σε ημέρα που υπάρχει κάθε έτος, οπότε η 29η Φεβρουαρίου δεν μπορεί να χρησιμοποιηθεί. Άλλοι αριθμοί αρχείων diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx index bb9a29c22..975b3eefd 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx @@ -128,7 +128,7 @@ Day of the month Named by the year it ends in Named by the year it starts in - For records numbered by a fiscal year, your own or a county's or state's you report to, pick the day it starts. Numbers that include the year start again at 1 that day, and {YYYY} and {YY} write the fiscal year's name. Most fiscal years are named by the year they end in, so one starting November 1, 2026 is 2027. January 1 is the calendar year. Call numbers have their own year start on the Call Import Settings page. Records request numbers and the numbers of inspections, permits, occupancies, investigations and evidence follow the same year. + For records numbered by a fiscal year, your own or a county's or state's you report to, pick the day it starts. Numbers that include the year start again at 1 that day, and {YYYY} and {YY} write the fiscal year's name. Most fiscal years are named by the year they end in, so one starting November 1, 2026 is 2027. January 1 is the calendar year. Call numbers have their own year start on the Call Settings page. Records request numbers and the numbers of inspections, permits, occupancies, investigations and evidence follow the same year. Next numbers for {0} ({1} to {2}) The record numbering was not saved. The numbering year has to start on a day every year has, so February 29 cannot be used. Other record numbers diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx index dd8b4950b..794d4d050 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx @@ -128,7 +128,7 @@ Día del mes Se nombra por el año en que termina Se nombra por el año en que empieza - Para registros numerados por un año fiscal, el suyo o el de un condado o estado al que informa, elija el día en que empieza. Los números que incluyen el año vuelven a empezar en 1 ese día, y {YYYY} y {YY} escriben el nombre del año fiscal. La mayoría de los años fiscales se nombran por el año en que terminan, así que uno que empieza el 1 de noviembre de 2026 es 2027. El 1 de enero es el año natural. Los números de llamada tienen su propio inicio de año en la página Configuración de importación de llamadas. Los números de las solicitudes de acceso a registros y de inspecciones, permisos, ocupaciones, investigaciones y evidencias siguen el mismo año. + Para registros numerados por un año fiscal, el suyo o el de un condado o estado al que informa, elija el día en que empieza. Los números que incluyen el año vuelven a empezar en 1 ese día, y {YYYY} y {YY} escriben el nombre del año fiscal. La mayoría de los años fiscales se nombran por el año en que terminan, así que uno que empieza el 1 de noviembre de 2026 es 2027. El 1 de enero es el año natural. Los números de llamada tienen su propio inicio de año en la página Configuración de llamadas. Los números de las solicitudes de acceso a registros y de inspecciones, permisos, ocupaciones, investigaciones y evidencias siguen el mismo año. Próximos números para {0} ({1} a {2}) La numeración de registros no se guardó. El año de numeración tiene que empezar en un día que exista todos los años, así que no se puede usar el 29 de febrero. Otros números de registros diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx index 60ccfc5ff..55909290b 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx @@ -128,7 +128,7 @@ Jour du mois Nommée d'après l'année où elle se termine Nommée d'après l'année où elle commence - Pour les dossiers numérotés par exercice budgétaire, le vôtre ou celui d'un comté ou d'un État auquel vous rendez compte, choisissez le jour où il commence. Les numéros qui contiennent l'année recommencent à 1 ce jour-là, et {YYYY} et {YY} écrivent le nom de l'exercice. La plupart des exercices sont nommés d'après l'année où ils se terminent : celui qui commence le 1er novembre 2026 est donc 2027. Le 1er janvier correspond à l'année civile. Les numéros d'appel ont leur propre début d'année sur la page Paramètres d'importation d'appels. Les numéros des demandes d'accès aux documents et ceux des inspections, permis, occupations, enquêtes et preuves suivent la même année. + Pour les dossiers numérotés par exercice budgétaire, le vôtre ou celui d'un comté ou d'un État auquel vous rendez compte, choisissez le jour où il commence. Les numéros qui contiennent l'année recommencent à 1 ce jour-là, et {YYYY} et {YY} écrivent le nom de l'exercice. La plupart des exercices sont nommés d'après l'année où ils se terminent : celui qui commence le 1er novembre 2026 est donc 2027. Le 1er janvier correspond à l'année civile. Les numéros d'appel ont leur propre début d'année sur la page Paramètres des appels. Les numéros des demandes d'accès aux documents et ceux des inspections, permis, occupations, enquêtes et preuves suivent la même année. Prochains numéros pour {0} (du {1} au {2}) La numérotation des dossiers n'a pas été enregistrée. L'année de numérotation doit commencer un jour qui existe chaque année ; le 29 février ne peut donc pas être utilisé. Autres numéros de dossiers diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx index 98cfd9317..6dd94be3d 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx @@ -128,7 +128,7 @@ Giorno del mese Prende il nome dall'anno in cui termina Prende il nome dall'anno in cui inizia - Per i registri numerati per anno fiscale, il tuo o quello di una contea o di uno stato a cui riferisci, scegli il giorno in cui inizia. I numeri che includono l'anno ripartono da 1 quel giorno, e {YYYY} e {YY} scrivono il nome dell'anno fiscale. La maggior parte degli anni fiscali prende il nome dall'anno in cui termina, quindi uno che inizia il 1° novembre 2026 è il 2027. Il 1° gennaio è l'anno solare. I numeri di chiamata hanno un proprio inizio d'anno nella pagina Impostazioni importazione chiamate. I numeri delle richieste di accesso agli atti e quelli di ispezioni, permessi, occupazioni, indagini e prove seguono lo stesso anno. + Per i registri numerati per anno fiscale, il tuo o quello di una contea o di uno stato a cui riferisci, scegli il giorno in cui inizia. I numeri che includono l'anno ripartono da 1 quel giorno, e {YYYY} e {YY} scrivono il nome dell'anno fiscale. La maggior parte degli anni fiscali prende il nome dall'anno in cui termina, quindi uno che inizia il 1° novembre 2026 è il 2027. Il 1° gennaio è l'anno solare. I numeri di chiamata hanno un proprio inizio d'anno nella pagina Impostazioni chiamate. I numeri delle richieste di accesso agli atti e quelli di ispezioni, permessi, occupazioni, indagini e prove seguono lo stesso anno. Prossimi numeri per il {0} (dal {1} al {2}) La numerazione dei registri non è stata salvata. L'anno di numerazione deve iniziare in un giorno presente in ogni anno, quindi il 29 febbraio non può essere usato. Altri numeri dei registri diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx index fd4d2abf3..05dd35a9a 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx @@ -128,7 +128,7 @@ Dzień miesiąca Nazwany rokiem, w którym się kończy Nazwany rokiem, w którym się zaczyna - Dla rejestrów numerowanych według roku budżetowego — własnego albo hrabstwa lub stanu, któremu składasz raporty — wybierz dzień, w którym się zaczyna. Numery zawierające rok zaczynają się wtedy ponownie od 1, a {YYYY} i {YY} wpisują nazwę roku budżetowego. Większość lat budżetowych nazywa się rokiem, w którym się kończą, więc rok zaczynający się 1 listopada 2026 to 2027. 1 stycznia oznacza rok kalendarzowy. Numery zgłoszeń mają własny początek roku na stronie Ustawienia importu zgłoszeń. Numery wniosków o udostępnienie akt oraz numery kontroli, zezwoleń, obiektów, dochodzeń i materiału dowodowego podlegają temu samemu rokowi. + Dla rejestrów numerowanych według roku budżetowego — własnego albo hrabstwa lub stanu, któremu składasz raporty — wybierz dzień, w którym się zaczyna. Numery zawierające rok zaczynają się wtedy ponownie od 1, a {YYYY} i {YY} wpisują nazwę roku budżetowego. Większość lat budżetowych nazywa się rokiem, w którym się kończą, więc rok zaczynający się 1 listopada 2026 to 2027. 1 stycznia oznacza rok kalendarzowy. Numery zgłoszeń mają własny początek roku na stronie Ustawienia zgłoszeń. Numery wniosków o udostępnienie akt oraz numery kontroli, zezwoleń, obiektów, dochodzeń i materiału dowodowego podlegają temu samemu rokowi. Następne numery na rok {0} (od {1} do {2}) Numeracja rejestrów nie została zapisana. Rok numeracji musi zaczynać się w dniu, który występuje w każdym roku, więc nie można użyć 29 lutego. Inne numery rejestrów diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx index 852c6913d..b5283ad4e 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx @@ -128,7 +128,7 @@ Dag i månaden Namnges efter året det slutar Namnges efter året det börjar - För journaler som numreras efter ett räkenskapsår, ert eget eller ett läns eller en delstats som ni rapporterar till, väljer du dagen det börjar. Nummer som innehåller året börjar om på 1 den dagen, och {YYYY} och {YY} skriver räkenskapsårets namn. De flesta räkenskapsår namnges efter året de slutar, så ett som börjar den 1 november 2026 är 2027. Den 1 januari är kalenderåret. Larmnummer har en egen årsstart på sidan Inställningar för larmimport. Nummer för begäran om allmänna handlingar och nummer för tillsyner, tillstånd, objekt, utredningar och underlag följer samma år. + För journaler som numreras efter ett räkenskapsår, ert eget eller ett läns eller en delstats som ni rapporterar till, väljer du dagen det börjar. Nummer som innehåller året börjar om på 1 den dagen, och {YYYY} och {YY} skriver räkenskapsårets namn. De flesta räkenskapsår namnges efter året de slutar, så ett som börjar den 1 november 2026 är 2027. Den 1 januari är kalenderåret. Larmnummer har en egen årsstart på sidan Larminställningar. Nummer för begäran om allmänna handlingar och nummer för tillsyner, tillstånd, objekt, utredningar och underlag följer samma år. Nästa nummer för {0} ({1} till {2}) Journalnumreringen sparades inte. Numreringsåret måste börja på en dag som finns varje år, så den 29 februari kan inte användas. Andra journalnummer diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx index 5221f7f04..203e7bf9a 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx @@ -128,7 +128,7 @@ День місяця Називається за роком, у якому закінчується Називається за роком, у якому починається - Для записів, що нумеруються за фінансовим роком — вашим власним або округу чи штату, якому ви звітуєте, — виберіть день, коли він починається. Номери з роком знову починаються з 1 цього дня, а {YYYY} і {YY} записують назву фінансового року. Більшість фінансових років називають за роком, у якому вони закінчуються, тож рік, що починається 1 листопада 2026, — це 2027. 1 січня — календарний рік. Номери викликів мають власний початок року на сторінці «Налаштування імпорту викликів». Номери запитів на доступ до документів і номери перевірок, дозволів, об’єктів, розслідувань та доказів слідують тому самому року. + Для записів, що нумеруються за фінансовим роком — вашим власним або округу чи штату, якому ви звітуєте, — виберіть день, коли він починається. Номери з роком знову починаються з 1 цього дня, а {YYYY} і {YY} записують назву фінансового року. Більшість фінансових років називають за роком, у якому вони закінчуються, тож рік, що починається 1 листопада 2026, — це 2027. 1 січня — календарний рік. Номери викликів мають власний початок року на сторінці «Налаштування викликів». Номери запитів на доступ до документів і номери перевірок, дозволів, об’єктів, розслідувань та доказів слідують тому самому року. Наступні номери на {0} рік (з {1} по {2}) Нумерацію записів не збережено. Рік нумерації має починатися в день, який є в кожному році, тож 29 лютого використати не можна. Інші номери записів diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.ar.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.ar.resx index 79dafa7b9..77fc945e0 100644 --- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.ar.resx @@ -356,4 +356,99 @@ مرتبط تلقائيًا: حالة أُرسلت دون بلاغ وربطها Resgrid استنادًا إلى الحالة السابقة للوحدة أو إرسالها. مستنتج: حالة حدّدتها الوحدة دون وجهة أثناء إرسالها إلى البلاغ. الإرسال فقط: أُرسلت الوحدة لكنها لم تُبلغ عن أي حالة في البلاغ. + + + + جميع المجموعات + + + تُنسب المكالمات إلى المجموعة التي يحتوي سياجها الجغرافي على موقع المكالمة. تُنسب المكالمة الواقعة خارج كل الأسوار الجغرافية، أو التي ليس لها موقع، إلى المجموعة التي ينتمي إليها معظم الوحدات والأفراد المرسَلين إليها. اختيار مجموعة يشمل أيضًا المجموعات التابعة لها. + + + بلا نوع + + + الربع {0} {1} + + + المجموعة: {0} (بما في ذلك المجموعات التابعة لها) + + + المكالمات حسب المجموعة + + + غير منسوبة + + + مجموعات أخرى + + + تُحتسب كل مكالمة مرة واحدة: في المجموعة التي يحتوي سياجها الجغرافي على موقعها، وإلا ففي المجموعة التي ينتمي إليها معظم الوحدات والأفراد المرسَلين إليها. المكالمات غير المنسوبة تقع خارج كل الأسوار الجغرافية ولم يكن لها وحدات أو أفراد من أي مجموعة، أو توزعت بالتساوي بين المجموعات. + + + سياج جغرافي + + + الإرسال + + + المستجيبون + + + إجمالي المكالمات + + + متوسط زمن الوصول إلى الموقع + + + متوسط مدة المكالمة + + + استجابات الوحدات + + + استجابات الأفراد + + + الإحصاءات السنوية + + + الإحصاءات الربعية + + + الإحصاءات الشهرية + + + المكالمات حسب السنة + + + المكالمات حسب الربع + + + المكالمات حسب الشهر + + + الفترة + + + المكالمات + + + منذ بداية السنة + + + * فترة جزئية: نطاق التقرير يغطي جزءًا منها فقط. + + + أنواع المكالمات حسب الفترة + + + بلا مكالمات: + + + أخرى + + + {0} مكالمة + diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.de.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.de.resx index 1981eb479..25de64e73 100644 --- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.de.resx @@ -356,4 +356,99 @@ Automatisch verknüpft: ein ohne Einsatz gesendeter Status, den Resgrid anhand des vorherigen Status der Einheit oder ihrer Alarmierung verknüpft hat. Abgeleitet: ein Status, den die Einheit ohne Ziel gesetzt hat, während sie zum Einsatz alarmiert war. Nur Alarmierung: die Einheit wurde alarmiert, hat aber keinen Status zum Einsatz gemeldet. + + + + Alle Gruppen + + + Einsätze werden der Gruppe zugeordnet, deren Geofence den Einsatzort enthält. Ein Einsatz außerhalb aller Geofences oder ohne Ort wird der Gruppe zugeordnet, der die meisten alarmierten Einheiten und Kräfte angehören. Die Auswahl einer Gruppe schließt die ihr untergeordneten Gruppen ein. + + + Kein Typ + + + Q{0} {1} + + + Gruppe: {0} (einschließlich der untergeordneten Gruppen) + + + Einsätze nach Gruppe + + + Nicht zugeordnet + + + Andere Gruppen + + + Jeder Einsatz zählt einmal: in der Gruppe, deren Geofence seinen Ort enthält, andernfalls in der Gruppe, der die meisten alarmierten Einheiten und Kräfte angehören. Nicht zugeordnete Einsätze liegen außerhalb aller Geofences und hatten keine Einheiten oder Kräfte aus einer Gruppe, oder sie verteilten sich gleichmäßig auf mehrere Gruppen. + + + Geofence + + + Alarmierung + + + Einsatzkräfte + + + Einsätze gesamt + + + Ø Zeit bis vor Ort + + + Ø Einsatzdauer + + + Einheiteneinsätze + + + Personaleinsätze + + + Jahresstatistik + + + Quartalsstatistik + + + Monatsstatistik + + + Einsätze nach Jahr + + + Einsätze nach Quartal + + + Einsätze nach Monat + + + Zeitraum + + + Einsätze + + + Lfd. Jahr + + + * Teilzeitraum: Der Berichtszeitraum deckt ihn nur teilweise ab. + + + Einsatztypen nach Zeitraum + + + Keine Einsätze: + + + Sonstige + + + {0} Einsätze + diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.el.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.el.resx index cfc23c50f..6dd5bec99 100644 --- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.el.resx @@ -785,4 +785,99 @@ Αυτόματη σύνδεση: κατάσταση που στάλθηκε χωρίς κλήση και το Resgrid τη συνέδεσε βάσει της προηγούμενης κατάστασης της μονάδας ή της αποστολής της. Συναγόμενο: κατάσταση που όρισε η μονάδα χωρίς προορισμό ενώ είχε σταλεί στην κλήση. Μόνο αποστολή: η μονάδα στάλθηκε αλλά δεν ανέφερε καμία κατάσταση στην κλήση. + + + + Όλες οι ομάδες + + + Οι κλήσεις αντιστοιχίζονται στην ομάδα της οποίας η γεωπεριοχή περιέχει τη θέση της κλήσης. Μια κλήση εκτός κάθε γεωπεριοχής, ή χωρίς θέση, αντιστοιχίζεται στην ομάδα στην οποία ανήκουν οι περισσότερες μονάδες και το προσωπικό που διατέθηκαν. Η επιλογή μιας ομάδας περιλαμβάνει και τις υποκείμενες ομάδες της. + + + Χωρίς τύπο + + + Τ{0} {1} + + + Ομάδα: {0} (μαζί με τις υποκείμενες ομάδες) + + + Κλήσεις ανά ομάδα + + + Χωρίς αντιστοίχιση + + + Άλλες ομάδες + + + Κάθε κλήση μετρά μία φορά: στην ομάδα της οποίας η γεωπεριοχή περιέχει τη θέση της, αλλιώς στην ομάδα στην οποία ανήκουν οι περισσότερες μονάδες και το προσωπικό που διατέθηκαν. Οι κλήσεις χωρίς αντιστοίχιση βρίσκονται εκτός κάθε γεωπεριοχής και δεν είχαν μονάδες ή προσωπικό από κάποια ομάδα, ή μοιράστηκαν εξίσου μεταξύ ομάδων. + + + γεωπεριοχή + + + διάθεση + + + ανταποκριθέντες + + + Σύνολο κλήσεων + + + Μέσος χρόνος άφιξης + + + Μέση διάρκεια κλήσης + + + Αποκρίσεις μονάδων + + + Αποκρίσεις προσωπικού + + + Ετήσια στατιστικά + + + Τριμηνιαία στατιστικά + + + Μηνιαία στατιστικά + + + Κλήσεις ανά έτος + + + Κλήσεις ανά τρίμηνο + + + Κλήσεις ανά μήνα + + + Περίοδος + + + Κλήσεις + + + Από αρχή έτους + + + * Μερική περίοδος: το εύρος της αναφοράς καλύπτει μόνο μέρος της. + + + Τύποι κλήσεων ανά περίοδο + + + Χωρίς κλήσεις: + + + Άλλοι + + + {0} κλήσεις + diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.en.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.en.resx index 28c340559..717f8913c 100644 --- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.en.resx @@ -785,4 +785,99 @@ Auto-linked: a status sent without a call that Resgrid linked from the unit's previous status or its dispatch. Inferred: a status the unit set with no destination while dispatched to the call. Dispatch only: the unit was dispatched but reported no status on the call. + + + + All Groups + + + Calls are placed in the group whose geofence contains the call's location. A call outside every geofence, or without a location, is placed in the group most of its dispatched units and personnel belong to. Choosing a group also includes the groups beneath it. + + + No Type + + + Q{0} {1} + + + Group: {0} (including the groups beneath it) + + + Calls by Group + + + Unassigned + + + Other groups + + + Each call counts once: in the group whose geofence contains its location, otherwise in the group most of its dispatched units and personnel belong to. Unassigned calls are outside every geofence and had no units or personnel from a group, or were split evenly between groups. + + + geofence + + + dispatch + + + responders + + + Total Calls + + + Avg. Time to On Scene + + + Avg. Call Length + + + Unit Responses + + + Personnel Responses + + + Yearly Statistics + + + Quarterly Statistics + + + Monthly Statistics + + + Calls by Year + + + Calls by Quarter + + + Calls by Month + + + Period + + + Calls + + + YTD + + + * Partial period: the report range covers only part of it. + + + Call Types by Period + + + No calls: + + + Other + + + {0} calls + diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.es.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.es.resx index cd375b48e..255ae2e22 100644 --- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.es.resx @@ -356,4 +356,99 @@ Vinculado automáticamente: un estado enviado sin llamada que Resgrid vinculó a partir del estado anterior de la unidad o de su despacho. Deducido: un estado que la unidad estableció sin destino mientras estaba despachada a la llamada. Solo despacho: la unidad fue despachada pero no informó ningún estado en la llamada. + + + + Todos los grupos + + + Las llamadas se asignan al grupo cuya geocerca contiene la ubicación de la llamada. Una llamada fuera de todas las geocercas, o sin ubicación, se asigna al grupo al que pertenecen la mayoría de las unidades y el personal despachados. Elegir un grupo también incluye los grupos que dependen de él. + + + Sin tipo + + + T{0} {1} + + + Grupo: {0} (incluidos los grupos que dependen de él) + + + Llamadas por grupo + + + Sin asignar + + + Otros grupos + + + Cada llamada cuenta una vez: en el grupo cuya geocerca contiene su ubicación o, si no, en el grupo al que pertenecen la mayoría de las unidades y el personal despachados. Las llamadas sin asignar están fuera de todas las geocercas y no tuvieron unidades ni personal de ningún grupo, o se repartieron por igual entre grupos. + + + geocerca + + + despacho + + + intervinientes + + + Llamadas totales + + + Tiempo medio hasta llegar + + + Duración media de la llamada + + + Respuestas de unidades + + + Respuestas del personal + + + Estadísticas anuales + + + Estadísticas trimestrales + + + Estadísticas mensuales + + + Llamadas por año + + + Llamadas por trimestre + + + Llamadas por mes + + + Periodo + + + Llamadas + + + Acum. año + + + * Periodo parcial: el rango del informe solo cubre una parte. + + + Tipos de llamada por periodo + + + Sin llamadas: + + + Otros + + + {0} llamadas + diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.fr.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.fr.resx index 0d73b70c1..01399c376 100644 --- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.fr.resx @@ -356,4 +356,99 @@ Lié automatiquement : un statut envoyé sans appel que Resgrid a lié d'après le statut précédent de l'unité ou son envoi. Déduit : un statut défini sans destination par l'unité alors qu'elle était envoyée sur l'appel. Envoi seulement : l'unité a été envoyée mais n'a signalé aucun statut sur l'appel. + + + + Tous les groupes + + + Les appels sont attribués au groupe dont le géorepérage contient le lieu de l'appel. Un appel situé hors de tout géorepérage, ou sans lieu, est attribué au groupe auquel appartiennent la plupart des unités et du personnel engagés. Choisir un groupe inclut aussi les groupes qui en dépendent. + + + Sans type + + + T{0} {1} + + + Groupe : {0} (y compris les groupes qui en dépendent) + + + Appels par groupe + + + Non attribué + + + Autres groupes + + + Chaque appel compte une fois : dans le groupe dont le géorepérage contient son lieu, sinon dans le groupe auquel appartiennent la plupart des unités et du personnel engagés. Les appels non attribués sont hors de tout géorepérage et n'ont eu ni unités ni personnel d'un groupe, ou se sont répartis à égalité entre plusieurs groupes. + + + géorepérage + + + engagement + + + intervenants + + + Total des appels + + + Délai moyen d'arrivée sur les lieux + + + Durée moyenne des appels + + + Engagements d'unités + + + Engagements du personnel + + + Statistiques annuelles + + + Statistiques trimestrielles + + + Statistiques mensuelles + + + Appels par année + + + Appels par trimestre + + + Appels par mois + + + Période + + + Appels + + + Cumul annuel + + + * Période partielle : la plage du rapport n'en couvre qu'une partie. + + + Types d'appel par période + + + Aucun appel : + + + Autres + + + {0} appels + diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.it.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.it.resx index f80b5fab2..eb502eed5 100644 --- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.it.resx @@ -356,4 +356,99 @@ Collegato automaticamente: uno stato inviato senza chiamata che Resgrid ha collegato in base allo stato precedente dell'unità o al suo invio. Dedotto: uno stato impostato dall'unità senza destinazione mentre era inviata alla chiamata. Solo invio: l'unità è stata inviata ma non ha segnalato alcuno stato sulla chiamata. + + + + Tutti i gruppi + + + Le chiamate vengono assegnate al gruppo il cui geofence contiene il luogo della chiamata. Una chiamata fuori da ogni geofence, o senza luogo, viene assegnata al gruppo a cui appartiene la maggior parte delle unità e del personale inviati. La scelta di un gruppo include anche i gruppi sottostanti. + + + Nessun tipo + + + T{0} {1} + + + Gruppo: {0} (inclusi i gruppi sottostanti) + + + Chiamate per gruppo + + + Non assegnate + + + Altri gruppi + + + Ogni chiamata conta una volta: nel gruppo il cui geofence contiene il suo luogo, altrimenti nel gruppo a cui appartiene la maggior parte delle unità e del personale inviati. Le chiamate non assegnate sono fuori da ogni geofence e non hanno avuto unità o personale di alcun gruppo, oppure si sono divise in parti uguali tra più gruppi. + + + geofence + + + invio + + + soccorritori + + + Chiamate totali + + + Tempo medio di arrivo sul posto + + + Durata media della chiamata + + + Interventi delle unità + + + Interventi del personale + + + Statistiche annuali + + + Statistiche trimestrali + + + Statistiche mensili + + + Chiamate per anno + + + Chiamate per trimestre + + + Chiamate per mese + + + Periodo + + + Chiamate + + + Da inizio anno + + + * Periodo parziale: l'intervallo del report ne copre solo una parte. + + + Tipi di chiamata per periodo + + + Nessuna chiamata: + + + Altri + + + {0} chiamate + diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.pl.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.pl.resx index fdca64175..3284ccb19 100644 --- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.pl.resx @@ -356,4 +356,99 @@ Powiązane automatycznie: status wysłany bez zgłoszenia, który Resgrid powiązał na podstawie poprzedniego statusu jednostki lub jej zadysponowania. Wywnioskowane: status ustawiony przez jednostkę bez celu, gdy była zadysponowana do zgłoszenia. Tylko zadysponowanie: jednostka została zadysponowana, ale nie zgłosiła żadnego statusu w zgłoszeniu. + + + + Wszystkie grupy + + + Zgłoszenia są przypisywane do grupy, której obszar geofence obejmuje miejsce zgłoszenia. Zgłoszenie spoza wszystkich obszarów geofence lub bez lokalizacji jest przypisywane do grupy, do której należy większość zadysponowanych jednostek i personelu. Wybranie grupy obejmuje także grupy jej podrzędne. + + + Brak typu + + + Kw. {0} {1} + + + Grupa: {0} (wraz z grupami podrzędnymi) + + + Zgłoszenia według grup + + + Nieprzypisane + + + Inne grupy + + + Każde zgłoszenie liczy się raz: w grupie, której obszar geofence obejmuje jego lokalizację, a w przeciwnym razie w grupie, do której należy większość zadysponowanych jednostek i personelu. Zgłoszenia nieprzypisane leżą poza wszystkimi obszarami geofence i nie miały jednostek ani personelu z żadnej grupy lub rozłożyły się równo między grupy. + + + geofence + + + dysponowanie + + + ratownicy + + + Łącznie zgłoszeń + + + Śr. czas dojazdu na miejsce + + + Śr. czas trwania zgłoszenia + + + Wyjazdy jednostek + + + Udział personelu + + + Statystyki roczne + + + Statystyki kwartalne + + + Statystyki miesięczne + + + Zgłoszenia według lat + + + Zgłoszenia według kwartałów + + + Zgłoszenia według miesięcy + + + Okres + + + Zgłoszenia + + + Od pocz. roku + + + * Okres częściowy: zakres raportu obejmuje tylko jego część. + + + Typy zgłoszeń według okresów + + + Brak zgłoszeń: + + + Inne + + + Zgłoszenia: {0} + diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.sv.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.sv.resx index 134dd1c6b..a4bdc7887 100644 --- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.sv.resx @@ -356,4 +356,99 @@ Automatiskt kopplad: en status skickad utan larm som Resgrid kopplade utifrån enhetens föregående status eller dess utlarmning. Härledd: en status som enheten satte utan destination medan den var larmad till larmet. Endast larm: enheten larmades men rapporterade ingen status på larmet. + + + + Alla grupper + + + Larm placeras i den grupp vars geofence innehåller larmets plats. Ett larm utanför alla geofences, eller utan plats, placeras i den grupp som de flesta utlarmade enheter och personal tillhör. Att välja en grupp inkluderar även grupperna under den. + + + Ingen typ + + + K{0} {1} + + + Grupp: {0} (inklusive grupperna under den) + + + Larm per grupp + + + Ej tilldelade + + + Övriga grupper + + + Varje larm räknas en gång: i den grupp vars geofence innehåller dess plats, annars i den grupp som de flesta utlarmade enheter och personal tillhör. Ej tilldelade larm ligger utanför alla geofences och hade inga enheter eller personal från någon grupp, eller delades lika mellan grupper. + + + geofence + + + utlarmning + + + insatta resurser + + + Larm totalt + + + Snittid till framme + + + Snittlängd per larm + + + Enhetsinsatser + + + Personalinsatser + + + Årsstatistik + + + Kvartalsstatistik + + + Månadsstatistik + + + Larm per år + + + Larm per kvartal + + + Larm per månad + + + Period + + + Larm + + + Hittills i år + + + * Delperiod: rapportens intervall täcker bara en del av den. + + + Larmtyper per period + + + Inga larm: + + + Övriga + + + {0} larm + diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.uk.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.uk.resx index 8fcda3eef..e7d12d3c5 100644 --- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.uk.resx @@ -356,4 +356,99 @@ Пов’язано автоматично: статус, надісланий без виклику, який Resgrid пов’язав за попереднім статусом підрозділу або його направленням. Виведено: статус, який підрозділ встановив без пункту призначення, будучи направленим на виклик. Лише направлення: підрозділ було направлено, але він не повідомив жодного статусу на виклику. + + + + Усі групи + + + Виклики відносяться до групи, геозона якої містить місце виклику. Виклик поза всіма геозонами або без місця відноситься до групи, до якої належить більшість направлених підрозділів і персоналу. Вибір групи також включає підпорядковані їй групи. + + + Без типу + + + {0} кв. {1} + + + Група: {0} (разом із підпорядкованими групами) + + + Виклики за групами + + + Не віднесено + + + Інші групи + + + Кожен виклик рахується один раз: у групі, геозона якої містить його місце, інакше в групі, до якої належить більшість направлених підрозділів і персоналу. Не віднесені виклики лежать поза всіма геозонами і не мали підрозділів чи персоналу жодної групи або порівну розподілилися між групами. + + + геозона + + + направлення + + + залучені сили + + + Усього викликів + + + Сер. час прибуття на місце + + + Сер. тривалість виклику + + + Виїзди підрозділів + + + Залучення персоналу + + + Річна статистика + + + Квартальна статистика + + + Місячна статистика + + + Виклики за роками + + + Виклики за кварталами + + + Виклики за місяцями + + + Період + + + Виклики + + + З поч. року + + + * Неповний період: діапазон звіту охоплює лише його частину. + + + Типи викликів за періодами + + + Без викликів: + + + Інші + + + Викликів: {0} + diff --git a/Core/Resgrid.Localization/Common.ar.resx b/Core/Resgrid.Localization/Common.ar.resx index a7d090dbb..54f5e3f67 100644 --- a/Core/Resgrid.Localization/Common.ar.resx +++ b/Core/Resgrid.Localization/Common.ar.resx @@ -21,7 +21,7 @@ التقويم بلاغ عنوان البلاغ - إعدادات استيراد البلاغات + إعدادات البلاغات ترقيم المستندات البلاغات طابع وقت البلاغ diff --git a/Core/Resgrid.Localization/Common.de.resx b/Core/Resgrid.Localization/Common.de.resx index f0b99742f..fed50421e 100644 --- a/Core/Resgrid.Localization/Common.de.resx +++ b/Core/Resgrid.Localization/Common.de.resx @@ -107,8 +107,8 @@ Einsatzadresse - - Einsatz-Importeinstellungen + + Einsatzeinstellungen Dokumentnummerierung diff --git a/Core/Resgrid.Localization/Common.el.resx b/Core/Resgrid.Localization/Common.el.resx index 54c0b4b9a..427240e72 100644 --- a/Core/Resgrid.Localization/Common.el.resx +++ b/Core/Resgrid.Localization/Common.el.resx @@ -165,8 +165,8 @@ Διεύθυνση Κλήσης - - Ρυθμίσεις Εισαγωγής Κλήσεων + + Ρυθμίσεις Κλήσεων Αρίθμηση εγγράφων diff --git a/Core/Resgrid.Localization/Common.en.resx b/Core/Resgrid.Localization/Common.en.resx index a9dff8a51..591cc7780 100644 --- a/Core/Resgrid.Localization/Common.en.resx +++ b/Core/Resgrid.Localization/Common.en.resx @@ -165,8 +165,8 @@ Call Address - - Call Import Settings + + Call Settings Document Numbering diff --git a/Core/Resgrid.Localization/Common.es.resx b/Core/Resgrid.Localization/Common.es.resx index 15f63ab3c..db44c79fc 100644 --- a/Core/Resgrid.Localization/Common.es.resx +++ b/Core/Resgrid.Localization/Common.es.resx @@ -165,8 +165,8 @@ Dirección de la llamada - - Configuración de importación de llamadas + + Configuración de llamadas Numeración de documentos diff --git a/Core/Resgrid.Localization/Common.fr.resx b/Core/Resgrid.Localization/Common.fr.resx index a4ab32d76..19da3bab6 100644 --- a/Core/Resgrid.Localization/Common.fr.resx +++ b/Core/Resgrid.Localization/Common.fr.resx @@ -107,8 +107,8 @@ Adresse d'appel - - Paramètres d'importation d'appels + + Paramètres des appels Numérotation des documents diff --git a/Core/Resgrid.Localization/Common.it.resx b/Core/Resgrid.Localization/Common.it.resx index a1a1e516b..80c1d2f67 100644 --- a/Core/Resgrid.Localization/Common.it.resx +++ b/Core/Resgrid.Localization/Common.it.resx @@ -107,8 +107,8 @@ Indirizzo chiamata - - Impostazioni importazione chiamate + + Impostazioni chiamate Numerazione dei documenti diff --git a/Core/Resgrid.Localization/Common.pl.resx b/Core/Resgrid.Localization/Common.pl.resx index 9c087768d..0bdfa6c4b 100644 --- a/Core/Resgrid.Localization/Common.pl.resx +++ b/Core/Resgrid.Localization/Common.pl.resx @@ -107,8 +107,8 @@ Adres zgłoszenia - - Ustawienia importu zgłoszeń + + Ustawienia zgłoszeń Numeracja dokumentów diff --git a/Core/Resgrid.Localization/Common.sv.resx b/Core/Resgrid.Localization/Common.sv.resx index b68bb5eef..b37cdf5f1 100644 --- a/Core/Resgrid.Localization/Common.sv.resx +++ b/Core/Resgrid.Localization/Common.sv.resx @@ -107,8 +107,8 @@ Larmadress - - Inställningar för larmimport + + Larminställningar Dokumentnumrering diff --git a/Core/Resgrid.Localization/Common.uk.resx b/Core/Resgrid.Localization/Common.uk.resx index 23d7a8b5d..448737808 100644 --- a/Core/Resgrid.Localization/Common.uk.resx +++ b/Core/Resgrid.Localization/Common.uk.resx @@ -107,8 +107,8 @@ Адреса виклику - - Налаштування імпорту викликів + + Налаштування викликів Нумерація документів diff --git a/Core/Resgrid.Model/DispatchRecommendation.cs b/Core/Resgrid.Model/DispatchRecommendation.cs index d6d12ae9f..4feb33a61 100644 --- a/Core/Resgrid.Model/DispatchRecommendation.cs +++ b/Core/Resgrid.Model/DispatchRecommendation.cs @@ -138,6 +138,12 @@ public class UnitRecommendation public string CurrentStatusText { get; set; } + /// The status's button colour as the department set it up (hex); null when the status has none. + public string CurrentStatusColor { get; set; } + + /// The status's text colour (hex), drawn on ; null when the status has none. + public string CurrentStatusTextColor { get; set; } + /// UnitStaffingLevel at recommendation time (null when the staffing gate is off). public int? StaffingLevel { get; set; } diff --git a/Core/Resgrid.Model/NearestUnitBoard.cs b/Core/Resgrid.Model/NearestUnitBoard.cs index 8f19241e4..74c40afd9 100644 --- a/Core/Resgrid.Model/NearestUnitBoard.cs +++ b/Core/Resgrid.Model/NearestUnitBoard.cs @@ -124,6 +124,13 @@ public class NearestUnitResult public string StatusText { get; set; } + /// The status's button colour as the department set it up (hex, e.g. "#ff0000"), the same colour the + /// status shows everywhere else; null when the status has none. + public string StatusColor { get; set; } + + /// The status's text colour (hex), drawn on ; null when the status has none. + public string StatusTextColor { get; set; } + public bool IsAvailable { get; set; } public double? Latitude { get; set; } diff --git a/Core/Resgrid.Model/Reporting/CallGroupAssigner.cs b/Core/Resgrid.Model/Reporting/CallGroupAssigner.cs new file mode 100644 index 000000000..9a069aaa7 --- /dev/null +++ b/Core/Resgrid.Model/Reporting/CallGroupAssigner.cs @@ -0,0 +1,262 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Resgrid.Model.Helpers; + +namespace Resgrid.Model.Reporting +{ + /// How a call's group was decided, most direct first. + public enum CallGroupAssignmentMethods + { + /// No group: the call is outside every boundary and nothing with a group worked it, or the vote tied. + None = 0, + + /// The call's coordinates fall inside the group's geofence. + Geofence = 1, + + /// The group holds the plurality of the units and personnel dispatched to the call. + Dispatch = 2, + + /// Nothing with a group was dispatched; the group holds the plurality of the units and personnel with a status on the call. + Response = 3 + } + + /// The one group a call belongs to for reporting, and how that was decided. + public class CallGroupAssignment + { + public int CallId { get; set; } + + public int? DepartmentGroupId { get; set; } + + public CallGroupAssignmentMethods Method { get; set; } + } + + /// The units, personnel and groups on one side of a call (dispatched to it, or working it). + public class CallGroupResources + { + public HashSet UnitIds { get; } = new HashSet(); + + public HashSet UserIds { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); + + /// Groups dispatched as a whole; each counts through its members. + public HashSet GroupIds { get; } = new HashSet(); + + public bool IsEmpty => UnitIds.Count == 0 && UserIds.Count == 0 && GroupIds.Count == 0; + } + + /// + /// Places each call in one department group, for reports that break calls down by group (station). + /// + /// A call located inside a group's geofence belongs to that group. When boundaries overlap the innermost group in the + /// hierarchy wins, then a station over an organizational group, then the smaller boundary. + /// + /// + /// A call with no usable location, or outside every boundary, belongs to the group holding the plurality of what was + /// dispatched to it: each unit votes for its station group and each person for their group. A dispatched group counts + /// through its members, and one with no members votes once for itself; role dispatches do not vote, since a role + /// cuts across stations. Groups tied on votes are split by unit votes, then by the station nearest the call; a tie + /// that survives leaves the call unassigned rather than guessing. When nothing dispatched has a group, the units and + /// personnel that set a status on the call vote the same way. + /// + /// Group membership and unit station assignments are today's, not as they were when the call ran. + /// + public sealed class CallGroupAssigner + { + private sealed class Boundary + { + public int GroupId { get; set; } + public List Polygon { get; set; } + public int Depth { get; set; } + public bool IsStation { get; set; } + public double Area { get; set; } + } + + private readonly List _boundaries = new List(); + private readonly Dictionary _unitGroups = new Dictionary(); + private readonly Dictionary> _userGroups = new Dictionary>(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _groupMembers = new Dictionary>(); + private readonly Dictionary _stationPoints = new Dictionary(); + + public CallGroupAssigner(IEnumerable groups, IEnumerable units) + { + var groupList = (groups ?? Enumerable.Empty()).Where(g => g != null) + .GroupBy(g => g.DepartmentGroupId).Select(g => g.First()).ToList(); + + foreach (var group in groupList) + { + var polygon = GeoMath.ParseGeofence(group.Geofence); + if (polygon != null) + { + _boundaries.Add(new Boundary + { + GroupId = group.DepartmentGroupId, + Polygon = polygon, + Depth = DepartmentGroupHierarchy.GetAncestorIds(groupList, group.DepartmentGroupId).Count, + IsStation = group.Type == (int)DepartmentGroupTypes.Station, + Area = PlanarArea(polygon) + }); + } + + var stationPoint = GeoMath.ParseCoordinatePair(group.Latitude, group.Longitude) + ?? (polygon != null ? GeoMath.Centroid(polygon) : (GeoMath.GeoPoint?)null); + if (stationPoint.HasValue) + _stationPoints[group.DepartmentGroupId] = stationPoint.Value; + + var members = (group.Members ?? Enumerable.Empty()) + .Where(m => m != null && !string.IsNullOrWhiteSpace(m.UserId)) + .Select(m => m.UserId) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + _groupMembers[group.DepartmentGroupId] = members; + + foreach (var userId in members) + { + if (!_userGroups.TryGetValue(userId, out var userGroups)) + _userGroups[userId] = userGroups = new List(); + + userGroups.Add(group.DepartmentGroupId); + } + } + + foreach (var unit in (units ?? Enumerable.Empty()).Where(u => u != null)) + { + if (unit.StationGroupId.HasValue && _groupMembers.ContainsKey(unit.StationGroupId.Value)) + _unitGroups[unit.UnitId] = unit.StationGroupId.Value; + } + } + + /// Echoed onto the result. + /// The call's "lat,long" (Call.GeoLocationData); null or unparseable skips the boundary test. + /// What was dispatched to the call. + /// What set a status on the call; only votes when nothing dispatched has a group. + public CallGroupAssignment Assign(int callId, string geoLocationData, CallGroupResources dispatched, CallGroupResources responded = null) + { + var result = new CallGroupAssignment { CallId = callId, Method = CallGroupAssignmentMethods.None }; + var point = GeoMath.ParseLatLonString(geoLocationData); + + if (point.HasValue) + { + var containing = _boundaries + .Where(b => GeoMath.IsPointInPolygon(point.Value.Latitude, point.Value.Longitude, b.Polygon)) + .OrderByDescending(b => b.Depth) + .ThenByDescending(b => b.IsStation) + .ThenBy(b => b.Area) + .ThenBy(b => b.GroupId) + .FirstOrDefault(); + + if (containing != null) + { + result.DepartmentGroupId = containing.GroupId; + result.Method = CallGroupAssignmentMethods.Geofence; + return result; + } + } + + var votes = Tally(dispatched); + var method = CallGroupAssignmentMethods.Dispatch; + + if (votes.Count == 0) + { + votes = Tally(responded); + method = CallGroupAssignmentMethods.Response; + } + + var winner = Plurality(votes, point); + if (winner.HasValue) + { + result.DepartmentGroupId = winner; + result.Method = method; + } + + return result; + } + + private Dictionary Tally(CallGroupResources resources) + { + var votes = new Dictionary(); + if (resources == null || resources.IsEmpty) + return votes; + + void Vote(int groupId, bool isUnit) + { + votes.TryGetValue(groupId, out var current); + votes[groupId] = (current.Total + 1, current.Units + (isUnit ? 1 : 0)); + } + + foreach (var unitId in resources.UnitIds) + { + if (_unitGroups.TryGetValue(unitId, out var groupId)) + Vote(groupId, true); + } + + var users = new HashSet(resources.UserIds.Where(u => !string.IsNullOrWhiteSpace(u)), StringComparer.OrdinalIgnoreCase); + foreach (var groupId in resources.GroupIds) + { + if (!_groupMembers.TryGetValue(groupId, out var members)) + continue; + + if (members.Count == 0) + Vote(groupId, false); + else + users.UnionWith(members); + } + + foreach (var userId in users) + { + if (!_userGroups.TryGetValue(userId, out var userGroups)) + continue; + + foreach (var groupId in userGroups) + Vote(groupId, false); + } + + return votes; + } + + private int? Plurality(Dictionary votes, GeoMath.GeoPoint? point) + { + if (votes.Count == 0) + return null; + + var topTotal = votes.Values.Max(v => v.Total); + var tied = votes.Where(v => v.Value.Total == topTotal).ToList(); + + if (tied.Count > 1) + { + var topUnits = tied.Max(v => v.Value.Units); + tied = tied.Where(v => v.Value.Units == topUnits).ToList(); + } + + if (tied.Count == 1) + return tied[0].Key; + + // Nearest station breaks what is left, but only when every tied group has a location to measure from. + if (point.HasValue && tied.All(v => _stationPoints.ContainsKey(v.Key))) + { + var byDistance = tied + .Select(v => (GroupId: v.Key, Meters: GeoMath.HaversineMeters(point.Value.Latitude, point.Value.Longitude, + _stationPoints[v.Key].Latitude, _stationPoints[v.Key].Longitude))) + .OrderBy(v => v.Meters) + .ToList(); + + if (byDistance[0].Meters < byDistance[1].Meters) + return byDistance[0].GroupId; + } + + return null; + } + + /// + /// Shoelace area in square degrees. Only compares overlapping boundaries, which sit at the same latitude, so the + /// longitude scale distortion cancels out. + /// + private static double PlanarArea(IReadOnlyList polygon) + { + double sum = 0; + for (int i = 0, j = polygon.Count - 1; i < polygon.Count; j = i++) + sum += (polygon[j].Longitude * polygon[i].Latitude) - (polygon[i].Longitude * polygon[j].Latitude); + + return Math.Abs(sum) / 2d; + } + } +} diff --git a/Core/Resgrid.Model/Reporting/ReportPeriods.cs b/Core/Resgrid.Model/Reporting/ReportPeriods.cs new file mode 100644 index 000000000..c2b02183e --- /dev/null +++ b/Core/Resgrid.Model/Reporting/ReportPeriods.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model.Reporting +{ + public enum ReportPeriodGranularity + { + Month = 1, + Quarter = 2, + Year = 3 + } + + /// One calendar month, quarter or year a report range touches, in department-local time. + public class ReportPeriod + { + public ReportPeriodGranularity Granularity { get; set; } + + /// Calendar start of the period (inclusive), not clipped to the report range. + public DateTime Start { get; set; } + + /// Calendar end of the period (exclusive), not clipped to the report range. + public DateTime End { get; set; } + + public int Year { get; set; } + + /// Month 1-12, quarter 1-4, or the year again for a yearly period. + public int Number { get; set; } + + /// The report range covers only part of the period. + public bool IsPartial { get; set; } + + public bool Contains(DateTime local) => local >= Start && local < End; + } + + /// + /// Calendar breakdowns of a department-local report range. Coverage is judged by date, not time of day, so the + /// report pickers' "Jan 1 00:00:01 to Dec 31 23:59:59" default counts as the whole year, and a range ending exactly + /// at midnight ends the day before (it does not touch the next month). + /// + public static class ReportPeriods + { + /// The last instant the range covers: an end at exactly midnight belongs to the previous day. + public static DateTime InclusiveEnd(DateTime start, DateTime end) + { + if (end > start && end.TimeOfDay == TimeSpan.Zero) + return end.AddTicks(-1); + + return end < start ? start : end; + } + + public static bool CrossesMonth(DateTime start, DateTime end) + { + return MonthIndex(start) != MonthIndex(InclusiveEnd(start, end)); + } + + public static bool CrossesQuarter(DateTime start, DateTime end) + { + return QuarterIndex(start) != QuarterIndex(InclusiveEnd(start, end)); + } + + /// True when at least one calendar year lies entirely inside the range. + public static bool CoversFullYear(DateTime start, DateTime end) + { + var last = InclusiveEnd(start, end).Date; + + for (var year = start.Year; year <= last.Year; year++) + { + if (start.Date <= new DateTime(year, 1, 1) && last >= new DateTime(year, 12, 31)) + return true; + } + + return false; + } + + /// Every calendar period of the granularity the range touches, in order. + public static List Split(DateTime start, DateTime end, ReportPeriodGranularity granularity) + { + var periods = new List(); + var last = InclusiveEnd(start, end); + + var periodStart = PeriodStart(start, granularity); + while (periodStart <= last) + { + var periodEnd = granularity == ReportPeriodGranularity.Month ? periodStart.AddMonths(1) + : granularity == ReportPeriodGranularity.Quarter ? periodStart.AddMonths(3) + : periodStart.AddYears(1); + + periods.Add(new ReportPeriod + { + Granularity = granularity, + Start = periodStart, + End = periodEnd, + Year = periodStart.Year, + Number = granularity == ReportPeriodGranularity.Month ? periodStart.Month + : granularity == ReportPeriodGranularity.Quarter ? ((periodStart.Month - 1) / 3) + 1 + : periodStart.Year, + IsPartial = start.Date > periodStart || last.Date < periodEnd.AddDays(-1) + }); + + periodStart = periodEnd; + } + + return periods; + } + + private static DateTime PeriodStart(DateTime value, ReportPeriodGranularity granularity) + { + switch (granularity) + { + case ReportPeriodGranularity.Month: + return new DateTime(value.Year, value.Month, 1); + case ReportPeriodGranularity.Quarter: + return new DateTime(value.Year, (((value.Month - 1) / 3) * 3) + 1, 1); + default: + return new DateTime(value.Year, 1, 1); + } + } + + private static int MonthIndex(DateTime value) => (value.Year * 12) + value.Month - 1; + + private static int QuarterIndex(DateTime value) => (value.Year * 4) + ((value.Month - 1) / 3); + } +} diff --git a/Core/Resgrid.Model/Services/ICallGroupAssignmentService.cs b/Core/Resgrid.Model/Services/ICallGroupAssignmentService.cs new file mode 100644 index 000000000..352ef8497 --- /dev/null +++ b/Core/Resgrid.Model/Services/ICallGroupAssignmentService.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Resgrid.Model.Reporting; + +namespace Resgrid.Model.Services +{ + /// + /// Places calls in department groups for reporting: by the group geofence holding the call's location, else by the + /// plurality of what was dispatched to it. See for the rules. + /// + public interface ICallGroupAssignmentService + { + /// + /// Each call's group, keyed by call id. Reads the department's groups, units and the dispatches of the calls' period + /// once, not per call. + /// + /// Calls from any other department are left out of the result. + /// The calls to place. + /// + /// Optional, by call id: the units and personnel that set a status on the call. Only consulted for a call where + /// nothing dispatched belongs to a group. + /// + Task> AssignCallsToGroupsAsync(int departmentId, IReadOnlyCollection calls, + IReadOnlyDictionary responders = null); + } +} diff --git a/Core/Resgrid.Model/UnitStatusColors.cs b/Core/Resgrid.Model/UnitStatusColors.cs new file mode 100644 index 000000000..c73b48a09 --- /dev/null +++ b/Core/Resgrid.Model/UnitStatusColors.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Resgrid.Model +{ + /// + /// A unit status's button and text colours as the department set them up, for the boards that list units with their + /// status (the nearest-unit board, run card recommendations), so a status reads the same there as everywhere else. + /// + public static class UnitStatusColors + { + private static readonly Regex HexColor = new Regex("^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$", RegexOptions.Compiled); + + /// The built-in unit status options (ICustomStateService.GetDefaultUnitStatuses) by state id. + public static Dictionary BuildDefaultMap(IEnumerable defaultStatuses) + { + return (defaultStatuses ?? Enumerable.Empty()) + .Where(d => d != null) + .GroupBy(d => d.CustomStateDetailId) + .ToDictionary(g => g.Key, g => g.First()); + } + + /// + /// The colours of the option the unit's state was set from: the department's own option, else the built-in one. + /// Each is a hex colour or null: an old built-in state that no longer has an option has neither, and an older + /// option can hold a CSS class name instead of a colour, which is passed on only when it maps to one. + /// + public static (string Color, string TextColor) Resolve(int stateId, IReadOnlyDictionary customDetails, + IReadOnlyDictionary defaultDetails) + { + CustomStateDetail detail = null; + + if (customDetails == null || !customDetails.TryGetValue(stateId, out detail) || detail == null) + { + if (defaultDetails == null || !defaultDetails.TryGetValue(stateId, out detail)) + detail = null; + } + + if (detail == null) + return (null, null); + + return (ToHexColor(detail.ButtonClassToColor()), ToHexColor(detail.TextColor)); + } + + /// The colour as "#rgb", "#rgba", "#rrggbb" or "#rrggbbaa", else null. + public static string ToHexColor(string color) + { + if (string.IsNullOrWhiteSpace(color)) + return null; + + color = color.Trim(); + + return HexColor.IsMatch(color) ? color : null; + } + } +} diff --git a/Core/Resgrid.Services/CallGroupAssignmentService.cs b/Core/Resgrid.Services/CallGroupAssignmentService.cs new file mode 100644 index 000000000..8d7a4da48 --- /dev/null +++ b/Core/Resgrid.Services/CallGroupAssignmentService.cs @@ -0,0 +1,78 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Reporting; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + public class CallGroupAssignmentService : ICallGroupAssignmentService + { + private readonly IDepartmentGroupsService _departmentGroupsService; + private readonly IUnitsService _unitsService; + private readonly ICallDispatchesRepository _callDispatchesRepository; + private readonly ICallDispatchGroupRepository _callDispatchGroupRepository; + private readonly ICallDispatchUnitRepository _callDispatchUnitRepository; + + public CallGroupAssignmentService(IDepartmentGroupsService departmentGroupsService, IUnitsService unitsService, + ICallDispatchesRepository callDispatchesRepository, ICallDispatchGroupRepository callDispatchGroupRepository, + ICallDispatchUnitRepository callDispatchUnitRepository) + { + _departmentGroupsService = departmentGroupsService; + _unitsService = unitsService; + _callDispatchesRepository = callDispatchesRepository; + _callDispatchGroupRepository = callDispatchGroupRepository; + _callDispatchUnitRepository = callDispatchUnitRepository; + } + + public async Task> AssignCallsToGroupsAsync(int departmentId, IReadOnlyCollection calls, + IReadOnlyDictionary responders = null) + { + var result = new Dictionary(); + var departmentCalls = calls?.Where(x => x != null && x.DepartmentId == departmentId) + .GroupBy(x => x.CallId).Select(g => g.First()).ToList() ?? new List(); + if (departmentCalls.Count == 0) + return result; + + var groups = await _departmentGroupsService.GetAllGroupsForDepartmentUnlimitedAsync(departmentId) ?? new List(); + // Past calls: the units that worked them, deleted ones included. + var units = await _unitsService.GetUnitsForDepartmentIncludingDeletedAsync(departmentId) ?? new List(); + var assigner = new CallGroupAssigner(groups, units); + + var dispatched = departmentCalls.ToDictionary(x => x.CallId, x => new CallGroupResources()); + var loggedFrom = departmentCalls.Min(x => x.LoggedOn); + var loggedTo = departmentCalls.Max(x => x.LoggedOn); + + foreach (var dispatch in await _callDispatchesRepository.GetCallDispatchesForCallsInRangeAsync(departmentId, loggedFrom, loggedTo) ?? Enumerable.Empty()) + { + if (dispatch != null && !string.IsNullOrWhiteSpace(dispatch.UserId) && dispatched.TryGetValue(dispatch.CallId, out var resources)) + resources.UserIds.Add(dispatch.UserId); + } + + foreach (var dispatch in await _callDispatchGroupRepository.GetCallDispatchGroupsForCallsInRangeAsync(departmentId, loggedFrom, loggedTo) ?? Enumerable.Empty()) + { + if (dispatch != null && dispatched.TryGetValue(dispatch.CallId, out var resources)) + resources.GroupIds.Add(dispatch.DepartmentGroupId); + } + + foreach (var dispatch in await _callDispatchUnitRepository.GetCallUnitDispatchesForCallsInRangeAsync(departmentId, loggedFrom, loggedTo) ?? Enumerable.Empty()) + { + if (dispatch != null && dispatched.TryGetValue(dispatch.CallId, out var resources)) + resources.UnitIds.Add(dispatch.UnitId); + } + + foreach (var call in departmentCalls) + { + CallGroupResources responded = null; + responders?.TryGetValue(call.CallId, out responded); + + result[call.CallId] = assigner.Assign(call.CallId, call.GeoLocationData, dispatched[call.CallId], responded); + } + + return result; + } + } +} diff --git a/Core/Resgrid.Services/CheckInTimerService.cs b/Core/Resgrid.Services/CheckInTimerService.cs index a8b011f34..6373f2776 100644 --- a/Core/Resgrid.Services/CheckInTimerService.cs +++ b/Core/Resgrid.Services/CheckInTimerService.cs @@ -752,9 +752,17 @@ public async Task> GetCallPersonnelCheckInStatu var now = DateTime.UtcNow; var statuses = new List(); - foreach (var dispatch in dispatches) + // A member can hold more than one CallDispatch row on a call (re-dispatched, dispatched + // directly and via a group, etc.). PAR is per person, so emit exactly one row per user — + // duplicates would double-count PAR and double-fire Critical PAR alerts. + var dispatchedUserIds = dispatches + .Where(d => !string.IsNullOrWhiteSpace(d.UserId)) + .Select(d => d.UserId) + .Distinct(); + + foreach (var userId in dispatchedUserIds) { - latestByUser.TryGetValue(dispatch.UserId, out var lastRecord); + latestByUser.TryGetValue(userId, out var lastRecord); var baseTime = lastRecord?.Timestamp ?? call.LoggedOn; var elapsed = (now - baseTime).TotalMinutes; @@ -770,7 +778,7 @@ public async Task> GetCallPersonnelCheckInStatu statuses.Add(new PersonnelCallCheckInStatus { - UserId = dispatch.UserId, + UserId = userId, // FullName is intentionally left null here; the controller enriches // it from IUserProfileService to avoid pulling that dependency into // this service. diff --git a/Core/Resgrid.Services/DispatchRecommendationService.cs b/Core/Resgrid.Services/DispatchRecommendationService.cs index 94b43558a..58acacb04 100644 --- a/Core/Resgrid.Services/DispatchRecommendationService.cs +++ b/Core/Resgrid.Services/DispatchRecommendationService.cs @@ -285,6 +285,8 @@ private sealed class UnitCandidate public int UnitTypeId { get; set; } public string UnitTypeName { get; set; } public string StatusText { get; set; } + public string StatusColor { get; set; } + public string StatusTextColor { get; set; } public int? StaffingLevel { get; set; } public bool InRestPeriod { get; set; } /// The unit's current status is a custom status with the In Quarters base type. @@ -355,6 +357,7 @@ private async Task BuildUnitCandidatesAsync(RecommendationContext context) var stateByUnit = states.GroupBy(s => s.UnitId).ToDictionary(g => g.Key, g => g.OrderByDescending(s => s.Timestamp).First()); var typeByName = BuildUnitTypeLookup(unitTypes); var customDetails = BuildCustomDetailMap(customStates); + var defaultDetails = UnitStatusColors.BuildDefaultMap(_customStateService.GetDefaultUnitStatuses()); foreach (var unitType in unitTypes.Where(t => t != null)) context.UnitTypeNames[unitType.UnitTypeId] = unitType.Type; @@ -406,12 +409,16 @@ private async Task BuildUnitCandidatesAsync(RecommendationContext context) } } + var statusColors = UnitStatusColors.Resolve(stateId, customDetails, defaultDetails); + context.UnitCandidates.Add(new UnitCandidate { Unit = unit, UnitTypeId = unitType.UnitTypeId, UnitTypeName = unitType.Type, StatusText = GetUnitStatusText(stateId, isCustom, customDetails), + StatusColor = statusColors.Color, + StatusTextColor = statusColors.TextColor, StaffingLevel = staffingLevel, InQuarters = UnitResponseOrigin.IsInQuarters(stateId, customDetails) }); @@ -858,6 +865,8 @@ private void FillUnitRequirementFromStations(RecommendationContext context, RunC CascadeDepth = depth, DistanceMeters = station.DistanceMeters, CurrentStatusText = candidate.StatusText, + CurrentStatusColor = candidate.StatusColor, + CurrentStatusTextColor = candidate.StatusTextColor, StaffingLevel = candidate.StaffingLevel, SatisfiesRequirementId = requirement.RunCardUnitRequirementId }); @@ -1166,6 +1175,8 @@ private async Task FillUnitRequirementByProximityAsync(RecommendationContext con LocationTimestamp = entry.Candidate.LocationTimestamp, LocationIsStale = entry.Candidate.LocationIsStale, CurrentStatusText = entry.Candidate.StatusText, + CurrentStatusColor = entry.Candidate.StatusColor, + CurrentStatusTextColor = entry.Candidate.StatusTextColor, StaffingLevel = entry.Candidate.StaffingLevel, SatisfiesRequirementId = requirement.RunCardUnitRequirementId }); diff --git a/Core/Resgrid.Services/NearestUnitService.cs b/Core/Resgrid.Services/NearestUnitService.cs index 435351ca0..a31d641f4 100644 --- a/Core/Resgrid.Services/NearestUnitService.cs +++ b/Core/Resgrid.Services/NearestUnitService.cs @@ -174,6 +174,10 @@ private sealed class UnitCandidate : Candidate public string StatusText { get; set; } + public string StatusColor { get; set; } + + public string StatusTextColor { get; set; } + public DateTime? PositionTimestamp { get; set; } public UnitPositionSources PositionSource { get; set; } @@ -211,6 +215,8 @@ public NearestUnitResult ToResult(List groups, Dictionary> BuildUnitCandidatesAsync(NearestUnitRequ var states = await _unitsService.GetAllLatestStatusForUnitsByDepartmentIdAsync(departmentId) ?? new List(); var customDetails = BuildCustomDetailMap(await _customStateService.GetAllActiveUnitStatesForDepartmentAsync(departmentId)); + var defaultDetails = UnitStatusColors.BuildDefaultMap(_customStateService.GetDefaultUnitStatuses()); var locations = await _unitsService.GetLatestUnitLocationsAsync(departmentId) ?? new List(); var stateByUnit = states.Where(s => s != null).GroupBy(s => s.UnitId).ToDictionary(g => g.Key, g => g.OrderByDescending(s => s.Timestamp).First()); @@ -427,11 +434,14 @@ private async Task> BuildUnitCandidatesAsync(NearestUnitRequ var stateId = stateByUnit.TryGetValue(unit.UnitId, out var state) ? state.State : (int)UnitStateTypes.Available; var isCustom = customDetails.ContainsKey(stateId); + var statusColors = UnitStatusColors.Resolve(stateId, customDetails, defaultDetails); var candidate = new UnitCandidate { Unit = unit, StatusText = GetUnitStatusText(stateId, isCustom, customDetails), + StatusColor = statusColors.Color, + StatusTextColor = statusColors.TextColor, IsAvailable = IsUnitAvailable(stateId, isCustom, customDetails) }; diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index 5ca41fcbf..84c7fc9ea 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -208,6 +208,7 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Tests/Resgrid.Tests/Models/UnitStatusColorsTests.cs b/Tests/Resgrid.Tests/Models/UnitStatusColorsTests.cs new file mode 100644 index 000000000..8b8df9b0a --- /dev/null +++ b/Tests/Resgrid.Tests/Models/UnitStatusColorsTests.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; + +namespace Resgrid.Tests.Models +{ + /// + /// A unit status's colours on the boards that list units with their status (nearest units, run card + /// recommendations): the department's own option first, then the built-in one, hex colours only. + /// + [TestFixture] + public class UnitStatusColorsTests + { + private static readonly Dictionary Custom = new Dictionary + { + { 900, new CustomStateDetail { CustomStateDetailId = 900, ButtonText = "Standplaats", ButtonColor = "#FF0000", TextColor = "#000000" } }, + { 901, new CustomStateDetail { CustomStateDetailId = 901, ButtonText = "Legacy", ButtonColor = "label-warning", TextColor = null } }, + { 902, new CustomStateDetail { CustomStateDetailId = 902, ButtonText = "Odd", ButtonColor = "label-primary", TextColor = "red;display:none" } } + }; + + private static readonly Dictionary Defaults = UnitStatusColors.BuildDefaultMap(new[] + { + new CustomStateDetail { CustomStateDetailId = (int)UnitStateTypes.Available, ButtonColor = "#d1dade", TextColor = "#5E5E5E" }, + null, + new CustomStateDetail { CustomStateDetailId = (int)UnitStateTypes.Available, ButtonColor = "#000000", TextColor = "#ffffff" } + }); + + [Test] + public void a_custom_status_uses_its_own_colours() + { + UnitStatusColors.Resolve(900, Custom, Defaults).Should().Be(("#FF0000", "#000000")); + } + + [Test] + public void an_older_label_class_maps_to_its_colour_when_it_has_one() + { + UnitStatusColors.Resolve(901, Custom, Defaults).Should().Be(("#f0ad4e", (string)null)); + UnitStatusColors.Resolve(902, Custom, Defaults).Should().Be(((string)null, (string)null)); + } + + [Test] + public void a_built_in_status_uses_the_built_in_colours_first_one_wins() + { + UnitStatusColors.Resolve((int)UnitStateTypes.Available, Custom, Defaults).Should().Be(("#d1dade", "#5E5E5E")); + } + + [Test] + public void a_status_with_no_option_has_no_colours() + { + UnitStatusColors.Resolve((int)UnitStateTypes.Delayed, Custom, Defaults).Should().Be(((string)null, (string)null)); + UnitStatusColors.Resolve(900, null, null).Should().Be(((string)null, (string)null)); + } + + [TestCase(" #abc ", "#abc")] + [TestCase("#aabbccdd", "#aabbccdd")] + [TestCase("#ggg", null)] + [TestCase("#12345", null)] + [TestCase("red", null)] + [TestCase("", null)] + [TestCase(null, null)] + public void only_hex_colours_are_passed_on(string color, string expected) + { + UnitStatusColors.ToHexColor(color).Should().Be(expected); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/CallGroupAssignerTests.cs b/Tests/Resgrid.Tests/Services/CallGroupAssignerTests.cs new file mode 100644 index 000000000..5076c9b69 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/CallGroupAssignerTests.cs @@ -0,0 +1,206 @@ +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Reporting; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class CallGroupAssignerTests + { + // Two side-by-side station boxes: Station 1 west of -104.9, Station 2 east of it. + private const string Station1Fence = "[{\"lat\":39.70,\"lng\":-105.00},{\"lat\":39.80,\"lng\":-105.00},{\"lat\":39.80,\"lng\":-104.90},{\"lat\":39.70,\"lng\":-104.90}]"; + private const string Station2Fence = "[{\"lat\":39.70,\"lng\":-104.90},{\"lat\":39.80,\"lng\":-104.90},{\"lat\":39.80,\"lng\":-104.80},{\"lat\":39.70,\"lng\":-104.80}]"; + // A battalion boundary covering both stations. + private const string BattalionFence = "[{\"lat\":39.60,\"lng\":-105.10},{\"lat\":39.90,\"lng\":-105.10},{\"lat\":39.90,\"lng\":-104.70},{\"lat\":39.60,\"lng\":-104.70}]"; + + private const string InStation1 = "39.75,-104.95"; + private const string InStation2 = "39.75,-104.85"; + private const string InBattalionOnly = "39.65,-105.05"; + private const string OutsideEverything = "40.50,-104.00"; + + private static DepartmentGroup Group(int id, string fence = null, int? parentId = null, DepartmentGroupTypes type = DepartmentGroupTypes.Station, + string latitude = null, string longitude = null, params string[] members) + { + return new DepartmentGroup + { + DepartmentGroupId = id, + DepartmentId = 1, + Name = "Group " + id, + Type = (int)type, + Geofence = fence, + ParentDepartmentGroupId = parentId, + Latitude = latitude, + Longitude = longitude, + Members = members.Select(m => new DepartmentGroupMember { DepartmentGroupId = id, UserId = m }).ToList() + }; + } + + private static Unit Unit(int id, int? stationGroupId) => new Unit { UnitId = id, DepartmentId = 1, StationGroupId = stationGroupId }; + + private static CallGroupResources Resources(IEnumerable units = null, IEnumerable users = null, IEnumerable groups = null) + { + var resources = new CallGroupResources(); + resources.UnitIds.UnionWith(units ?? Enumerable.Empty()); + resources.UserIds.UnionWith(users ?? Enumerable.Empty()); + resources.GroupIds.UnionWith(groups ?? Enumerable.Empty()); + return resources; + } + + [Test] + public void call_inside_a_geofence_goes_to_that_group_even_when_another_group_was_dispatched() + { + var assigner = new CallGroupAssigner(new[] { Group(1, Station1Fence), Group(2, Station2Fence) }, new[] { Unit(20, 2), Unit(21, 2) }); + + var result = assigner.Assign(100, InStation1, Resources(units: new[] { 20, 21 })); + + result.CallId.Should().Be(100); + result.DepartmentGroupId.Should().Be(1); + result.Method.Should().Be(CallGroupAssignmentMethods.Geofence); + } + + [Test] + public void nested_boundaries_resolve_to_the_innermost_group() + { + var groups = new[] { Group(10, BattalionFence, type: DepartmentGroupTypes.Orginizational), Group(1, Station1Fence, 10), Group(2, Station2Fence, 10) }; + var assigner = new CallGroupAssigner(groups, Enumerable.Empty()); + + assigner.Assign(1, InStation2, null).DepartmentGroupId.Should().Be(2); + assigner.Assign(2, InBattalionOnly, null).DepartmentGroupId.Should().Be(10); + } + + [Test] + public void overlapping_unrelated_boundaries_prefer_the_station_then_the_smaller_area() + { + // The organizational boundary is listed first and is not the station's parent. + var orgOverlap = new CallGroupAssigner(new[] { Group(10, BattalionFence, type: DepartmentGroupTypes.Orginizational), Group(1, Station1Fence) }, null); + orgOverlap.Assign(1, InStation1, null).DepartmentGroupId.Should().Be(1); + + var stationOverlap = new CallGroupAssigner(new[] { Group(10, BattalionFence), Group(1, Station1Fence) }, null); + stationOverlap.Assign(1, InStation1, null).DepartmentGroupId.Should().Be(1); + } + + [Test] + public void call_without_a_location_goes_to_the_plurality_of_dispatched_units_and_personnel() + { + var groups = new[] { Group(1, Station1Fence, members: new[] { "a", "b" }), Group(2, members: new[] { "c" }) }; + var units = new[] { Unit(10, 1), Unit(20, 2), Unit(21, 2) }; + var assigner = new CallGroupAssigner(groups, units); + + // Group 1: unit 10 + users a, b = 3 votes. Group 2: units 20, 21 + user c = 3 votes, but more unit votes. + var tiedOnTotal = assigner.Assign(1, null, Resources(new[] { 10, 20, 21 }, new[] { "a", "b", "c" })); + tiedOnTotal.DepartmentGroupId.Should().Be(2); + tiedOnTotal.Method.Should().Be(CallGroupAssignmentMethods.Dispatch); + + var plurality = assigner.Assign(2, "", Resources(new[] { 10 }, new[] { "a", "b", "c" })); + plurality.DepartmentGroupId.Should().Be(1); + plurality.Method.Should().Be(CallGroupAssignmentMethods.Dispatch); + } + + [Test] + public void call_outside_every_boundary_falls_back_to_the_dispatch_plurality() + { + var assigner = new CallGroupAssigner(new[] { Group(1, Station1Fence), Group(2, Station2Fence) }, new[] { Unit(20, 2) }); + + var result = assigner.Assign(1, OutsideEverything, Resources(units: new[] { 20 })); + + result.DepartmentGroupId.Should().Be(2); + result.Method.Should().Be(CallGroupAssignmentMethods.Dispatch); + } + + [Test] + public void a_dispatched_group_counts_through_its_members_or_once_when_it_has_none() + { + var groups = new[] { Group(1, members: new[] { "a", "b", "c" }), Group(2, members: new[] { "d" }), Group(3) }; + var assigner = new CallGroupAssigner(groups, null); + + // Dispatching group 1 votes its three members; user d is one vote for group 2. + assigner.Assign(1, null, Resources(users: new[] { "d" }, groups: new[] { 1 })).DepartmentGroupId.Should().Be(1); + + // A member dispatched directly and through their group still votes once. + assigner.Assign(2, null, Resources(users: new[] { "a", "d" }, groups: new[] { 2 })).DepartmentGroupId.Should().BeNull(); + + // An empty group dispatched alone still says where the call is. + assigner.Assign(3, null, Resources(groups: new[] { 3 })).DepartmentGroupId.Should().Be(3); + } + + [Test] + public void user_ids_match_regardless_of_case() + { + var assigner = new CallGroupAssigner(new[] { Group(1, members: new[] { "ABC-123" }) }, null); + + assigner.Assign(1, null, Resources(users: new[] { "abc-123" })).DepartmentGroupId.Should().Be(1); + } + + [Test] + public void a_tie_is_broken_by_the_nearest_station_when_every_tied_group_has_a_location() + { + var groups = new[] + { + Group(1, latitude: "39.75", longitude: "-104.95", members: new[] { "a" }), + Group(2, latitude: "39.75", longitude: "-104.60", members: new[] { "b" }) + }; + var assigner = new CallGroupAssigner(groups, null); + + assigner.Assign(1, "39.76,-104.94", Resources(users: new[] { "a", "b" })).DepartmentGroupId.Should().Be(1); + assigner.Assign(2, "39.76,-104.61", Resources(users: new[] { "a", "b" })).DepartmentGroupId.Should().Be(2); + } + + [Test] + public void a_tie_that_cannot_be_broken_leaves_the_call_unassigned() + { + var groups = new[] { Group(1, latitude: "39.75", longitude: "-104.95", members: new[] { "a" }), Group(2, members: new[] { "b" }) }; + var assigner = new CallGroupAssigner(groups, null); + + // No call location, and group 2 has no station location to measure from. + var noLocation = assigner.Assign(1, null, Resources(users: new[] { "a", "b" })); + noLocation.DepartmentGroupId.Should().BeNull(); + noLocation.Method.Should().Be(CallGroupAssignmentMethods.None); + + assigner.Assign(2, "39.76,-104.94", Resources(users: new[] { "a", "b" })).DepartmentGroupId.Should().BeNull(); + } + + [Test] + public void responders_vote_only_when_nothing_dispatched_has_a_group() + { + var groups = new[] { Group(1, members: new[] { "a" }), Group(2, members: new[] { "b", "c" }) }; + var units = new[] { Unit(10, 1), Unit(99, null) }; + var assigner = new CallGroupAssigner(groups, units); + + var dispatchWins = assigner.Assign(1, null, Resources(units: new[] { 10 }), Resources(users: new[] { "b", "c" })); + dispatchWins.DepartmentGroupId.Should().Be(1); + dispatchWins.Method.Should().Be(CallGroupAssignmentMethods.Dispatch); + + // Unit 99 has no station, so the dispatch casts no vote and the responders decide. + var responders = assigner.Assign(2, null, Resources(units: new[] { 99 }), Resources(users: new[] { "b", "c" })); + responders.DepartmentGroupId.Should().Be(2); + responders.Method.Should().Be(CallGroupAssignmentMethods.Response); + } + + [Test] + public void nothing_with_a_group_leaves_the_call_unassigned() + { + var assigner = new CallGroupAssigner(new[] { Group(1, Station1Fence) }, new[] { Unit(10, null) }); + + var result = assigner.Assign(1, OutsideEverything, Resources(units: new[] { 10 }, users: new[] { "nobody" }), new CallGroupResources()); + + result.DepartmentGroupId.Should().BeNull(); + result.Method.Should().Be(CallGroupAssignmentMethods.None); + } + + [Test] + public void unusable_geofences_and_units_of_unknown_groups_are_ignored() + { + var groups = new[] { Group(1, "not json", members: new[] { "a" }) }; + // Unit 20 points at a group from another department (not in the list). + var assigner = new CallGroupAssigner(groups, new[] { Unit(20, 555), Unit(21, 555) }); + + var result = assigner.Assign(1, InStation1, Resources(units: new[] { 20, 21 }, users: new[] { "a" })); + + result.DepartmentGroupId.Should().Be(1); + result.Method.Should().Be(CallGroupAssignmentMethods.Dispatch); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs b/Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs index 9fe86a0cf..a0c0d5d3f 100644 --- a/Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs @@ -531,6 +531,39 @@ public async Task ResolveAllTimersForCallAsync_NullActiveForStates_IsPreserved() #endregion ActiveForStates Propagation + #region Call Personnel Check-In Statuses + + [Test] + public async Task GetCallPersonnelCheckInStatusesAsync_ReturnsOneRowPerUser_WhenUserHasMultipleDispatches() + { + var call = new Call + { + CallId = 1, DepartmentId = 10, State = (int)CallStates.Active, CheckInTimersEnabled = true, + LoggedOn = DateTime.UtcNow.AddMinutes(-5), + Dispatches = new List + { + new CallDispatch { UserId = "user1" }, + new CallDispatch { UserId = "user2" }, + new CallDispatch { UserId = "user1" } + }, + UnitDispatches = new List() + }; + var configs = new List + { + new CheckInTimerConfig { TimerTargetType = 0, DurationMinutes = 30, WarningThresholdMinutes = 5, IsEnabled = true } + }; + _configRepo.Setup(x => x.GetByDepartmentIdAsync(10)).ReturnsAsync(configs); + _overrideRepo.Setup(x => x.GetMatchingOverridesAsync(10, null, 0)).ReturnsAsync(new List()); + _recordRepo.Setup(x => x.GetByCallIdAsync(1)).ReturnsAsync(new List()); + + var result = await _service.GetCallPersonnelCheckInStatusesAsync(call); + + result.Should().HaveCount(2); + result.Select(s => s.UserId).Should().BeEquivalentTo(new[] { "user1", "user2" }); + } + + #endregion Call Personnel Check-In Statuses + #region State Filtering [Test] diff --git a/Tests/Resgrid.Tests/Services/DispatchRecommendationServiceTests.cs b/Tests/Resgrid.Tests/Services/DispatchRecommendationServiceTests.cs index c94359c04..8831bda29 100644 --- a/Tests/Resgrid.Tests/Services/DispatchRecommendationServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/DispatchRecommendationServiceTests.cs @@ -507,6 +507,56 @@ public async Task closest_unit_measures_an_in_quarters_unit_from_its_station_not engine.CurrentStatusText.Should().Be("In Kazerne"); } + [Test] + public async Task closest_unit_carries_the_status_colours_the_department_set_up() + { + BuildCard(engineCount: 1); + _departmentSettingsService.Setup(x => x.GetDispatchRecommendationModeAsync(DepartmentId, It.IsAny())) + .ReturnsAsync(DispatchRecommendationModes.ClosestUnit); + + _customStateService.Setup(x => x.GetAllActiveUnitStatesForDepartmentAsync(DepartmentId)) + .ReturnsAsync(new List + { + new CustomState + { + CustomStateId = 90, + DepartmentId = DepartmentId, + Type = (int)CustomStateTypes.Unit, + Details = new List + { + new CustomStateDetail { CustomStateDetailId = 900, CustomStateId = 90, ButtonText = "Standplaats", ButtonColor = "#FF0000", TextColor = "#000000", BaseType = (int)ActionBaseTypes.InQuarters } + } + } + }); + _unitsService.Setup(x => x.GetAllLatestStatusForUnitsByDepartmentIdAsync(DepartmentId)) + .ReturnsAsync(new List + { + new UnitState { UnitId = 1, State = 900, Timestamp = DateTime.UtcNow } + }); + + var result = await _service.GetRecommendationAsync(BuildRequest()); + + var engine = result.Units.Should().ContainSingle().Subject; + engine.CurrentStatusText.Should().Be("Standplaats"); + engine.CurrentStatusColor.Should().Be("#FF0000"); + engine.CurrentStatusTextColor.Should().Be("#000000"); + } + + [Test] + public async Task station_based_units_on_a_built_in_status_carry_the_built_in_colours() + { + BuildCard(engineCount: 2); + _customStateService.Setup(x => x.GetDefaultUnitStatuses()).Returns(new List + { + new CustomStateDetail { CustomStateDetailId = (int)UnitStateTypes.Available, ButtonText = "Available", ButtonColor = "#d1dade", TextColor = "#5E5E5E" } + }); + + var result = await _service.GetRecommendationAsync(BuildRequest()); + + result.Units.Should().HaveCount(2); + result.Units.Should().OnlyContain(u => u.CurrentStatusColor == "#d1dade" && u.CurrentStatusTextColor == "#5E5E5E"); + } + [Test] public async Task closest_unit_ranks_on_turnout_plus_travel() { diff --git a/Tests/Resgrid.Tests/Services/NearestUnitServiceTests.cs b/Tests/Resgrid.Tests/Services/NearestUnitServiceTests.cs index 315817eaa..198108976 100644 --- a/Tests/Resgrid.Tests/Services/NearestUnitServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/NearestUnitServiceTests.cs @@ -299,6 +299,75 @@ public async Task an_in_quarters_unit_is_placed_at_its_station_whatever_its_gps_ team.PositionIsStale.Should().BeFalse(); } + [Test] + public async Task a_unit_row_carries_its_status_colours_as_the_department_set_them_up() + { + _customStateService.Setup(x => x.GetAllActiveUnitStatesForDepartmentAsync(DepartmentId)).ReturnsAsync(new List + { + new CustomState + { + CustomStateId = 90, + DepartmentId = DepartmentId, + Type = (int)CustomStateTypes.Unit, + Details = new List + { + new CustomStateDetail { CustomStateDetailId = 900, CustomStateId = 90, ButtonText = "Standplaats", ButtonColor = "#FF0000", TextColor = "#000000", BaseType = (int)ActionBaseTypes.InQuarters }, + new CustomStateDetail { CustomStateDetailId = 901, CustomStateId = 90, ButtonText = "Radiofonisch", ButtonColor = " #0000ff ", TextColor = "#000000" }, + new CustomStateDetail { CustomStateDetailId = 902, CustomStateId = 90, ButtonText = "Legacy", ButtonColor = "label-danger", TextColor = "" }, + new CustomStateDetail { CustomStateDetailId = 903, CustomStateId = 90, ButtonText = "Odd", ButtonColor = "label-primary", TextColor = "red;display:none" } + } + } + }); + _unitsService.Setup(x => x.GetAllLatestStatusForUnitsByDepartmentIdAsync(DepartmentId)).ReturnsAsync(new List + { + new UnitState { UnitId = TeamUnit, State = 900, Timestamp = DateTime.UtcNow }, + new UnitState { UnitId = FarTeamUnit, State = 901, Timestamp = DateTime.UtcNow }, + new UnitState { UnitId = ApparatusUnit, State = 902, Timestamp = DateTime.UtcNow }, + new UnitState { UnitId = IndividualUnit, State = 903, Timestamp = DateTime.UtcNow } + }); + + var board = await GetBoardAsync(); + + UnitRow(board, TeamUnit).StatusColor.Should().Be("#FF0000"); + UnitRow(board, TeamUnit).StatusTextColor.Should().Be("#000000"); + UnitRow(board, FarTeamUnit).StatusColor.Should().Be("#0000ff"); + UnitRow(board, ApparatusUnit).StatusColor.Should().Be("#ff0000", "an older option's label class maps to its colour"); + UnitRow(board, ApparatusUnit).StatusTextColor.Should().BeNull(); + UnitRow(board, IndividualUnit).StatusColor.Should().BeNull("a class with no known colour is left to the page"); + UnitRow(board, IndividualUnit).StatusTextColor.Should().BeNull("only a hex colour is passed on"); + } + + [Test] + public async Task a_unit_on_a_built_in_status_takes_the_built_in_colours() + { + _customStateService.Setup(x => x.GetDefaultUnitStatuses()).Returns(new List + { + new CustomStateDetail { CustomStateDetailId = (int)UnitStateTypes.Available, ButtonText = "Available", ButtonColor = "#d1dade", TextColor = "#5E5E5E" }, + new CustomStateDetail { CustomStateDetailId = (int)UnitStateTypes.OnScene, ButtonText = "On Scene", ButtonColor = "#69BB7B", TextColor = "#ffffff" } + }); + + var board = await GetBoardAsync(); + + UnitRow(board, TeamUnit).StatusColor.Should().Be("#d1dade"); + UnitRow(board, TeamUnit).StatusTextColor.Should().Be("#5E5E5E"); + UnitRow(board, ApparatusUnit).StatusColor.Should().Be("#69BB7B"); + UnitRow(board, ApparatusUnit).StatusTextColor.Should().Be("#ffffff"); + } + + [Test] + public async Task a_built_in_status_with_no_option_has_no_colours() + { + _unitsService.Setup(x => x.GetAllLatestStatusForUnitsByDepartmentIdAsync(DepartmentId)).ReturnsAsync(new List + { + new UnitState { UnitId = TeamUnit, State = (int)UnitStateTypes.Delayed, Timestamp = DateTime.UtcNow } + }); + + var team = UnitRow(await GetBoardAsync(), TeamUnit); + + team.StatusColor.Should().BeNull(); + team.StatusTextColor.Should().BeNull(); + } + [Test] public async Task a_stale_fix_is_flagged_on_the_row_not_ranked_below_fresh_units() { diff --git a/Tests/Resgrid.Tests/Services/ReportPeriodsTests.cs b/Tests/Resgrid.Tests/Services/ReportPeriodsTests.cs new file mode 100644 index 000000000..168b7441b --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ReportPeriodsTests.cs @@ -0,0 +1,99 @@ +using System; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model.Reporting; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class ReportPeriodsTests + { + // The report pickers' default: the whole current year, a second inside each end. + private static readonly DateTime YearStart = new DateTime(2026, 1, 1, 0, 0, 1); + private static readonly DateTime YearEnd = new DateTime(2026, 12, 31, 23, 59, 59); + + [Test] + public void the_default_year_range_crosses_months_and_quarters_and_covers_the_year() + { + ReportPeriods.CrossesMonth(YearStart, YearEnd).Should().BeTrue(); + ReportPeriods.CrossesQuarter(YearStart, YearEnd).Should().BeTrue(); + ReportPeriods.CoversFullYear(YearStart, YearEnd).Should().BeTrue(); + } + + [Test] + public void a_range_inside_one_month_has_no_breakdowns() + { + var start = new DateTime(2026, 3, 2, 8, 0, 0); + var end = new DateTime(2026, 3, 30, 17, 0, 0); + + ReportPeriods.CrossesMonth(start, end).Should().BeFalse(); + ReportPeriods.CrossesQuarter(start, end).Should().BeFalse(); + ReportPeriods.CoversFullYear(start, end).Should().BeFalse(); + } + + [Test] + public void a_range_ending_at_midnight_on_the_first_does_not_touch_the_next_month() + { + var start = new DateTime(2026, 3, 1); + var end = new DateTime(2026, 4, 1); + + ReportPeriods.CrossesMonth(start, end).Should().BeFalse(); + ReportPeriods.Split(start, end, ReportPeriodGranularity.Month).Should().ContainSingle() + .Which.IsPartial.Should().BeFalse(); + } + + [Test] + public void months_inside_one_quarter_cross_months_but_not_quarters() + { + var start = new DateTime(2026, 4, 1); + var end = new DateTime(2026, 6, 30, 23, 59, 59); + + ReportPeriods.CrossesMonth(start, end).Should().BeTrue(); + ReportPeriods.CrossesQuarter(start, end).Should().BeFalse(); + } + + [Test] + public void a_partial_year_does_not_cover_a_full_year_but_two_spanning_years_can() + { + ReportPeriods.CoversFullYear(new DateTime(2026, 1, 2), YearEnd).Should().BeFalse(); + ReportPeriods.CoversFullYear(YearStart, new DateTime(2026, 12, 30)).Should().BeFalse(); + ReportPeriods.CoversFullYear(new DateTime(2025, 6, 1), new DateTime(2026, 6, 1)).Should().BeFalse(); + ReportPeriods.CoversFullYear(new DateTime(2024, 7, 1), new DateTime(2026, 2, 1)).Should().BeTrue(); + } + + [Test] + public void splitting_into_quarters_numbers_them_and_flags_partial_ends() + { + var quarters = ReportPeriods.Split(new DateTime(2025, 11, 15), new DateTime(2026, 7, 10), ReportPeriodGranularity.Quarter); + + quarters.Select(q => (q.Year, q.Number)).Should().Equal((2025, 4), (2026, 1), (2026, 2), (2026, 3)); + quarters.Select(q => q.IsPartial).Should().Equal(true, false, false, true); + quarters[1].Start.Should().Be(new DateTime(2026, 1, 1)); + quarters[1].End.Should().Be(new DateTime(2026, 4, 1)); + } + + [Test] + public void splitting_the_default_year_gives_twelve_whole_months_and_one_whole_year() + { + var months = ReportPeriods.Split(YearStart, YearEnd, ReportPeriodGranularity.Month); + months.Should().HaveCount(12); + months.Should().OnlyContain(m => !m.IsPartial); + months.Select(m => m.Number).Should().Equal(Enumerable.Range(1, 12)); + + var years = ReportPeriods.Split(YearStart, YearEnd, ReportPeriodGranularity.Year); + years.Should().ContainSingle().Which.IsPartial.Should().BeFalse(); + } + + [Test] + public void a_period_contains_its_start_but_not_its_end() + { + var march = ReportPeriods.Split(new DateTime(2026, 3, 1), new DateTime(2026, 3, 31), ReportPeriodGranularity.Month).Single(); + + march.Contains(new DateTime(2026, 3, 1)).Should().BeTrue(); + march.Contains(new DateTime(2026, 3, 31, 23, 59, 59)).Should().BeTrue(); + march.Contains(new DateTime(2026, 4, 1)).Should().BeFalse(); + march.Contains(new DateTime(2026, 2, 28, 23, 59, 59)).Should().BeFalse(); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/User/CallSummaryReportTests.cs b/Tests/Resgrid.Tests/Web/User/CallSummaryReportTests.cs new file mode 100644 index 000000000..2ac4a06c6 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/User/CallSummaryReportTests.cs @@ -0,0 +1,266 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Localization; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Reporting; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; +using Resgrid.Web.Areas.User.Controllers; +using Resgrid.Web.Areas.User.Models.Reports.Calls; +using MvcClaims = Resgrid.Web.Helpers.ClaimsAuthorizationHelper; +using ReportsStrings = Resgrid.Localization.Areas.User.Reports.Reports; + +namespace Resgrid.Tests.Web.User +{ + /// + /// The call summary report's group filter, group breakdown and month/quarter/year sections, through the controller's + /// model builder and the real call group assignment service (repositories mocked). + /// + [TestFixture] + [NonParallelizable] + public class CallSummaryReportTests + { + private const int DepartmentId = 7; + private const string Caller = "CALLER-CS"; + + // Station 1's first-due box; Station 2 has no geofence. Both sit under the battalion. + private const string Station1Fence = "[{\"lat\":39.70,\"lng\":-105.00},{\"lat\":39.80,\"lng\":-105.00},{\"lat\":39.80,\"lng\":-104.90},{\"lat\":39.70,\"lng\":-104.90}]"; + + private static readonly DateTime YearStart = new DateTime(2026, 1, 1, 0, 0, 1); + private static readonly DateTime YearEnd = new DateTime(2026, 12, 31, 23, 59, 59); + + private List _calls; + + [SetUp] + public void SetUp() + { + _calls = new List + { + // Inside Station 1's fence, though Station 2's unit went. + Call(1, new DateTime(2026, 1, 10, 12, 0, 0), "Fire", "39.75,-104.95"), + // No location: the dispatched Station 2 unit places it. + Call(2, new DateTime(2026, 2, 5, 12, 0, 0), "EMS", null), + // Nothing dispatched, no location: unassigned. + Call(3, new DateTime(2026, 4, 2, 12, 0, 0), "Fire", null), + // Outside the only fence: the dispatched Station 2 member places it. + Call(4, new DateTime(2026, 7, 20, 12, 0, 0), "", "40.50,-104.00") + }; + } + + [TearDown] + public void TearDown() + { + MvcClaims._httpContextAccessor = null; + } + + private static Call Call(int id, DateTime loggedOn, string type, string geo) => new Call + { + CallId = id, + DepartmentId = DepartmentId, + Number = "26-" + id, + Name = "Call " + id, + Type = type, + GeoLocationData = geo, + LoggedOn = loggedOn, + ClosedOn = loggedOn.AddHours(1), + State = (int)CallStates.Closed + }; + + private ReportsController Controller() + { + var department = new Department { DepartmentId = DepartmentId, Name = "Test", TimeZone = "UTC" }; + var groups = new List + { + new DepartmentGroup { DepartmentGroupId = 10, DepartmentId = DepartmentId, Name = "Battalion", Type = (int)DepartmentGroupTypes.Orginizational, Members = new List() }, + new DepartmentGroup { DepartmentGroupId = 1, DepartmentId = DepartmentId, Name = "Station 1", Type = (int)DepartmentGroupTypes.Station, ParentDepartmentGroupId = 10, Geofence = Station1Fence, Members = new List() }, + new DepartmentGroup + { + DepartmentGroupId = 2, DepartmentId = DepartmentId, Name = "Station 2", Type = (int)DepartmentGroupTypes.Station, ParentDepartmentGroupId = 10, + Members = new List { new DepartmentGroupMember { DepartmentGroupId = 2, UserId = "s2-member" } } + } + }; + + var departments = new Mock(); + departments.Setup(x => x.GetDepartmentByIdAsync(DepartmentId, It.IsAny())).ReturnsAsync(department); + + var calls = new Mock(); + calls.Setup(x => x.GetAllCallsByDepartmentDateRangeAsync(DepartmentId, It.IsAny(), It.IsAny())) + .ReturnsAsync((int _, DateTime start, DateTime end) => _calls.Where(c => c.LoggedOn >= start && c.LoggedOn <= end).ToList()); + calls.Setup(x => x.GetCallTypesForDepartmentAsync(DepartmentId)).ReturnsAsync(new List + { + new CallType { DepartmentId = DepartmentId, Type = "Fire" }, + new CallType { DepartmentId = DepartmentId, Type = "EMS" }, + new CallType { DepartmentId = DepartmentId, Type = "Hazmat" } + }); + + var logs = new Mock(); + logs.Setup(x => x.GetAllLogsByDepartmentDateRangeAsync(DepartmentId, LogTypes.Run, It.IsAny(), It.IsAny())).ReturnsAsync(new List()); + + var customStates = new Mock(); + customStates.Setup(x => x.GetAllCustomStatesForDepartmentAsync(DepartmentId)).ReturnsAsync(new List()); + + var attribution = new Mock(); + attribution.Setup(x => x.GetUnitStatesForCallsAsync(DepartmentId, It.IsAny>())).ReturnsAsync(new Dictionary>()); + attribution.Setup(x => x.GetActionLogsForCallsAsync(DepartmentId, It.IsAny>())).ReturnsAsync(new Dictionary>()); + + var groupsService = new Mock(); + groupsService.Setup(x => x.GetAllGroupsForDepartmentAsync(DepartmentId)).ReturnsAsync(groups); + groupsService.Setup(x => x.GetAllGroupsForDepartmentUnlimitedAsync(DepartmentId)).ReturnsAsync(groups); + + var units = new Mock(); + units.Setup(x => x.GetUnitsForDepartmentIncludingDeletedAsync(DepartmentId)).ReturnsAsync(new List + { + new Unit { UnitId = 20, DepartmentId = DepartmentId, Name = "Engine 2", StationGroupId = 2 } + }); + + var userDispatches = new Mock(); + userDispatches.Setup(x => x.GetCallDispatchesForCallsInRangeAsync(DepartmentId, It.IsAny(), It.IsAny())) + .ReturnsAsync(new[] { new CallDispatch { CallId = 4, UserId = "S2-MEMBER" } }); + var groupDispatches = new Mock(); + groupDispatches.Setup(x => x.GetCallDispatchGroupsForCallsInRangeAsync(DepartmentId, It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + var unitDispatches = new Mock(); + unitDispatches.Setup(x => x.GetCallUnitDispatchesForCallsInRangeAsync(DepartmentId, It.IsAny(), It.IsAny())) + .ReturnsAsync(new[] { new CallDispatchUnit { CallId = 1, UnitId = 20 }, new CallDispatchUnit { CallId = 2, UnitId = 20 } }); + + var assignment = new CallGroupAssignmentService(groupsService.Object, units.Object, userDispatches.Object, groupDispatches.Object, unitDispatches.Object); + + var localizer = new Mock>(); + localizer.Setup(x => x[It.IsAny()]).Returns((string name) => new LocalizedString(name, name)); + localizer.Setup(x => x[It.IsAny(), It.IsAny()]) + .Returns((string name, object[] args) => new LocalizedString(name, name + ":" + string.Join(",", args))); + + var known = new Dictionary + { + [typeof(IDepartmentsService)] = departments.Object, + [typeof(ICallsService)] = calls.Object, + [typeof(IWorkLogsService)] = logs.Object, + [typeof(ICustomStateService)] = customStates.Object, + [typeof(ICallStatusAttributionService)] = attribution.Object, + [typeof(IDepartmentGroupsService)] = groupsService.Object, + [typeof(IUnitsService)] = units.Object, + [typeof(ICallGroupAssignmentService)] = assignment, + [typeof(IStringLocalizer)] = localizer.Object + }; + + var constructor = typeof(ReportsController).GetConstructors().OrderByDescending(c => c.GetParameters().Length).First(); + var arguments = constructor.GetParameters().Select(p => + known.TryGetValue(p.ParameterType, out var value) ? value : + ((Mock)Activator.CreateInstance(typeof(Mock<>).MakeGenericType(p.ParameterType))).Object).ToArray(); + var controller = (ReportsController)constructor.Invoke(arguments); + + var http = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.PrimarySid, Caller), + new Claim(ClaimTypes.PrimaryGroupSid, DepartmentId.ToString()) + }, "test")) + }; + MvcClaims._httpContextAccessor = new HttpContextAccessor { HttpContext = http }; + controller.ControllerContext = new ControllerContext { HttpContext = http }; + + return controller; + } + + private async Task Run(DateTime start, DateTime end, int groupId = 0) + { + var result = await Controller().CallSummaryReport(start, end, groupId); + return result.Should().BeOfType().Subject.Model.Should().BeOfType().Subject; + } + + [Test] + public async Task Every_call_is_placed_once_by_geofence_then_dispatch_plurality() + { + var model = await Run(YearStart, YearEnd); + + model.TotalCalls.Should().Be(4); + model.GroupId.Should().BeNull(); + + var byNumber = model.CallSummaries.ToDictionary(c => c.Number); + byNumber["26-1"].GroupName.Should().Be("Station 1"); + byNumber["26-1"].GroupMethod.Should().Be(CallGroupAssignmentMethods.Geofence); + byNumber["26-2"].GroupName.Should().Be("Station 2"); + byNumber["26-2"].GroupMethod.Should().Be(CallGroupAssignmentMethods.Dispatch); + byNumber["26-3"].GroupName.Should().BeNull(); + byNumber["26-4"].GroupName.Should().Be("Station 2"); + byNumber["26-4"].Type.Should().Be("NoCallType"); + + // Largest group first, the unassigned bucket (null name) last. + model.CallGroupCount.Select(g => (g.Item1, g.Item2)).Should().Equal(("Station 2", 2), ("Station 1", 1), (null, 1)); + } + + [Test] + public async Task Filtering_by_a_group_keeps_its_calls_and_those_of_the_groups_beneath_it() + { + var station2 = await Run(YearStart, YearEnd, 2); + station2.GroupId.Should().Be(2); + station2.GroupName.Should().Be("Station 2"); + station2.TotalCalls.Should().Be(2); + station2.CallSummaries.Select(c => c.Number).Should().Equal("26-2", "26-4"); + station2.CallTypeCount.Sum(t => t.Item2).Should().Be(2); + + var battalion = await Run(YearStart, YearEnd, 10); + battalion.TotalCalls.Should().Be(3); + battalion.CallSummaries.Select(c => c.Number).Should().Equal("26-1", "26-2", "26-4"); + } + + [Test] + public async Task A_group_from_outside_the_department_is_not_found() + { + (await Controller().CallSummaryReport(YearStart, YearEnd, 999)).Should().BeOfType(); + } + + [Test] + public async Task A_full_year_gets_yearly_quarterly_and_monthly_sections() + { + var model = await Run(YearStart, YearEnd); + + model.PeriodSections.Select(s => s.Granularity).Should().Equal(ReportPeriodGranularity.Year, ReportPeriodGranularity.Quarter, ReportPeriodGranularity.Month); + + var quarters = model.PeriodSections[1].Periods; + quarters.Select(q => q.Stats.Calls).Should().Equal(2, 1, 1, 0); + quarters.Select(q => q.YearToDate).Should().Equal(2, 3, 4, 4); + quarters[0].Label.Should().Be("QuarterLabel:1,2026"); + quarters[0].TypesWithNoCalls.Should().Equal("Hazmat"); + quarters[3].TypesWithNoCalls.Should().Equal("EMS", "Fire", "Hazmat"); + + var months = model.PeriodSections[2].Periods; + months.Should().HaveCount(12); + months.Select(m => m.Stats.Calls).Sum().Should().Be(4); + months[0].TypeCounts.Should().ContainKey("Fire").WhoseValue.Should().Be(1); + + var year = model.PeriodSections[0].Periods.Should().ContainSingle().Subject; + year.Stats.Calls.Should().Be(4); + year.Period.IsPartial.Should().BeFalse(); + year.Stats.AverageCallLength.Should().Be(TimeSpan.FromHours(1)); + } + + [Test] + public async Task A_range_inside_one_month_has_no_period_sections() + { + var model = await Run(new DateTime(2026, 1, 2), new DateTime(2026, 1, 20, 23, 59, 59)); + + model.TotalCalls.Should().Be(1); + model.PeriodSections.Should().BeEmpty(); + } + + [Test] + public async Task Two_quarters_get_quarterly_and_monthly_sections_but_no_yearly_one() + { + var model = await Run(new DateTime(2026, 1, 1), new DateTime(2026, 6, 30, 23, 59, 59)); + + model.PeriodSections.Select(s => s.Granularity).Should().Equal(ReportPeriodGranularity.Quarter, ReportPeriodGranularity.Month); + model.PeriodSections[1].Periods.Should().HaveCount(6); + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs index 07762acf8..2869b0aff 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs @@ -64,6 +64,8 @@ public partial class ReportsController : SecureBaseController private readonly IRecordsReportingService _recordsReporting; private readonly IBusinessOperationsAccessService _businessOperationsAccess; private readonly ICallStatusAttributionService _callStatusAttributionService; + private readonly ICallGroupAssignmentService _callGroupAssignmentService; + private readonly IStringLocalizer _reportsLocalizer; private readonly IStringLocalizer _unitsLocalizer; public ReportsController(IDepartmentsService departmentsService, IUsersService usersService, @@ -79,7 +81,8 @@ public ReportsController(IDepartmentsService departmentsService, IUsersService u ICalendarService calendarService, IDepartmentMemberSensitiveDataService memberSensitiveDataService, IProtectedReadService protectedReadService, IRecordsReportingService recordsReporting, IBusinessOperationsAccessService businessOperationsAccess, ICallStatusAttributionService callStatusAttributionService, - IStringLocalizer unitsLocalizer) + IStringLocalizer unitsLocalizer, ICallGroupAssignmentService callGroupAssignmentService, + IStringLocalizer reportsLocalizer) { _unitsLocalizer = unitsLocalizer; _departmentsService = departmentsService; @@ -106,6 +109,8 @@ public ReportsController(IDepartmentsService departmentsService, IUsersService u _recordsReporting = recordsReporting; _businessOperationsAccess = businessOperationsAccess; _callStatusAttributionService = callStatusAttributionService; + _callGroupAssignmentService = callGroupAssignmentService; + _reportsLocalizer = reportsLocalizer; } #endregion Private Members and Constructors @@ -355,9 +360,15 @@ public async Task PersonnelStaffingHistoryReport(bool groupSelect [HttpGet] [Authorize(Policy = ResgridResources.Reports_View)] - public async Task CallSummaryReport(DateTime start, DateTime end) + public async Task CallSummaryReport(DateTime start, DateTime end, int groupId = 0) { - return View(await CallSummaryReportModel(DepartmentId, start, end)); + var model = await CallSummaryReportModel(DepartmentId, start, end, groupId); + + // A group id that isn't one of the department's. + if (model == null) + return NotFound(); + + return View(model); } [HttpGet] @@ -567,7 +578,7 @@ public async Task ActionLogsParams(PersonnelStaffingHistoryReport [Authorize(Policy = ResgridResources.Reports_View)] public async Task CallSummaryReportParams() { - var model = new PersonnelHoursReportParams(); + var model = new CallSummaryReportParams(); var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); @@ -576,13 +587,19 @@ public async Task CallSummaryReportParams() model.End = TimeConverterHelper.TimeConverter(new DateTime(DateTime.UtcNow.Year, 12, 31, 23, 59, 59), department); + var groups = new List { new DepartmentGroup { DepartmentGroupId = 0, Name = _reportsLocalizer["AllGroups"] } }; + groups.AddRange((await _departmentGroupsService.GetAllGroupsForDepartmentAsync(DepartmentId) ?? new List()) + .OrderBy(g => g.Name)); + model.Groups = new SelectList(groups, "DepartmentGroupId", "Name", 0); + return View(model); } [HttpPost] - public async Task CallSummaryReportParams(PersonnelHoursReportParams model) + [Authorize(Policy = ResgridResources.Reports_View)] + public async Task CallSummaryReportParams(CallSummaryReportParams model) { - return RedirectToAction("CallSummaryReport", new { start = model.Start, end = model.End }); + return RedirectToAction("CallSummaryReport", new { start = model.Start, end = model.End, groupId = model.GroupId }); } [HttpGet] @@ -1715,7 +1732,12 @@ await _scheduledTasksService.GetScheduledTasksByUserTypeAsync(group.UserId, return model; } - private async Task CallSummaryReportModel(int departmentId, DateTime? start, DateTime? end) + /// + /// The call summary for a department-local range, optionally narrowed to the calls placed in one group or the groups + /// beneath it (see ). Null when is not one of the + /// department's groups. + /// + private async Task CallSummaryReportModel(int departmentId, DateTime? start, DateTime? end, int groupId = 0) { var model = new CallSummaryView(); @@ -1733,61 +1755,99 @@ private async Task CallSummaryReportModel(int departmentId, Dat model.End = new DateTime(DateTime.UtcNow.Year, 12, 31, 23, 59, 59); } + var groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(departmentId) ?? new List(); + var groupNames = groups.GroupBy(g => g.DepartmentGroupId).ToDictionary(g => g.Key, g => g.First().Name); + + HashSet groupFilter = null; + if (groupId > 0) + { + if (!groupNames.TryGetValue(groupId, out var groupName)) + return null; + + model.GroupId = groupId; + model.GroupName = groupName; + groupFilter = DepartmentGroupHierarchy.GetSelfAndDescendantIds(groups, groupId); + } + // The range is picked in department-local time; the call and log timestamps are UTC. var startUtc = model.Start.DepartmentLocalToUtc(model.Department); var endUtc = model.End.DepartmentLocalToUtc(model.Department); - var calls = await _callsService.GetAllCallsByDepartmentDateRangeAsync(departmentId, startUtc, endUtc); + var calls = await _callsService.GetAllCallsByDepartmentDateRangeAsync(departmentId, startUtc, endUtc) ?? new List(); var logs = await _logService.GetAllLogsByDepartmentDateRangeAsync(departmentId, LogTypes.Run, startUtc, - endUtc); + endUtc) ?? new List(); + var logsByCall = logs.Where(l => l.CallId.HasValue).GroupBy(l => l.CallId.Value).ToDictionary(g => g.Key, g => g.ToList()); - model.TotalCalls = calls.Count; - model.CallTypeCount = new List>(); - model.CallCloseCount = new List>(); - model.CallSummaries = new List(); + // Calls without a run log (most departments never write one, and none can once Records is active) take + // their unit/personnel counts and first on-scene time from the status history linked to the call. + var callsWithoutLogs = calls.Where(c => !logsByCall.ContainsKey(c.CallId)).ToList(); + var statusActivity = callsWithoutLogs.Count > 0 + ? await GetCallStatusActivityAsync(departmentId, callsWithoutLogs, startUtc, endUtc) + : new Dictionary Units, HashSet Personnel, DateTime? FirstOnScene)>(); - var groupedCalls = calls.GroupBy(x => x.Type); - foreach (var grouppedCall in groupedCalls) + // What worked each call: the run log's units and personnel, else the statuses linked to the call. A call nothing + // with a group was dispatched to is placed by these. + var responders = new Dictionary(); + foreach (var call in calls) { - string key = "No Type"; - if (!String.IsNullOrWhiteSpace(grouppedCall.Key)) - key = grouppedCall.Key; + var resources = new CallGroupResources(); + if (logsByCall.TryGetValue(call.CallId, out var callLogs)) + { + foreach (var callLog in callLogs) + { + foreach (var unit in callLog.Units ?? Enumerable.Empty()) + resources.UnitIds.Add(unit.UnitId); - model.CallTypeCount.Add(new Tuple(key, grouppedCall.ToList().Count)); + foreach (var user in (callLog.Users ?? Enumerable.Empty()).Where(u => !string.IsNullOrWhiteSpace(u.UserId))) + resources.UserIds.Add(user.UserId); + } + } + else if (statusActivity.TryGetValue(call.CallId, out var activity)) + { + resources.UnitIds.UnionWith(activity.Units); + resources.UserIds.UnionWith(activity.Personnel); + } + + responders[call.CallId] = resources; } - model.CallTypeCount.Add(new Tuple("Total", calls.Count)); + var assignments = await _callGroupAssignmentService.AssignCallsToGroupsAsync(departmentId, calls, responders); - var groupedCallStates = calls.GroupBy(x => x.State); - foreach (var grouppedCall in groupedCallStates) + if (groupFilter != null) + calls = calls.Where(c => assignments.TryGetValue(c.CallId, out var a) && a.DepartmentGroupId.HasValue + && groupFilter.Contains(a.DepartmentGroupId.Value)).ToList(); + + string TypeName(Call call) { - model.CallCloseCount.Add(new Tuple(((CallStates)grouppedCall.Key).ToString(), - grouppedCall.ToList().Count)); + var type = ProtectedDataEnvelope.SafeDisplay(call.Type); + return String.IsNullOrWhiteSpace(type) ? _reportsLocalizer["NoCallType"].Value : type.Trim(); } - model.CallCloseCount.Add(new Tuple("Total", calls.Count)); - - // Calls without a run log (most departments never write one, and none can once Records is active) take - // their unit/personnel counts and first on-scene time from the status history linked to the call. - var callsWithoutLogs = calls.Where(c => !logs.Any(l => l.CallId == c.CallId)).ToList(); - var statusActivity = callsWithoutLogs.Count > 0 - ? await GetCallStatusActivityAsync(departmentId, callsWithoutLogs, startUtc, endUtc) - : new Dictionary(); + model.TotalCalls = calls.Count; + model.CallSummaries = new List(); - foreach (var call in calls) + foreach (var call in calls.OrderBy(c => c.LoggedOn)) { var summary = new CallSummary(); + summary.CallId = call.CallId; summary.Number = call.Number; summary.Name = ProtectedDataEnvelope.SafeDisplay(call.Name); summary.LoggedOn = call.LoggedOn; + summary.LoggedOnLocal = call.LoggedOn.TimeConverter(model.Department); summary.ClosedOn = call.ClosedOn; - summary.Type = ProtectedDataEnvelope.SafeDisplay(call.Type); + summary.Type = TypeName(call); - DateTime? onSceneTime = null; - var callLogs = logs.Where(x => x.CallId == call.CallId); + if (assignments.TryGetValue(call.CallId, out var assignment) && assignment.DepartmentGroupId.HasValue + && groupNames.TryGetValue(assignment.DepartmentGroupId.Value, out var assignedName)) + { + summary.GroupName = assignedName; + summary.GroupMethod = assignment.Method; + } - if (callLogs != null && callLogs.Any()) + if (logsByCall.TryGetValue(call.CallId, out var callLogs)) { + DateTime? onSceneTime = null; + foreach (var callLog in callLogs) { if (callLog.Units != null && callLog.Units.Any()) @@ -1813,28 +1873,111 @@ private async Task CallSummaryReportModel(int departmentId, Dat else if (statusActivity.TryGetValue(call.CallId, out var activity)) { summary.FirstOnSceneTime = activity.FirstOnScene; - summary.UnitsCount = activity.Units; - summary.PersonnelCount = activity.Personnel; - } - else - { - summary.FirstOnSceneTime = null; - summary.UnitsCount = 0; - summary.PersonnelCount = 0; + summary.UnitsCount = activity.Units.Count; + summary.PersonnelCount = activity.Personnel.Count; } model.CallSummaries.Add(summary); } + model.Overall = CallSummaryStats.From(model.CallSummaries); + + model.CallTypeCount = model.CallSummaries.GroupBy(c => c.Type, StringComparer.OrdinalIgnoreCase) + .Select(g => new Tuple(g.First().Type, g.Count())) + .OrderByDescending(t => t.Item2).ThenBy(t => t.Item1, StringComparer.CurrentCultureIgnoreCase) + .ToList(); + model.TypeOrder = model.CallTypeCount.Select(t => t.Item1).ToList(); + + model.CallStateCount = calls.GroupBy(c => c.State) + .Select(g => new Tuple(g.Key, g.Count())) + .OrderByDescending(t => t.Item2) + .ToList(); + + // A null name is the unassigned bucket; the view names it. + model.CallGroupCount = model.CallSummaries.GroupBy(c => c.GroupName) + .Select(g => new Tuple(g.Key, g.Count())) + .OrderBy(t => t.Item1 == null) + .ThenByDescending(t => t.Item2) + .ThenBy(t => t.Item1, StringComparer.CurrentCultureIgnoreCase) + .ToList(); + + var definedTypes = (await _callsService.GetCallTypesForDepartmentAsync(departmentId) ?? new List()) + .Select(t => ProtectedDataEnvelope.SafeDisplay(t.Type)) + .Where(t => !String.IsNullOrWhiteSpace(t)) + .Select(t => t.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(t => t, StringComparer.CurrentCultureIgnoreCase) + .ToList(); + + if (ReportPeriods.CoversFullYear(model.Start, model.End)) + model.PeriodSections.Add(BuildCallSummaryPeriodSection(model, ReportPeriodGranularity.Year, definedTypes)); + + if (ReportPeriods.CrossesQuarter(model.Start, model.End)) + model.PeriodSections.Add(BuildCallSummaryPeriodSection(model, ReportPeriodGranularity.Quarter, definedTypes)); + + if (ReportPeriods.CrossesMonth(model.Start, model.End)) + model.PeriodSections.Add(BuildCallSummaryPeriodSection(model, ReportPeriodGranularity.Month, definedTypes)); + return model; } + private CallSummaryPeriodSection BuildCallSummaryPeriodSection(CallSummaryView model, ReportPeriodGranularity granularity, + List definedTypes) + { + var section = new CallSummaryPeriodSection { Granularity = granularity }; + var culture = CultureInfo.CurrentUICulture; + var yearToDate = 0; + int? currentYear = null; + + foreach (var period in ReportPeriods.Split(model.Start, model.End, granularity)) + { + var periodCalls = model.CallSummaries.Where(c => period.Contains(c.LoggedOnLocal)).ToList(); + + if (currentYear != period.Year) + { + currentYear = period.Year; + yearToDate = 0; + } + + yearToDate += periodCalls.Count; + + var typeCounts = periodCalls.GroupBy(c => c.Type, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.First().Type, g => g.Count(), StringComparer.OrdinalIgnoreCase); + + string label; + switch (granularity) + { + case ReportPeriodGranularity.Month: + label = culture.TextInfo.ToTitleCase(period.Start.ToString("MMM yyyy", culture)); + break; + case ReportPeriodGranularity.Quarter: + label = _reportsLocalizer["QuarterLabel", period.Number, period.Year].Value; + break; + default: + label = period.Year.ToString(CultureInfo.InvariantCulture); + break; + } + + section.Periods.Add(new CallSummaryPeriod + { + Period = period, + Label = label, + Stats = CallSummaryStats.From(periodCalls), + YearToDate = yearToDate, + TypeCounts = typeCounts, + TypesWithNoCalls = definedTypes.Where(t => !typeCounts.ContainsKey(t)).ToList() + }); + } + + return section; + } + /// - /// Per call: distinct units and personnel with a status on the call's record (linked or inferred, see + /// Per call: the distinct units and personnel with a status on the call's record (linked or inferred, see /// CallStatusAttribution), and the first unit on-scene time (custom statuses count through their base type). Status /// history is read once for the report window rather than once per call. /// - private async Task> GetCallStatusActivityAsync(int departmentId, + private async Task Units, HashSet Personnel, DateTime? FirstOnScene)>> GetCallStatusActivityAsync(int departmentId, List calls, DateTime startUtc, DateTime endUtc) { var callIds = calls.Select(c => c.CallId).ToHashSet(); @@ -1844,7 +1987,7 @@ private async Task CallSummaryReportModel(int departmentId, Dat var statesByCall = await _callStatusAttributionService.GetUnitStatesForCallsAsync(departmentId, calls); var logsByCall = await _callStatusAttributionService.GetActionLogsForCallsAsync(departmentId, calls); - var result = new Dictionary(); + var result = new Dictionary Units, HashSet Personnel, DateTime? FirstOnScene)>(); foreach (var callId in callIds) { var callUnitStates = statesByCall.TryGetValue(callId, out var callStates) ? callStates : new List(); @@ -1856,8 +1999,8 @@ private async Task CallSummaryReportModel(int departmentId, Dat var callLogs = logsByCall.TryGetValue(callId, out var logs) ? logs : new List(); - result[callId] = (callUnitStates.Select(s => s.UnitId).Distinct().Count(), - callLogs.Select(a => a.UserId).Distinct(StringComparer.OrdinalIgnoreCase).Count(), + result[callId] = (callUnitStates.Select(s => s.UnitId).ToHashSet(), + new HashSet(callLogs.Select(a => a.UserId).Where(u => !string.IsNullOrWhiteSpace(u)), StringComparer.OrdinalIgnoreCase), firstOnScene); } diff --git a/Web/Resgrid.Web/Areas/User/Models/Reports/Calls/CallSummaryView.cs b/Web/Resgrid.Web/Areas/User/Models/Reports/Calls/CallSummaryView.cs index 94342a34a..abe05e315 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Reports/Calls/CallSummaryView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Reports/Calls/CallSummaryView.cs @@ -1,8 +1,9 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Web; using Resgrid.Model; +using Resgrid.Model.Reporting; namespace Resgrid.Web.Areas.User.Models.Reports.Calls { @@ -13,17 +14,35 @@ public class CallSummaryView public DateTime Start { get; set; } public DateTime End { get; set; } + /// The group the report is filtered to (with the groups beneath it); null for the whole department. + public int? GroupId { get; set; } + public string GroupName { get; set; } + + /// Calls per call type, most first. public List> CallTypeCount { get; set; } - public List> CallCloseCount { get; set; } - + /// Calls per value. + public List> CallStateCount { get; set; } + + /// Calls per assigned group, largest first; calls no group could be decided for count under "unassigned". + public List> CallGroupCount { get; set; } = new List>(); + public int TotalCalls { get; set; } - public List CallSummaries { get; set; } + public CallSummaryStats Overall { get; set; } = new CallSummaryStats(); + + /// Every call type in the report, most calls first; chart colors follow this order. + public List TypeOrder { get; set; } = new List(); + + /// Monthly, quarterly and yearly breakdowns, only those the range calls for, yearly first. + public List PeriodSections { get; set; } = new List(); + + public List CallSummaries { get; set; } } public class CallSummary { + public int CallId { get; set; } public string Number { get; set; } public string Name { get; set; } public string Type { get; set; } @@ -33,11 +52,16 @@ public class CallSummary public int UnitsCount { get; set; } public int PersonnelCount { get; set; } + /// The call's logged time in department-local time, for placing it in a month, quarter or year. + public DateTime LoggedOnLocal { get; set; } + public string GroupName { get; set; } + public CallGroupAssignmentMethods GroupMethod { get; set; } + public string GetCallLength() { if (ClosedOn.HasValue) return (ClosedOn.Value - LoggedOn).ToString(@"d\d\:h\h\:m\m\:s\s", System.Globalization.CultureInfo.InvariantCulture); - + return "Open"; } @@ -49,4 +73,74 @@ public string GetOnSceneTime() return "N/A"; } } -} \ No newline at end of file + + /// Totals and averages over a set of calls. + public class CallSummaryStats + { + public int Calls { get; set; } + public int Closed { get; set; } + public TimeSpan? AverageOnScene { get; set; } + public TimeSpan? AverageCallLength { get; set; } + public int UnitResponses { get; set; } + public int PersonnelResponses { get; set; } + + public static CallSummaryStats From(IReadOnlyCollection calls) + { + var stats = new CallSummaryStats { Calls = calls.Count }; + + var lengths = calls.Where(c => c.ClosedOn.HasValue && c.ClosedOn.Value >= c.LoggedOn) + .Select(c => (c.ClosedOn.Value - c.LoggedOn).Ticks).ToList(); + var onScene = calls.Where(c => c.FirstOnSceneTime.HasValue && c.FirstOnSceneTime.Value >= c.LoggedOn) + .Select(c => (c.FirstOnSceneTime.Value - c.LoggedOn).Ticks).ToList(); + + stats.Closed = calls.Count(c => c.ClosedOn.HasValue); + stats.AverageCallLength = lengths.Count > 0 ? TimeSpan.FromTicks((long)lengths.Average()) : (TimeSpan?)null; + stats.AverageOnScene = onScene.Count > 0 ? TimeSpan.FromTicks((long)onScene.Average()) : (TimeSpan?)null; + stats.UnitResponses = calls.Sum(c => c.UnitsCount); + stats.PersonnelResponses = calls.Sum(c => c.PersonnelCount); + + return stats; + } + + public int Open => Calls - Closed; + + /// An average for a summary cell: "8m 05s", "1h 26m", "2d 3h". + public static string Format(TimeSpan? value) + { + if (!value.HasValue) + return "N/A"; + + var span = value.Value; + if (span.TotalDays >= 1) + return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}d {1}h", (int)span.TotalDays, span.Hours); + + if (span.TotalHours >= 1) + return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}h {1:00}m", (int)span.TotalHours, span.Minutes); + + return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}m {1:00}s", (int)span.TotalMinutes, span.Seconds); + } + } + + /// One breakdown of the report range: its months, quarters or years. + public class CallSummaryPeriodSection + { + public ReportPeriodGranularity Granularity { get; set; } + public List Periods { get; set; } = new List(); + } + + public class CallSummaryPeriod + { + public ReportPeriod Period { get; set; } + public string Label { get; set; } + public CallSummaryStats Stats { get; set; } + + /// Calls from the start of the period's calendar year (or of the report, if later) through the period. + public int YearToDate { get; set; } + + /// Calls per call type in the period; types with none are left out. + public Dictionary TypeCounts { get; set; } = new Dictionary(); + + /// The department's defined call types with no call in the period. + public List TypesWithNoCalls { get; set; } = new List(); + } +} diff --git a/Web/Resgrid.Web/Areas/User/Models/Reports/Params/CallSummaryReportParams.cs b/Web/Resgrid.Web/Areas/User/Models/Reports/Params/CallSummaryReportParams.cs new file mode 100644 index 000000000..2b45c0579 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/Reports/Params/CallSummaryReportParams.cs @@ -0,0 +1,15 @@ +using System; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace Resgrid.Web.Areas.User.Models.Reports.Params +{ + public class CallSummaryReportParams + { + public DateTime Start { get; set; } + public DateTime End { get; set; } + + /// 0 for every group (the whole department). + public int GroupId { get; set; } + public SelectList Groups { get; set; } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Department/DocumentNumbering.cshtml b/Web/Resgrid.Web/Areas/User/Views/Department/DocumentNumbering.cshtml index 9a255b14b..b1c1f2aaf 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Department/DocumentNumbering.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Department/DocumentNumbering.cshtml @@ -42,7 +42,7 @@

@localizer["DocumentNumberingIntro"]

- @commonLocalizer["CallImportSettings"] + @commonLocalizer["CallSettings"]  ·  @localizer["DocumentNumberingRecordsLink"]

diff --git a/Web/Resgrid.Web/Areas/User/Views/Reports/CallSummaryReport.cshtml b/Web/Resgrid.Web/Areas/User/Views/Reports/CallSummaryReport.cshtml index b5d253b73..9b9cc4314 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Reports/CallSummaryReport.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Reports/CallSummaryReport.cshtml @@ -1,11 +1,141 @@ @using Resgrid.Model.Helpers +@using Resgrid.Model.Reporting @using Resgrid.Web @using Resgrid.Web.Helpers +@using Resgrid.Web.Areas.User.Models.Reports.Calls @using Microsoft.Extensions.Localization +@using Newtonsoft.Json @model Resgrid.Web.Areas.User.Models.Reports.Calls.CallSummaryView @inject IStringLocalizer localizer +@inject IStringLocalizer callLocalizer @{ Layout = null; + + // Categorical slots in fixed order: a call type keeps its color in every chart on the page. Types past the eighth fold + // into "Other" rather than generating more hues. + var palette = new[] { "#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#4a3aa7", "#e34948" }; + const string otherColor = "#8c8b86"; + const string unassignedColor = "#c3c2b7"; + var otherLabel = localizer["OtherCallTypes"].Value; + var charted = Model.TypeOrder.Take(palette.Length).ToList(); + + string Percent(int count, int total) => total > 0 ? ((count * 100f) / total).ToString("F") + "%" : ""; + + string TypeColor(string type) + { + var index = charted.FindIndex(t => string.Equals(t, type, StringComparison.OrdinalIgnoreCase)); + return index >= 0 ? palette[index] : otherColor; + } + + List TypeSlices(IEnumerable> counts) + { + var list = counts.ToList(); + var slices = charted + .Select(t => new { label = t, value = list.Where(c => string.Equals(c.Key, t, StringComparison.OrdinalIgnoreCase)).Sum(c => c.Value), color = TypeColor(t) }) + .Where(s => s.value > 0) + .Cast() + .ToList(); + + var other = list.Where(c => !charted.Contains(c.Key, StringComparer.OrdinalIgnoreCase)).Sum(c => c.Value); + if (other > 0) + slices.Add(new { label = otherLabel, value = other, color = otherColor }); + + return slices; + } + + string GroupMethodText(CallGroupAssignmentMethods method) + { + switch (method) + { + case CallGroupAssignmentMethods.Geofence: return localizer["GroupMethodGeofence"].Value; + case CallGroupAssignmentMethods.Dispatch: return localizer["GroupMethodDispatch"].Value; + case CallGroupAssignmentMethods.Response: return localizer["GroupMethodResponse"].Value; + default: return ""; + } + } + + string SectionTitle(ReportPeriodGranularity granularity) + { + switch (granularity) + { + case ReportPeriodGranularity.Year: return localizer["YearlyStatistics"].Value; + case ReportPeriodGranularity.Quarter: return localizer["QuarterlyStatistics"].Value; + default: return localizer["MonthlyStatistics"].Value; + } + } + + string ChartTitle(ReportPeriodGranularity granularity) + { + switch (granularity) + { + case ReportPeriodGranularity.Year: return localizer["CallsByYear"].Value; + case ReportPeriodGranularity.Quarter: return localizer["CallsByQuarter"].Value; + default: return localizer["CallsByMonth"].Value; + } + } + + // One stacked/line series per charted call type, plus "Other" when types were folded. + List TypeSeries(CallSummaryPeriodSection section) + { + var series = charted + .Select(t => new { label = t, color = TypeColor(t), data = section.Periods.Select(p => p.TypeCounts.TryGetValue(t, out var c) ? c : 0).ToList() }) + .Cast() + .ToList(); + + if (Model.TypeOrder.Count > charted.Count) + series.Add(new + { + label = otherLabel, + color = otherColor, + data = section.Periods.Select(p => p.TypeCounts.Where(c => !charted.Contains(c.Key, StringComparer.OrdinalIgnoreCase)).Sum(c => c.Value)).ToList() + }); + + return series; + } + + string PeriodLabel(CallSummaryPeriod period) => period.Label + (period.Period.IsPartial ? "*" : ""); + + string SectionId(ReportPeriodGranularity granularity) => granularity.ToString().ToLowerInvariant(); + + // Per-period pies for quarters and years, and the type-by-period table, only when there is more than one period: a + // single year would repeat the call types table and pie above. A year chart likewise needs years to compare. + bool HasPies(CallSummaryPeriodSection section) => section.Granularity != ReportPeriodGranularity.Month && section.Periods.Count > 1; + bool HasTrend(CallSummaryPeriodSection section) => section.Granularity != ReportPeriodGranularity.Year || section.Periods.Count > 1; + const int maxMatrixColumns = 12; + + var groupSlices = new List(); + var namedGroups = Model.CallGroupCount.Where(g => g.Item1 != null).ToList(); + var groupSlots = namedGroups.Count > palette.Length ? palette.Length - 1 : palette.Length; + for (var i = 0; i < namedGroups.Count && i < groupSlots; i++) + groupSlices.Add(new { label = namedGroups[i].Item1, value = namedGroups[i].Item2, color = palette[i] }); + if (namedGroups.Count > groupSlots) + groupSlices.Add(new { label = localizer["OtherGroups"].Value, value = namedGroups.Skip(groupSlots).Sum(g => g.Item2), color = otherColor }); + var unassigned = Model.CallGroupCount.Where(g => g.Item1 == null).Sum(g => g.Item2); + if (unassigned > 0) + groupSlices.Add(new { label = localizer["UnassignedGroup"].Value, value = unassigned, color = unassignedColor }); + var showGroups = Model.CallGroupCount.Count > 1; + + var chartData = new + { + strings = new + { + calls = localizer["CallsHeader"].Value, + total = localizer["TotalHeader"].Value + }, + types = TypeSlices(Model.CallTypeCount.Select(t => new KeyValuePair(t.Item1, t.Item2))), + states = Model.CallStateCount.Select(s => new { label = DispatchDisplayHelper.GetLocalizedCallState(s.Item1, callLocalizer, commonLocalizer), value = s.Item2 }), + groups = showGroups ? groupSlices : new List(), + sections = Model.PeriodSections.Select(section => new + { + id = SectionId(section.Granularity), + kind = !HasTrend(section) ? null : section.Granularity == ReportPeriodGranularity.Year ? "line" : "bar", + labels = section.Periods.Select(PeriodLabel), + totals = section.Periods.Select(p => p.Stats.Calls), + series = TypeSeries(section), + pies = HasPies(section) ? section.Periods.Select((p, i) => new { index = i, slices = TypeSlices(p.TypeCounts) }).Cast() : Enumerable.Empty() + }) + }; + var chartJson = JsonConvert.SerializeObject(chartData, new JsonSerializerSettings { StringEscapeHandling = StringEscapeHandling.EscapeHtml }); } @@ -31,6 +161,117 @@ text-align: left !important; vertical-align: top !important; } + + .table td.num, .table th.num { + text-align: right !important; + } + + .content { + padding-left: 15px; + padding-right: 15px; + } + + .section-heading { + margin-top: 28px; + border-bottom: 1px solid #ddd; + padding-bottom: 6px; + } + + .stat-tiles { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin: 12px 0 4px 0; + } + + .stat-tile { + flex: 1 1 140px; + border: 1px solid #e3e2de; + border-radius: 4px; + padding: 10px 12px; + } + + .stat-tile .stat-label { + color: #52514e; + font-size: 12px; + } + + .stat-tile .stat-value { + font-size: 22px; + font-weight: 600; + color: #0b0b0b; + } + + .chart-box { + position: relative; + height: 300px; + } + + .chart-box.wide { + height: 320px; + } + + .period-pies { + display: flex; + flex-wrap: wrap; + gap: 16px; + } + + .period-pie { + flex: 0 1 210px; + border: 1px solid #e3e2de; + border-radius: 4px; + padding: 8px 10px; + page-break-inside: avoid; + } + + .period-pie h5 { + margin: 0 0 6px 0; + font-weight: 600; + } + + .period-pie .pie-box { + position: relative; + height: 180px; + } + + .period-pie .no-calls { + color: #52514e; + font-size: 12px; + margin-top: 6px; + } + + .legend-chips { + display: flex; + flex-wrap: wrap; + gap: 6px 14px; + margin: 6px 0 10px 0; + font-size: 12px; + color: #52514e; + } + + .legend-chips .chip { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 2px; + margin-right: 5px; + vertical-align: -1px; + } + + .muted { + color: #777; + } + + .matrix-wrap { + overflow-x: auto; + } + + @@media print { + .section-heading { + page-break-after: avoid; + } + }