From 24ad65c1baa46ef3b5324c9fe0d5ac6dc23a97f6 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 26 Sep 2026 13:09:49 -0700 Subject: [PATCH 1/2] RG-T135 Fixing Admin Assist Setup Page and Wizard --- Core/Resgrid.AdminAssist/Catalog/apps.yaml | 2 +- .../User/AdminAssist/AdminAssist.ar.resx | 51 +++ .../User/AdminAssist/AdminAssist.de.resx | 51 +++ .../User/AdminAssist/AdminAssist.el.resx | 51 +++ .../User/AdminAssist/AdminAssist.en.resx | 53 ++- .../User/AdminAssist/AdminAssist.es.resx | 51 +++ .../User/AdminAssist/AdminAssist.fr.resx | 51 +++ .../User/AdminAssist/AdminAssist.it.resx | 51 +++ .../User/AdminAssist/AdminAssist.pl.resx | 51 +++ .../Areas/User/AdminAssist/AdminAssist.resx | 53 ++- .../User/AdminAssist/AdminAssist.sv.resx | 51 +++ .../User/AdminAssist/AdminAssist.uk.resx | 51 +++ .../AdminAssist/AdminAssistContracts.cs | 17 +- .../AdminAssist/DepartmentOperatingProfile.cs | 2 +- .../Services/IDepartmentSettingsService.cs | 2 + .../AdminAssist/AdminAssistService.cs | 25 +- ...artmentSettingsService.OperatingProfile.cs | 7 + .../OrganizationFindingSubjects.cs | 42 +++ Core/Resgrid.Services/ServicesModule.cs | 1 + .../AdminAssistRepository.OperatingProfile.cs | 6 + .../AdminAssist/AdminAssistDatabaseTests.cs | 18 +- .../AdminAssist/AdminAssistPageTests.cs | 117 ++++++ .../AdminAssist/FindingSubjectsTests.cs | 134 +++++++ .../OperatingProfileScreenTests.cs | 176 +++++++++ .../Rms/RecordsQualityAndTelemetryTests.cs | 5 +- .../User/OperatingProfileRenderingTests.cs | 123 +++++++ .../Controllers/v4/AdminAssistController.cs | 6 +- .../adminAssist/AdminAssistElement.tsx | 40 +- .../components/adminAssist/SetupVisuals.tsx | 20 +- .../components/adminAssist/adminAssist.css | 8 +- .../Areas/User/Apps/src/elements.ts | 2 +- .../User/Controllers/AdminAssistController.cs | 26 +- .../DepartmentController.OperatingProfile.cs | 53 ++- .../User/Controllers/DepartmentController.cs | 4 +- .../Areas/User/Views/AdminAssist/Index.cshtml | 7 +- .../User/Views/AdminAssist/PrintReport.cshtml | 2 +- .../Views/Department/OperatingProfile.cshtml | 344 ++++++++++++++++-- .../Areas/User/Views/Shared/_TopNavbar.cshtml | 30 +- Web/Resgrid.Web/Helpers/SeasonMonthDay.cs | 58 +++ .../resgrid.department.operatingprofile.js | 75 ++++ docs/admin-assist/setup-guide.md | 2 +- 41 files changed, 1813 insertions(+), 106 deletions(-) create mode 100644 Core/Resgrid.Services/AdminAssist/OrganizationFindingSubjects.cs create mode 100644 Tests/Resgrid.Tests/AdminAssist/AdminAssistPageTests.cs create mode 100644 Tests/Resgrid.Tests/AdminAssist/FindingSubjectsTests.cs create mode 100644 Tests/Resgrid.Tests/AdminAssist/OperatingProfileScreenTests.cs create mode 100644 Tests/Resgrid.Tests/Web/User/OperatingProfileRenderingTests.cs create mode 100644 Web/Resgrid.Web/Helpers/SeasonMonthDay.cs create mode 100644 Web/Resgrid.Web/wwwroot/js/app/internal/department/resgrid.department.operatingprofile.js diff --git a/Core/Resgrid.AdminAssist/Catalog/apps.yaml b/Core/Resgrid.AdminAssist/Catalog/apps.yaml index d745537db..544450483 100644 --- a/Core/Resgrid.AdminAssist/Catalog/apps.yaml +++ b/Core/Resgrid.AdminAssist/Catalog/apps.yaml @@ -879,7 +879,7 @@ "Id": "guide.start-setup", "Locale": "en", "TitleKey": "Guide.start-setup", - "Body": "Department administrators can open **Setup Wizard** from the Department or Help menu when setup is enabled for the deployment. An unfinished setup also appears on the dashboard. Dismissing that prompt affects only your account; the menu remains available. Setup Wizard and Setup Report are available independently of Admin Assist and do not require an AI add-on.\n\nChoose **Fresh setup**, **Review existing setup**, or **Import or migration**. The choice records your intent; it does not import or overwrite data. Use the department operating profile to describe your organization and link approved local policies. Fire, EMS, mental health, SAR, emergency response, Hazmat, industrial, security and mutual-aid packs suggest areas to review. They do not establish qualifications or authorize clinical, tactical or hazardous work.\n\nThe nine-step journey covers goals and applications, the department profile, all-area orientation, people and access, operational essentials, selected workflows, add-ons, verification and practice, and review and handover. Pause and resume without creating sample incidents or overwriting existing configuration.\n\nSelected operating packs suggest areas without changing your choices. Site references use existing department group IDs; policy references use existing, unexpired department document IDs. Saving checks those references and rejects concurrent overwrites. A valid reference does not prove that its contents are approved or sufficient. Verification checks whether linked policies or site groups later become unavailable, and flags documents scheduled for removal within 30 days. A reviewed profile without a continuity link receives an administrative review prompt; an external procedure may still exist. The checks do not read or certify policy contents.\n\nAn optional **Expected email polling interval** compares recorded mailbox polls with your declared maximum interval. Leave it blank when no interval has been established. Missing or future poll timestamps remain unknown, and low call volume alone does not imply failure. SMS, CAD push and API intake require separate telemetry; the expectation does not change polling or send a test.", + "Body": "Department administrators can open **Admin Assist** from the top of the Department menu when setup is enabled for the deployment. It opens on **Setup Wizard**, with **Setup Report**, **Explore Resgrid & Addons** and **Admin AI** beside it. An unfinished setup also appears on the dashboard. Dismissing that prompt affects only your account; the menu remains available. Setup Wizard, Setup Report and Explore do not require Admin AI or an AI add-on; the Admin AI tab stays disabled until Admin AI is enabled for the department.\n\nChoose **Fresh setup**, **Review existing setup**, or **Import or migration**. The choice records your intent; it does not import or overwrite data. Use the department operating profile to describe your organization and link approved local policies. Fire, EMS, mental health, SAR, emergency response, Hazmat, industrial, security and mutual-aid packs suggest areas to review. They do not establish qualifications or authorize clinical, tactical or hazardous work.\n\nThe nine-step journey covers goals and applications, the department profile, all-area orientation, people and access, operational essentials, selected workflows, add-ons, verification and practice, and review and handover. Pause and resume without creating sample incidents or overwriting existing configuration.\n\nSelected operating packs suggest areas without changing your choices. Site references use existing department group IDs; policy references use existing, unexpired department document IDs. Saving checks those references and rejects concurrent overwrites. A valid reference does not prove that its contents are approved or sufficient. Verification checks whether linked policies or site groups later become unavailable, and flags documents scheduled for removal within 30 days. A reviewed profile without a continuity link receives an administrative review prompt; an external procedure may still exist. The checks do not read or certify policy contents.\n\nAn optional **Expected email polling interval** compares recorded mailbox polls with your declared maximum interval. Leave it blank when no interval has been established. Missing or future poll timestamps remain unknown, and low call volume alone does not imply failure. SMS, CAD push and API intake require separate telemetry; the expectation does not change polling or send a test.", "SourcePath": "docs/admin-assist/setup-guide.md", "Anchor": "start-setup", "PackVersion": "2026.09.25.2", diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.ar.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.ar.resx index f2aca0587..48493dca7 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.ar.resx @@ -3921,21 +3921,39 @@ الأنظمة المصدر المعتمدة + + اختر المستندات + + + اختر المجموعات + + + مسح الموسم + قام مسؤول آخر بتغيير هذا الملف. لا تزال القيم المقترحة منك معروضة. أعد تحميل الملف الحالي قبل الحفظ مرة أخرى. إجراءات استمرارية العمل المعتمدة + + اليوم + العدد التقريبي للأعضاء (اختياري) نموذج الإرسال + + اختر مستندات سارية من هذا القسم. لا يمكن الإشارة إلى مستندات منتهية الصلاحية. يتحقق الحفظ من وجود المستند فقط، وليس من اعتماده أو ملاءمته. + أدخل معرّفات مستندات رقمية موجودة للسياسات المعتمدة في هذا القسم. لا يمكن الإشارة إلى مستندات منتهية الصلاحية. يتحقق الحفظ من وجود المستند فقط، وليس من اعتماده أو ملاءمته. + + اختر مجموعات من هذا القسم. لا يمنح هذا وصولًا إلى المجموعة ولا يغيّر نطاق الإرسال. + أدخل معرّفات مجموعات رقمية موجودة من هذا القسم. يضيف الحفظ صفًا اختياريًا آخر. @@ -3945,6 +3963,12 @@ لغات العمل + + {0} (لم يعد متاحًا) + + + الشهر + يشارك في المساعدة المتبادلة @@ -3969,18 +3993,39 @@ حفظ ملف التشغيل + + تم حفظ ملف التشغيل. + نهاية الموسم (شهر-يوم، اختياري) + + إذا كان القسم يعمل موسميًا، فاختر الشهر واليوم اللذين يبدأ فيهما الموسم وينتهي. اترك الموسم فارغًا إن لم يكن كذلك. يمكن أن يمتد الموسم عبر بداية العام الجديد. + بداية الموسم (شهر-يوم، اختياري) + + التنظيم والعمليات + + + اللغات وإمكانية الوصول + + + المواقع والسياسات والأنظمة + + + التشغيل الموسمي + المواقع ومجموعات مناطق الخدمة سياسات التأمين المعتمدة + + اكتب تسمية واضغط Enter + استخدم تسمية مرجعية قصيرة للنظام المصدر. هذه التسميات هي إقرارات فقط؛ ولا يتحقق الحفظ من وجود اتصال أو استيراد. @@ -6324,6 +6369,9 @@ التحقق من الأدلة الحالية والإجراءات المحلية + + +{0} أخرى + الوصول إلى المصدر الحالي غير متاح @@ -6495,6 +6543,9 @@ يُحدّث التحقق النتائج. لا يحل التعيين أو الاستثناءات المقبولة فحصًا فاشلًا. يحله فحص ناجح جديد. + + Admin AI + اسأل Admin Assist diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.de.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.de.resx index c16d2571c..71ed7f3e0 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.de.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.de.resx @@ -3921,21 +3921,39 @@ Maßgebliche Quellsysteme + + Dokumente auswählen + + + Gruppen auswählen + + + Saison löschen + Ein anderer Administrator hat dieses Profil geändert. Ihre vorgeschlagenen Werte werden weiterhin angezeigt. Laden Sie das aktuelle Profil neu, bevor Sie erneut speichern. Genehmigte Kontinuitätsverfahren + + Tag + Ungefähre Mitgliederzahl (optional) Dispositionsmodell + + Wählen Sie aktuelle Dokumente dieser Abteilung aus. Abgelaufene Dokumente können nicht referenziert werden. Das Speichern prüft die Existenz, nicht die Genehmigung oder Eignung. + Geben Sie vorhandene numerische Dokument-IDs für genehmigte Richtlinien dieser Abteilung ein. Abgelaufene Dokumente können nicht referenziert werden. Das Speichern prüft die Existenz, nicht die Genehmigung oder Eignung. + + Wählen Sie Gruppen dieser Abteilung aus. Dies gewährt keinen Gruppenzugriff und ändert nicht den Dispositionsumfang. + Geben Sie vorhandene numerische Gruppen-IDs dieser Abteilung ein. Das Speichern fügt eine weitere optionale Zeile hinzu. @@ -3945,6 +3963,12 @@ Arbeitssprachen + + {0} (nicht mehr verfügbar) + + + Monat + Beteiligt sich an gegenseitiger Unterstützung @@ -3969,18 +3993,39 @@ Betriebsprofil speichern + + Betriebsprofil gespeichert. + Saisonende (MM-TT, optional) + + Wenn die Abteilung saisonal arbeitet, wählen Sie Monat und Tag für Beginn und Ende der Saison. Andernfalls lassen Sie die Saison leer. Eine Saison kann über den Jahreswechsel reichen. + Saisonbeginn (MM-TT, optional) + + Organisation und Betrieb + + + Sprachen und Barrierefreiheit + + + Standorte, Richtlinien und Systeme + + + Saisonaler Betrieb + Standorte und Einsatzgebiets-Gruppen Genehmigte Besetzungsrichtlinien + + Bezeichnung eingeben und Enter drücken + Verwenden Sie eine kurze Bezeichnung für das Quellsystem. Diese Bezeichnungen sind Angaben; das Speichern prüft keine Verbindung oder keinen Import. @@ -6324,6 +6369,9 @@ Aktuelle Nachweise und örtliche Verfahren verifizieren + + +{0} weitere + Aktueller Quellzugriff nicht verfügbar @@ -6495,6 +6543,9 @@ Die Verifizierung aktualisiert Befunde. Zuweisung und akzeptierte Ausnahmen lösen keine fehlgeschlagene Prüfung. Eine neue bestandene Prüfung löst sie. + + Admin AI + Admin Assist fragen diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.el.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.el.resx index 7486af121..7e1d602f5 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.el.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.el.resx @@ -3921,21 +3921,39 @@ Επίσημα συστήματα πηγής + + Επιλέξτε έγγραφα + + + Επιλέξτε ομάδες + + + Εκκαθάριση περιόδου + Ένας άλλος διαχειριστής άλλαξε αυτό το προφίλ. Οι προτεινόμενες τιμές σας εξακολουθούν να εμφανίζονται. Φορτώστε ξανά το τρέχον προφίλ πριν αποθηκεύσετε ξανά. Εγκεκριμένες διαδικασίες συνέχειας λειτουργίας + + Ημέρα + Κατά προσέγγιση αριθμός μελών (προαιρετικό) Μοντέλο αποστολής + + Επιλέξτε τρέχοντα έγγραφα από αυτό το τμήμα. Τα ληγμένα έγγραφα δεν μπορούν να αναφερθούν. Η αποθήκευση επαληθεύει την ύπαρξη, όχι την έγκριση ή την καταλληλότητα. + Εισαγάγετε υπάρχοντα αριθμητικά αναγνωριστικά εγγράφων για εγκεκριμένες πολιτικές σε αυτό το τμήμα. Τα ληγμένα έγγραφα δεν μπορούν να αναφερθούν. Η αποθήκευση επαληθεύει την ύπαρξη, όχι την έγκριση ή την καταλληλότητα. + + Επιλέξτε ομάδες από αυτό το τμήμα. Αυτό δεν παραχωρεί πρόσβαση ομάδας ούτε αλλάζει το εύρος αποστολής. + Εισαγάγετε υπάρχοντα αριθμητικά αναγνωριστικά ομάδων από αυτό το τμήμα. Η αποθήκευση προσθέτει άλλη μία προαιρετική γραμμή. @@ -3945,6 +3963,12 @@ Γλώσσες εργασίας + + {0} (δεν είναι πλέον διαθέσιμο) + + + Μήνας + Συμμετέχει σε αμοιβαία βοήθεια @@ -3969,18 +3993,39 @@ Αποθήκευση προφίλ λειτουργίας + + Το προφίλ λειτουργίας αποθηκεύτηκε. + Λήξη περιόδου (ΜΜ-ΗΗ, προαιρετικό) + + Αν το τμήμα λειτουργεί εποχικά, επιλέξτε τον μήνα και την ημέρα έναρξης και λήξης της περιόδου. Διαφορετικά, αφήστε την περίοδο κενή. Μια περίοδος μπορεί να εκτείνεται πέρα από την αλλαγή του έτους. + Έναρξη περιόδου (ΜΜ-ΗΗ, προαιρετικό) + + Οργάνωση και λειτουργίες + + + Γλώσσες και προσβασιμότητα + + + Τοποθεσίες, πολιτικές και συστήματα + + + Εποχική λειτουργία + Ομάδες εγκαταστάσεων και περιοχών εξυπηρέτησης Εγκεκριμένες πολιτικές στελέχωσης + + Πληκτρολογήστε μια ετικέτα και πατήστε Enter + Χρησιμοποιήστε μια σύντομη ετικέτα αναφοράς συστήματος πηγής. Αυτές οι ετικέτες είναι δηλώσεις· η αποθήκευση δεν επαληθεύει σύνδεση ή εισαγωγή δεδομένων. @@ -6324,6 +6369,9 @@ Επαλήθευση τρεχόντων στοιχείων και τοπικών διαδικασιών + + +{0} ακόμη + Η τρέχουσα πρόσβαση πηγής δεν είναι διαθέσιμη @@ -6495,6 +6543,9 @@ Η επαλήθευση ενημερώνει τα ευρήματα. Η ανάθεση και οι αποδεκτές εξαιρέσεις δεν επιλύουν έναν αποτυχημένο έλεγχο. Ένας νέος επιτυχής έλεγχος τον επιλύει. + + Admin AI + Ρωτήστε το Admin Assist diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.en.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.en.resx index 48f0e7b28..61bbd0efe 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.en.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.en.resx @@ -3921,21 +3921,39 @@ Authoritative source systems + + Choose documents + + + Choose groups + + + Clear season + Another administrator changed this profile. Your proposed values are still shown. Reload the current profile before saving again. Approved continuity procedures + + Day + Approximate member count (optional) Dispatch model + + Choose current documents from this department. Expired documents cannot be referenced. Saving verifies existence, not approval or suitability. + Enter existing numeric document IDs for approved policies in this department. Expired documents cannot be referenced. Saving verifies existence, not approval or suitability. + + Choose groups from this department. This does not grant group access or change dispatch scope. + Enter existing numeric group IDs from this department. Saving adds another optional row. @@ -3945,6 +3963,12 @@ Working languages + + {0} (no longer available) + + + Month + Participates in mutual aid @@ -3969,18 +3993,39 @@ Save operating profile + + Operating profile saved. + Season ends (MM-DD, optional) + + If the department operates seasonally, choose the month and day the season starts and ends. Leave the season blank if it does not. A season can run across the new year. + Season starts (MM-DD, optional) + + Organization and operations + + + Languages and accessibility + + + Sites, policies and systems + + + Seasonal operation + Sites and service-area groups Approved staffing policies + + Type a label and press Enter + Use a short source-system reference label. These labels are declarations; saving does not verify a connection or import. @@ -6324,6 +6369,9 @@ Verify current evidence and local procedures + + +{0} more + Current source access unavailable @@ -6495,11 +6543,14 @@ Verification updates findings. Assignment and accepted exceptions do not resolve a failed check. A fresh passing check resolves it. + + Admin AI + Ask Admin Assist - Explore Resgrid and add-ons + Explore Resgrid & Addons Health checks diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.es.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.es.resx index 9c7bb81d4..785e0a6c1 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.es.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.es.resx @@ -3921,21 +3921,39 @@ Sistemas de origen autorizados + + Elija documentos + + + Elija grupos + + + Borrar temporada + Otro administrador cambió este perfil. Sus valores propuestos aún se muestran. Vuelva a cargar el perfil actual antes de guardar de nuevo. Procedimientos de continuidad aprobados + + Día + Número aproximado de miembros (opcional) Modelo de despacho + + Elija documentos vigentes de este departamento. No se pueden referenciar documentos vencidos. Guardar verifica la existencia, no la aprobación ni la idoneidad. + Ingrese los ID numéricos de documentos existentes para las políticas aprobadas en este departamento. No se pueden referenciar documentos vencidos. Guardar verifica la existencia, no la aprobación ni la idoneidad. + + Elija grupos de este departamento. Esto no otorga acceso al grupo ni cambia el alcance del despacho. + Ingrese los ID numéricos de grupos existentes de este departamento. Guardar agrega otra fila opcional. @@ -3945,6 +3963,12 @@ Idiomas de trabajo + + {0} (ya no está disponible) + + + Mes + Participa en ayuda mutua @@ -3969,18 +3993,39 @@ Guardar perfil operativo + + Perfil operativo guardado. + Fin de temporada (MM-DD, opcional) + + Si el departamento opera por temporadas, elija el mes y el día en que empieza y termina la temporada. De lo contrario, deje la temporada en blanco. Una temporada puede extenderse más allá del año nuevo. + Inicio de temporada (MM-DD, opcional) + + Organización y operaciones + + + Idiomas y accesibilidad + + + Sitios, políticas y sistemas + + + Operación estacional + Sitios y grupos de área de servicio Políticas de dotación aprobadas + + Escriba una etiqueta y pulse Intro + Use una etiqueta de referencia breve para el sistema de origen. Estas etiquetas son declaraciones; guardar no verifica una conexión ni una importación. @@ -6324,6 +6369,9 @@ Verificar la evidencia actual y los procedimientos locales + + +{0} más + Acceso a la fuente actual no disponible @@ -6495,6 +6543,9 @@ La verificación actualiza los hallazgos. La asignación y las excepciones aceptadas no resuelven una verificación fallida. Una verificación nueva que se apruebe la resuelve. + + Admin AI + Preguntar a Admin Assist diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.fr.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.fr.resx index 1eb709b3e..c6ee697cf 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.fr.resx @@ -3921,21 +3921,39 @@ Systèmes sources faisant autorité + + Choisir des documents + + + Choisir des groupes + + + Effacer la saison + Un autre administrateur a modifié ce profil. Vos valeurs proposées restent affichées. Rechargez le profil actuel avant d’enregistrer à nouveau. Procédures de continuité approuvées + + Jour + Nombre approximatif de membres (facultatif) Modèle de régulation + + Choisissez des documents en vigueur de ce département. Les documents expirés ne peuvent pas être référencés. L’enregistrement vérifie l’existence, mais pas l’approbation ni la pertinence. + Saisissez les identifiants numériques de documents existants pour les politiques approuvées de ce département. Les documents expirés ne peuvent pas être référencés. L’enregistrement vérifie l’existence, mais pas l’approbation ni la pertinence. + + Choisissez des groupes de ce département. Cela n’accorde pas l’accès au groupe et ne modifie pas la portée de la régulation. + Saisissez les identifiants numériques de groupes existants de ce département. L’enregistrement ajoute une autre ligne facultative. @@ -3945,6 +3963,12 @@ Langues de travail + + {0} (n’est plus disponible) + + + Mois + Participe à l’entraide @@ -3969,18 +3993,39 @@ Enregistrer le profil d’exploitation + + Profil d’exploitation enregistré. + Fin de saison (MM-DD, facultatif) + + Si le département fonctionne de façon saisonnière, choisissez le mois et le jour de début et de fin de la saison. Sinon, laissez la saison vide. Une saison peut chevaucher le nouvel an. + Début de saison (MM-DD, facultatif) + + Organisation et opérations + + + Langues et accessibilité + + + Sites, politiques et systèmes + + + Activité saisonnière + Sites et groupes de zones de service Politiques de dotation en personnel approuvées + + Saisissez un libellé et appuyez sur Entrée + Utilisez un libellé court de référence du système source. Ces libellés sont des déclarations ; l’enregistrement ne vérifie ni une connexion ni un import. @@ -6324,6 +6369,9 @@ Vérifier les éléments de preuve actuels et les procédures locales + + +{0} de plus + Accès à la source actuel indisponible @@ -6495,6 +6543,9 @@ La vérification met à jour les constats. L’attribution et les exceptions acceptées ne résolvent pas une vérification en échec. Une nouvelle vérification réussie la résout. + + Admin AI + Poser une question à Admin Assist diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.it.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.it.resx index e52522a1b..82ec8859b 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.it.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.it.resx @@ -3921,21 +3921,39 @@ Sistemi di origine autorevoli + + Scegli i documenti + + + Scegli i gruppi + + + Cancella stagione + Un altro amministratore ha modificato questo profilo. I tuoi valori proposti sono ancora mostrati. Ricarica il profilo attuale prima di salvare di nuovo. Procedure di continuità approvate + + Giorno + Numero approssimativo di membri (opzionale) Modello di centrale operativa + + Scegli i documenti validi di questo dipartimento. I documenti scaduti non possono essere referenziati. Il salvataggio verifica l'esistenza, non l'approvazione o l'idoneità. + Inserisci gli ID numerici dei documenti esistenti per le policy approvate in questo dipartimento. I documenti scaduti non possono essere referenziati. Il salvataggio verifica l'esistenza, non l'approvazione o l'idoneità. + + Scegli i gruppi di questo dipartimento. Questo non concede l'accesso al gruppo né modifica l'ambito della centrale operativa. + Inserisci gli ID numerici dei gruppi esistenti in questo dipartimento. Il salvataggio aggiunge un'altra riga opzionale. @@ -3945,6 +3963,12 @@ Lingue di lavoro + + {0} (non più disponibile) + + + Mese + Partecipa al mutuo soccorso @@ -3969,18 +3993,39 @@ Salva profilo operativo + + Profilo operativo salvato. + Fine stagione (MM-GG, opzionale) + + Se il dipartimento opera stagionalmente, scegli il mese e il giorno di inizio e di fine della stagione. Altrimenti lascia la stagione vuota. Una stagione può proseguire oltre il nuovo anno. + Inizio stagione (MM-GG, opzionale) + + Organizzazione e operazioni + + + Lingue e accessibilità + + + Sedi, policy e sistemi + + + Operatività stagionale + Sedi e gruppi di area di servizio Policy di personale approvate + + Digita un'etichetta e premi Invio + Usa un'etichetta breve di riferimento per il sistema di origine. Queste etichette sono dichiarazioni; il salvataggio non verifica una connessione o un'importazione. @@ -6324,6 +6369,9 @@ Verifica le evidenze attuali e le procedure locali + + +{0} altri + Accesso alla fonte attuale non disponibile @@ -6495,6 +6543,9 @@ La verifica aggiorna i rilievi. Assegnazione ed eccezioni accettate non risolvono un controllo non superato. Un nuovo controllo superato lo risolve. + + Admin AI + Chiedi ad Admin Assist diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.pl.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.pl.resx index b27959a2d..281fa0124 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.pl.resx @@ -3921,21 +3921,39 @@ Wiążące systemy źródłowe + + Wybierz dokumenty + + + Wybierz grupy + + + Wyczyść sezon + Inny administrator zmienił ten profil. Twoje proponowane wartości są nadal wyświetlane. Przed ponownym zapisaniem wczytaj ponownie bieżący profil. Zatwierdzone procedury ciągłości działania + + Dzień + Przybliżona liczba członków (opcjonalnie) Model dyspozytorski + + Wybierz aktualne dokumenty z tego oddziału. Nie można odwołać się do dokumentów, które utraciły ważność. Zapisanie potwierdza istnienie dokumentu, a nie jego zatwierdzenie ani przydatność. + Wprowadź istniejące numeryczne identyfikatory dokumentów zatwierdzonych zasad w tym oddziale. Nie można odwołać się do dokumentów, które utraciły ważność. Zapisanie potwierdza istnienie dokumentu, a nie jego zatwierdzenie ani przydatność. + + Wybierz grupy z tego oddziału. Nie powoduje to przyznania dostępu do grupy ani zmiany zakresu dyspozytorskiego. + Wprowadź istniejące numeryczne identyfikatory grup z tego oddziału. Zapisanie dodaje kolejny opcjonalny wiersz. @@ -3945,6 +3963,12 @@ Języki robocze + + {0} (już niedostępne) + + + Miesiąc + Uczestniczy we wzajemnej pomocy @@ -3969,18 +3993,39 @@ Zapisz profil operacyjny + + Profil operacyjny został zapisany. + Koniec sezonu (MM-DD, opcjonalnie) + + Jeśli oddział działa sezonowo, wybierz miesiąc i dzień rozpoczęcia oraz zakończenia sezonu. W przeciwnym razie pozostaw sezon pusty. Sezon może obejmować przełom roku. + Początek sezonu (MM-DD, opcjonalnie) + + Organizacja i działania + + + Języki i dostępność + + + Lokalizacje, zasady i systemy + + + Działalność sezonowa + Lokalizacje i grupy obszarów działania Zatwierdzone zasady obsady + + Wpisz etykietę i naciśnij Enter + Użyj krótkiej etykiety odwołania do systemu źródłowego. Te etykiety są deklaracjami — zapisanie nie weryfikuje połączenia ani importu. @@ -6324,6 +6369,9 @@ Zweryfikuj bieżące dane oraz lokalne procedury + + +{0} więcej + Bieżący dostęp do źródła niedostępny @@ -6495,6 +6543,9 @@ Weryfikacja aktualizuje ustalenia. Przypisanie oraz zaakceptowane wyjątki nie rozwiązują nieudanej kontroli. Rozwiązuje ją dopiero nowa, zakończona powodzeniem kontrola. + + Admin AI + Zapytaj Admin Assist diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.resx index 48f0e7b28..61bbd0efe 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.resx @@ -3921,21 +3921,39 @@ Authoritative source systems + + Choose documents + + + Choose groups + + + Clear season + Another administrator changed this profile. Your proposed values are still shown. Reload the current profile before saving again. Approved continuity procedures + + Day + Approximate member count (optional) Dispatch model + + Choose current documents from this department. Expired documents cannot be referenced. Saving verifies existence, not approval or suitability. + Enter existing numeric document IDs for approved policies in this department. Expired documents cannot be referenced. Saving verifies existence, not approval or suitability. + + Choose groups from this department. This does not grant group access or change dispatch scope. + Enter existing numeric group IDs from this department. Saving adds another optional row. @@ -3945,6 +3963,12 @@ Working languages + + {0} (no longer available) + + + Month + Participates in mutual aid @@ -3969,18 +3993,39 @@ Save operating profile + + Operating profile saved. + Season ends (MM-DD, optional) + + If the department operates seasonally, choose the month and day the season starts and ends. Leave the season blank if it does not. A season can run across the new year. + Season starts (MM-DD, optional) + + Organization and operations + + + Languages and accessibility + + + Sites, policies and systems + + + Seasonal operation + Sites and service-area groups Approved staffing policies + + Type a label and press Enter + Use a short source-system reference label. These labels are declarations; saving does not verify a connection or import. @@ -6324,6 +6369,9 @@ Verify current evidence and local procedures + + +{0} more + Current source access unavailable @@ -6495,11 +6543,14 @@ Verification updates findings. Assignment and accepted exceptions do not resolve a failed check. A fresh passing check resolves it. + + Admin AI + Ask Admin Assist - Explore Resgrid and add-ons + Explore Resgrid & Addons Health checks diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.sv.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.sv.resx index 98dc54f3a..bcfb1e4f9 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.sv.resx @@ -3921,21 +3921,39 @@ Auktoritativa källsystem + + Välj dokument + + + Välj grupper + + + Rensa säsong + En annan administratör har ändrat den här profilen. Dina föreslagna värden visas fortfarande. Läs in den aktuella profilen igen innan du sparar på nytt. Godkända kontinuitetsrutiner + + Dag + Ungefärligt antal medlemmar (valfritt) Utlarmningsmodell + + Välj aktuella dokument i den här avdelningen. Utgångna dokument kan inte refereras. Att spara verifierar att dokumentet finns, inte att det är godkänt eller lämpligt. + Ange befintliga numeriska dokument-id för godkända policyer i den här avdelningen. Utgångna dokument kan inte refereras. Att spara verifierar att dokumentet finns, inte att det är godkänt eller lämpligt. + + Välj grupper i den här avdelningen. Det ger inte gruppåtkomst eller ändrar utlarmningens omfattning. + Ange befintliga numeriska grupp-id från den här avdelningen. Att spara lägger till ytterligare en valfri rad. @@ -3945,6 +3963,12 @@ Arbetsspråk + + {0} (inte längre tillgänglig) + + + Månad + Deltar i ömsesidig hjälp @@ -3969,18 +3993,39 @@ Spara driftprofil + + Driftprofilen har sparats. + Säsongen slutar (MM-DD, valfritt) + + Om avdelningen arbetar säsongsvis väljer du månad och dag då säsongen börjar och slutar. Annars lämnar du säsongen tom. En säsong kan sträcka sig över årsskiftet. + Säsongen börjar (MM-DD, valfritt) + + Organisation och verksamhet + + + Språk och tillgänglighet + + + Platser, policyer och system + + + Säsongsverksamhet + Platser och grupper för serviceområden Godkända bemanningspolicyer + + Skriv en etikett och tryck på Retur + Använd en kort referensetikett för källsystemet. Etiketterna är deklarationer – att spara verifierar inte en anslutning eller import. @@ -6324,6 +6369,9 @@ Verifiera aktuellt underlag och lokala rutiner + + +{0} till + Aktuell källåtkomst inte tillgänglig @@ -6495,6 +6543,9 @@ Verifiering uppdaterar fynd. Tilldelning och godkända undantag löser inte en misslyckad kontroll. En ny godkänd kontroll löser den. + + Admin AI + Fråga Admin Assist diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.uk.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.uk.resx index 921b9882d..8e2d0b835 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.uk.resx @@ -3921,21 +3921,39 @@ Офіційні системи-джерела + + Виберіть документи + + + Виберіть групи + + + Очистити сезон + Інший адміністратор змінив цей профіль. Запропоновані вами значення все ще відображаються. Перш ніж зберігати знову, перезавантажте поточний профіль. Затверджені процедури безперервності + + День + Приблизна кількість учасників (необов'язково) Модель диспетчеризації + + Виберіть чинні документи з цього підрозділу. На прострочені документи посилатися не можна. Збереження перевіряє лише наявність, а не затвердження чи придатність. + Введіть наявні числові ID документів для затверджених політик у цьому підрозділі. На прострочені документи посилатися не можна. Збереження перевіряє лише наявність, а не затвердження чи придатність. + + Виберіть групи з цього підрозділу. Це не надає доступу до групи та не змінює область диспетчеризації. + Введіть наявні числові ID груп цього підрозділу. Збереження додає ще один необов'язковий рядок. @@ -3945,6 +3963,12 @@ Робочі мови + + {0} (більше не доступно) + + + Місяць + Бере участь у взаємній допомозі @@ -3969,18 +3993,39 @@ Зберегти операційний профіль + + Операційний профіль збережено. + Закінчення сезону (ММ-ДД, необов'язково) + + Якщо підрозділ працює сезонно, виберіть місяць і день початку та завершення сезону. Інакше залиште сезон порожнім. Сезон може тривати й після початку нового року. + Початок сезону (ММ-ДД, необов'язково) + + Організація та діяльність + + + Мови та доступність + + + Об’єкти, політики та системи + + + Сезонна робота + Об'єкти та групи зон обслуговування Затверджені політики укомплектованості + + Введіть мітку й натисніть Enter + Використовуйте коротку позначку системи-джерела. Ці позначки є декларативними; збереження не перевіряє з'єднання чи імпорт. @@ -6324,6 +6369,9 @@ Перевірте поточні дані та місцеві процедури + + ще {0} + Поточний доступ до джерела недоступний @@ -6495,6 +6543,9 @@ Перевірка оновлює висновки. Призначення та прийняті винятки не вирішують проблему в невдалій перевірці. Її вирішує нова успішна перевірка. + + Admin AI + Запитати Admin Assist diff --git a/Core/Resgrid.Model/AdminAssist/AdminAssistContracts.cs b/Core/Resgrid.Model/AdminAssist/AdminAssistContracts.cs index 6449ac62c..c8c2852c0 100644 --- a/Core/Resgrid.Model/AdminAssist/AdminAssistContracts.cs +++ b/Core/Resgrid.Model/AdminAssist/AdminAssistContracts.cs @@ -20,6 +20,16 @@ public interface IAdminAssistEvidenceSource IReadOnlyList EvidenceIds { get; } Task> ReadAsync(AdminAssistActor actor, DateTime asOfUtc, CancellationToken cancellationToken); } + /// + /// Names the items a failing finding counts, such as the groups with no active members. Evidence stays scalar; these are + /// read fresh only to show an administrator on an attended page, and are never persisted, sent in a digest or given to a model. + /// + public interface IAdminAssistFindingSubjectSource + { + IReadOnlyList RuleIds { get; } + Task> ReadAsync(AdminAssistActor actor, string ruleId, CancellationToken cancellationToken); + } + public sealed record FindingSubject(string Id, string Name); public interface IAdminAssistAccessService { Task CanAccessAsync(AdminAssistActor actor, bool setup, CancellationToken cancellationToken = default); @@ -34,6 +44,8 @@ public interface IConfigurationRule public interface IAdminAssistService { Task GetOverviewAsync(AdminAssistActor actor, bool setup, CancellationToken cancellationToken = default); + /// Display-only names for the report's failing findings, keyed by rule id. Only attended pages call this. + Task>> GetFindingSubjectsAsync(AdminAssistActor actor, bool setup, ConfigurationReport report, CancellationToken cancellationToken = default); Task UpdateSetupAsync(AdminAssistActor actor, SetupProgressCommand command, CancellationToken cancellationToken = default); Task> GetHistoryAsync(AdminAssistActor actor, int skip, int take, CancellationToken cancellationToken = default); } @@ -50,7 +62,8 @@ public sealed record SetupProgressCommand(long ExpectedRevision, string Operatio public enum SetupAreaReason { OutsideMission, OtherSystem, PartnerManaged, NoCurrentNeed } public sealed record SetupReviewEvidence(string CatalogVersion, string SnapshotRevision, DateTime AsOfUtc, int Required, int Verified, int Failed, int Unknown, long ScopeRevision = 0); public sealed record AdminAssistOverview(string CatalogVersion, SetupWorkspace Workspace, - ConfigurationReport Report, IReadOnlyList Access, IReadOnlyList CapabilitySetup = null, SetupPlan SetupPlan = null); + ConfigurationReport Report, IReadOnlyList Access, IReadOnlyList CapabilitySetup = null, SetupPlan SetupPlan = null, + IReadOnlyDictionary> FindingSubjects = null); public enum CapabilitySetupState { NotAssessed, NotConfigured, ConfigurationPresent, ChecksPassed, NeedsAttention } public sealed record CapabilitySetupAssessment(string CapabilityId, CapabilitySetupState State, string OpportunityKey, string GuidanceKey, IReadOnlyList RuleIds, string SnapshotRevision, DateTime AsOfUtc); @@ -63,6 +76,8 @@ public interface IAdminAssistRepository Task SaveDailySummaryAsync(int departmentId, ConfigurationReport report, string catalogVersion, CancellationToken cancellationToken); Task LockConfigurationAsync(int departmentId, CancellationToken cancellationToken); Task ValidateOperatingProfileReferencesAsync(int departmentId, DepartmentOperatingProfile profile, DateTime asOfUtc, CancellationToken cancellationToken); + /// The documents a profile policy reference may name (this department's, unexpired): id, name, category and protection marker only, never file contents. + Task> GetOperatingProfileDocumentOptionsAsync(int departmentId, DateTime asOfUtc, CancellationToken cancellationToken); Task AppendConfigurationChangeAsync(int departmentId, string actorId, string binding, string before, string after, string correlationId, CancellationToken cancellationToken); Task GetConfigurationRevisionAsync(int departmentId, CancellationToken cancellationToken); Task GetWorkspaceAsync(int departmentId, string userId, string catalogVersion, CancellationToken cancellationToken); diff --git a/Core/Resgrid.Model/AdminAssist/DepartmentOperatingProfile.cs b/Core/Resgrid.Model/AdminAssist/DepartmentOperatingProfile.cs index 51a65d645..e10df25d0 100644 --- a/Core/Resgrid.Model/AdminAssist/DepartmentOperatingProfile.cs +++ b/Core/Resgrid.Model/AdminAssist/DepartmentOperatingProfile.cs @@ -50,7 +50,7 @@ public IEnumerable Validate(ValidationContext validationContex if (AccessibilityNeeds == null || AccessibilityNeeds.Count > 4 || AccessibilityNeeds.Any(value => !new[] { "captions", "screen-reader", "large-text", "plain-language" }.Contains(value))) yield return new ValidationResult("Invalid accessibility preference.", new[] { nameof(AccessibilityNeeds) }); if ((SeasonStartMonthDay != null || SeasonEndMonthDay != null) && (!ValidDay(SeasonStartMonthDay) || !ValidDay(SeasonEndMonthDay))) - yield return new ValidationResult("Season dates must both use MM-DD."); + yield return new ValidationResult("Choose a valid month and day for both the season start and the season end, or leave the season blank."); } private static bool ValidDay(string value) => DateTime.TryParseExact("2000-" + value, "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out _); } diff --git a/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs b/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs index c38beef6e..c5b66aa08 100644 --- a/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs +++ b/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs @@ -10,6 +10,8 @@ public interface IDepartmentSettingsService { Task GetOperatingProfileAsync(int departmentId); Task SetOperatingProfileAsync(int departmentId, AdminAssist.DepartmentOperatingProfile profile, string actingUserId, CancellationToken cancellationToken = default); + /// Documents the operating profile's policy pickers offer: this department's unexpired documents, without file contents. + Task> GetOperatingProfileDocumentOptionsAsync(int departmentId, CancellationToken cancellationToken = default); /// /// Saves the or update setting asynchronous. /// diff --git a/Core/Resgrid.Services/AdminAssist/AdminAssistService.cs b/Core/Resgrid.Services/AdminAssist/AdminAssistService.cs index f65a92f72..67f65f3ad 100644 --- a/Core/Resgrid.Services/AdminAssist/AdminAssistService.cs +++ b/Core/Resgrid.Services/AdminAssist/AdminAssistService.cs @@ -10,8 +10,31 @@ namespace Resgrid.Services.AdminAssist { public sealed class AdminAssistService(IAdminAssistAccessService access, IAdminAssistCatalog catalog, - IConfigurationSnapshotProvider snapshots, IAdminAssistRepository repository, TimeProvider clock) : IAdminAssistService + IConfigurationSnapshotProvider snapshots, IAdminAssistRepository repository, TimeProvider clock, + IEnumerable subjectSources = null) : IAdminAssistService { + public async Task>> GetFindingSubjectsAsync(AdminAssistActor actor, bool setup, + ConfigurationReport report, CancellationToken ct = default) + { + await RequireAccessAsync(actor, setup, ct); + var subjects = new Dictionary>(StringComparer.Ordinal); + foreach (var finding in report?.Findings?.Where(f => f.Result == RuleResult.Fail) ?? Enumerable.Empty()) + { + var source = subjectSources?.FirstOrDefault(s => s.RuleIds.Contains(finding.RuleId, StringComparer.Ordinal)); + if (source == null || subjects.ContainsKey(finding.RuleId)) continue; + try + { + var names = await source.ReadAsync(actor, finding.RuleId, ct).WaitAsync(ct); + if (names?.Count > 0) subjects[finding.RuleId] = names; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + // Names are an aid to the finding, not evidence: when they cannot be read the finding still shows without them. + catch (Exception ex) { Resgrid.Framework.Logging.LogException(ex, "Admin Assist finding subjects unavailable for " + finding.RuleId); } + } + await RequireAccessAsync(actor, setup, ct); + return subjects; + } + public async Task GetOverviewAsync(AdminAssistActor actor, bool setup, CancellationToken ct = default) { using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); diff --git a/Core/Resgrid.Services/AdminAssist/DepartmentSettingsService.OperatingProfile.cs b/Core/Resgrid.Services/AdminAssist/DepartmentSettingsService.OperatingProfile.cs index f96dc5d67..08cba824b 100644 --- a/Core/Resgrid.Services/AdminAssist/DepartmentSettingsService.OperatingProfile.cs +++ b/Core/Resgrid.Services/AdminAssist/DepartmentSettingsService.OperatingProfile.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Threading; using System.Threading.Tasks; @@ -17,6 +18,12 @@ public async Task GetOperatingProfileAsync(int depar ?? throw new InvalidOperationException("Operating profile could not be read."); } + public async Task> GetOperatingProfileDocumentOptionsAsync(int departmentId, CancellationToken cancellationToken = default) + { + if (departmentId <= 0 || _operatingProfileRepository == null) return Array.Empty(); + return await _operatingProfileRepository.GetOperatingProfileDocumentOptionsAsync(departmentId, DateTime.UtcNow, cancellationToken); + } + public async Task SetOperatingProfileAsync(int departmentId, DepartmentOperatingProfile profile, string actingUserId, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(profile); diff --git a/Core/Resgrid.Services/AdminAssist/OrganizationFindingSubjects.cs b/Core/Resgrid.Services/AdminAssist/OrganizationFindingSubjects.cs new file mode 100644 index 000000000..c9f13fd29 --- /dev/null +++ b/Core/Resgrid.Services/AdminAssist/OrganizationFindingSubjects.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.AdminAssist +{ + /// + /// Names the groups behind the empty-groups finding, using the same test as 's + /// emptyGroupCount: a group is empty when none of its members is an assignable member of the department. + /// + public sealed class OrganizationFindingSubjects(IDepartmentGroupsRepository groups, IDepartmentsService departments, + IRecordsAuthorizationService membership) : IAdminAssistFindingSubjectSource + { + public const string EmptyGroups = "empty-groups"; + public IReadOnlyList RuleIds { get; } = new[] { EmptyGroups }; + + public async Task> ReadAsync(AdminAssistActor actor, string ruleId, CancellationToken ct) + { + if (ruleId != EmptyGroups || actor == null || actor.DepartmentId <= 0) return Array.Empty(); + var groupRows = (await groups.GetAllGroupsByDepartmentIdAsync(actor.DepartmentId).WaitAsync(ct))?.ToList() + ?? throw new InvalidOperationException("Group metadata unavailable."); + var members = await departments.GetAllUsersForDepartmentUnlimitedMinusDisabledAsync(actor.DepartmentId, true).WaitAsync(ct) + ?? throw new InvalidOperationException("Member metadata unavailable."); + if (groupRows.Count + members.Count > Math.Clamp(Config.AdminAssistConfig.MaxEvidenceRows, 1, 10000) || + groupRows.Any(g => g.DepartmentId != actor.DepartmentId || g.Members == null)) + throw new InvalidOperationException("Scope or row bound exceeded."); + var active = await membership.GetAssignableMemberIdsAsync(members.Select(m => m.UserId), actor.DepartmentId).WaitAsync(ct); + return groupRows + .Where(g => !g.Members.Any(m => active.Contains(m.UserId))) + .Select(g => new FindingSubject(g.DepartmentGroupId.ToString(CultureInfo.InvariantCulture), + string.IsNullOrWhiteSpace(g.Name) ? "#" + g.DepartmentGroupId.ToString(CultureInfo.InvariantCulture) : g.Name.Trim())) + .OrderBy(s => s.Name, StringComparer.CurrentCultureIgnoreCase) + .ToArray(); + } + } +} diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index 30c8b96ee..4ce3acbb4 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -23,6 +23,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/Repositories/Resgrid.Repositories.DataRepository/AdminAssistRepository.OperatingProfile.cs b/Repositories/Resgrid.Repositories.DataRepository/AdminAssistRepository.OperatingProfile.cs index 25b79e2fd..ce7d64f3a 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/AdminAssistRepository.OperatingProfile.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/AdminAssistRepository.OperatingProfile.cs @@ -29,5 +29,11 @@ public async Task ValidateOperatingProfileReferencesAsync(int departmentId } return true; } + + public async Task> GetOperatingProfileDocumentOptionsAsync(int departmentId, DateTime asOfUtc, CancellationToken ct) => + // The same unexpired documents the save accepts; the file bytes are never read for a picker. + (await QueryAsync($"SELECT {Cols("DocumentId", "DepartmentId", "Name", "Category", "IsProtected")} FROM {Tbl("Documents")} " + + $"WHERE {Col("DepartmentId")}={P}DepartmentId AND ({Col("RemoveOn")} IS NULL OR {Col("RemoveOn")}>{P}AsOfUtc)", + new { DepartmentId = departmentId, AsOfUtc = DatabaseTimestamp(asOfUtc) }, ct)).ToList(); } } diff --git a/Tests/Resgrid.Tests/AdminAssist/AdminAssistDatabaseTests.cs b/Tests/Resgrid.Tests/AdminAssist/AdminAssistDatabaseTests.cs index 4c7cac3f4..ea2e3887e 100644 --- a/Tests/Resgrid.Tests/AdminAssist/AdminAssistDatabaseTests.cs +++ b/Tests/Resgrid.Tests/AdminAssist/AdminAssistDatabaseTests.cs @@ -71,7 +71,7 @@ public async Task Create_isolated_database() CREATE TABLE {Q("PlanAddons")} ({Q("PlanAddonId")} {text}(36) PRIMARY KEY,{Q("AddonType")} int,{Q("Cost")} decimal(18,2),{Q("ExternalId")} {text}(128),{Q("TestExternalId")} {text}(128)); CREATE TABLE {Q("RmsRecordLegalHolds")} ({Q("DepartmentId")} int,{Q("ReleasedOn")} {date},{Q("RmsRecordLegalHoldId")} {text}(128),{Q("RecordId")} {text}(128),{Q("DefinitionKey")} {text}(128)); CREATE TABLE {Q("DepartmentGroups")} ({Q("DepartmentId")} int,{Q("DepartmentGroupId")} int); -CREATE TABLE {Q("Documents")} ({Q("DepartmentId")} int,{Q("DocumentId")} int,{Q("RemoveOn")} {date}); +CREATE TABLE {Q("Documents")} ({Q("DepartmentId")} int,{Q("DocumentId")} int,{Q("RemoveOn")} {date},{Q("Name")} {text}(256),{Q("Category")} {text}(256),{Q("IsProtected")} {boolean},{Q("Data")} {(type == DatabaseTypes.Postgres ? "bytea" : "varbinary(max)")}); CREATE TABLE {Q("DepartmentMembers")} ({Q("DepartmentMemberId")} int,{Q("DepartmentId")} int,{Q("UserId")} {text}(128),{Q("IsDeleted")} {boolean},{Q("IsDisabled")} {boolean},{Q("IsHidden")} {boolean},{Q("PasswordLastSetOn")} {date}); CREATE TABLE {Q("AspNetUsers")} ({Q("Id")} {text}(128) PRIMARY KEY,{Q("TwoFactorEnabled")} {boolean},{Q("AuthenticationGeneration")} bigint); CREATE TABLE {Q("ActionLogs")} ({Q("ActionLogId")} int PRIMARY KEY,{Q("UserId")} {text}(128),{Q("DepartmentId")} int,{Q("ActionTypeId")} int,{Q("Timestamp")} {date},{Q("GeoLocationData")} {text}(128)); @@ -418,6 +418,22 @@ public async Task Administrative_references_detect_cross_tenant_missing_and_expi Assert.That(counts.ExpiringPolicies, Is.EqualTo(1)); Assert.That(counts.UnavailableSites, Is.EqualTo(1)); } + [Test] + public async Task Operating_profile_document_options_are_this_departments_unexpired_documents_without_contents() + { + await using var db = Connect(_connection); var now = new DateTime(2026, 9, 24, 12, 0, 0); + await db.ExecuteAsync($"INSERT INTO {Q("Documents")} ({Q("DepartmentId")},{Q("DocumentId")},{Q("Name")},{Q("Category")},{Q("IsProtected")},{Q("RemoveOn")},{Q("Data")}) " + + "VALUES (705,9201,'Staffing policy','Policies',@False,NULL,@Data),(705,9202,'Expired policy','Policies',@False,@Expired,@Data),(705,9203,'Protected procedure',NULL,@True,@Later,@Data),(706,9204,'Other department',NULL,@False,NULL,@Data)", + new { False = false, True = true, Expired = now.AddDays(-1), Later = now.AddDays(3), Data = new byte[] { 1, 2, 3 } }); + using var unit = new UnitOfWork(Connections()); + var options = await Repository(unit).GetOperatingProfileDocumentOptionsAsync(705, now, CancellationToken.None); + Assert.That(options.Select(d => d.DocumentId).OrderBy(id => id), Is.EqualTo(new[] { 9201, 9203 })); + var policy = options.Single(d => d.DocumentId == 9201); + Assert.That((policy.Name, policy.Category, policy.IsProtected), Is.EqualTo(("Staffing policy", "Policies", false))); + Assert.That(options.Single(d => d.DocumentId == 9203).IsProtected, Is.True); + Assert.That(options.All(d => d.Data == null), Is.True, "A picker never reads file contents."); + } + [Test] public async Task Module_preview_counts_are_bounded_tenant_metadata_and_keep_hidden_current_members() { diff --git a/Tests/Resgrid.Tests/AdminAssist/AdminAssistPageTests.cs b/Tests/Resgrid.Tests/AdminAssist/AdminAssistPageTests.cs new file mode 100644 index 000000000..ac75a4dd6 --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/AdminAssistPageTests.cs @@ -0,0 +1,117 @@ +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Services; +using Resgrid.Web.Areas.User.Controllers; +using Resgrid.Web.Helpers; + +namespace Resgrid.Tests.AdminAssist +{ + /// + /// Admin Assist is one page with Setup Wizard, Setup Report, Explore and Admin AI tabs. The setup tabs follow Admin.Setup; + /// Admin AI needs Admin.Assist and Ai.AdminAssist, and until then the page runs in setup mode with that tab disabled. + /// + [TestFixture, NonParallelizable] + public class AdminAssistPageTests + { + private IHttpContextAccessor _previousAccessor; + private Mock _access; + private Mock _flags; + private AdminAssistController _controller; + + [SetUp] + public void SetUp() + { + _previousAccessor = ClaimsAuthorizationHelper._httpContextAccessor; + var http = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { new Claim(ClaimTypes.PrimarySid, "admin"), new Claim(ClaimTypes.PrimaryGroupSid, "7") }, "Test")) }; + ClaimsAuthorizationHelper._httpContextAccessor = new HttpContextAccessor { HttpContext = http }; + _access = new Mock(); + _flags = new Mock(); + var protection = new Mock(); + protection.Setup(p => p.IsProtectionEnforcedAsync(7)).ReturnsAsync(false); + _controller = new AdminAssistController(_access.Object, Mock.Of(), protection.Object, _flags.Object) + { + ControllerContext = new ControllerContext { HttpContext = http } + }; + } + + [TearDown] + public void TearDown() => ClaimsAuthorizationHelper._httpContextAccessor = _previousAccessor; + + private void Rollout(bool setup, bool assist, bool ai) + { + // The access service carries the membership check and each rollout flag; the page reads the AI flag itself. + _access.Setup(a => a.CanAccessAsync(It.IsAny(), true, It.IsAny())).ReturnsAsync(setup); + _access.Setup(a => a.CanAccessAsync(It.IsAny(), false, It.IsAny())).ReturnsAsync(assist); + _flags.Setup(f => f.EvaluateFreshAsync(FeatureFlagKeys.AdminAssist, 7)).ReturnsAsync(new FeatureFlagEvaluation { IsEnabled = assist }); + _flags.Setup(f => f.EvaluateFreshAsync(FeatureFlagKeys.AiAdminAssist, 7)).ReturnsAsync(new FeatureFlagEvaluation { IsEnabled = ai }); + } + + private static (string Page, bool Setup) Page(IActionResult result) + { + Assert.That(result, Is.InstanceOf()); + var view = (ViewResult)result; + Assert.That(view.ViewName, Is.EqualTo("Index")); + return ((string)view.ViewData["AdminAssistPage"], (bool)view.ViewData["AdminAssistSetup"]); + } + + [Test] + public async Task Before_Admin_AI_launches_every_route_opens_the_page_in_setup_mode() + { + Rollout(setup: true, assist: true, ai: false); + + Assert.That(Page(await _controller.Index(default)), Is.EqualTo(("wizard", true))); + Assert.That(Page(await _controller.SetupWizard(default)), Is.EqualTo(("wizard", true))); + Assert.That(Page(await _controller.SetupReport(default)), Is.EqualTo(("report", true))); + } + + [Test] + public async Task With_Admin_AI_on_the_page_reads_the_workspace() + { + Rollout(setup: true, assist: true, ai: true); + + Assert.That(Page(await _controller.Index(default)), Is.EqualTo(("wizard", false))); + Assert.That(Page(await _controller.SetupReport(default)), Is.EqualTo(("report", false))); + } + + [Test] + public async Task Admin_AI_alone_opens_the_page_without_the_setup_rollout() + { + Rollout(setup: false, assist: true, ai: true); + + Assert.That(Page(await _controller.Index(default)), Is.EqualTo(("wizard", false))); + } + + [TestCase(false, false)] + [TestCase(false, true)] + public async Task Page_is_not_found_when_neither_setup_nor_Admin_AI_is_available(bool assist, bool ai) + { + Rollout(setup: false, assist: assist, ai: ai); + + Assert.That(await _controller.Index(default), Is.InstanceOf()); + Assert.That(await _controller.SetupWizard(default), Is.InstanceOf()); + } + + [Test] + public async Task Plans_belongs_to_Admin_AI() + { + Rollout(setup: true, assist: true, ai: false); + var before = Resgrid.Config.AdminAssistConfig.PlansEnabled; + try + { + Resgrid.Config.AdminAssistConfig.PlansEnabled = true; + Assert.That(await _controller.Plans(default), Is.InstanceOf()); + Rollout(setup: true, assist: true, ai: true); + Assert.That(Page(await _controller.Plans(default)), Is.EqualTo(("plans", false))); + } + finally { Resgrid.Config.AdminAssistConfig.PlansEnabled = before; } + } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/FindingSubjectsTests.cs b/Tests/Resgrid.Tests/AdminAssist/FindingSubjectsTests.cs new file mode 100644 index 000000000..cba39cb9c --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/FindingSubjectsTests.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Resgrid.AdminAssist; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Identity; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services.AdminAssist; + +namespace Resgrid.Tests.AdminAssist +{ + /// The empty-groups finding names its groups on attended pages; evidence and everything downstream stay scalar. + [TestFixture] + public class FindingSubjectsTests + { + private static readonly AdminAssistActor Actor = new(7, "admin"); + private Mock _groups; + private Mock _departments; + private Mock _membership; + private OrganizationFindingSubjects _subjects; + + private static DepartmentGroup Group(int id, string name, params string[] members) => new() + { + DepartmentGroupId = id, DepartmentId = 7, Name = name, + Members = members.Select(m => new DepartmentGroupMember { DepartmentGroupId = id, UserId = m }).ToList() + }; + + [SetUp] + public void SetUp() + { + _groups = new(); _departments = new(); _membership = new(); + _groups.Setup(g => g.GetAllGroupsByDepartmentIdAsync(7)).ReturnsAsync(new[] + { + Group(1, "Station 1", "active"), Group(2, "Training", "hidden"), Group(3, "Hazmat"), Group(4, " "), Group(5, "Admin team", "hidden", "active") + }); + _departments.Setup(d => d.GetAllUsersForDepartmentUnlimitedMinusDisabledAsync(7, true)) + .ReturnsAsync(new List { new() { Id = "active" }, new() { Id = "hidden" } }); + // The evidence's test: only assignable members make a group active (hidden, disabled and removed members do not). + _membership.Setup(m => m.GetAssignableMemberIdsAsync(It.IsAny>(), 7)).ReturnsAsync(new HashSet { "active" }); + _subjects = new OrganizationFindingSubjects(_groups.Object, _departments.Object, _membership.Object); + } + + [Test] + public async Task Empty_groups_are_named_alphabetically_with_an_id_when_unnamed() + { + var subjects = await _subjects.ReadAsync(Actor, "empty-groups", CancellationToken.None); + + Assert.That(subjects.Select(s => (s.Id, s.Name)), Is.EqualTo(new[] { ("4", "#4"), ("3", "Hazmat"), ("2", "Training") })); + } + + [Test] + public async Task Other_rules_read_nothing() + { + Assert.That(await _subjects.ReadAsync(Actor, "unit-type", CancellationToken.None), Is.Empty); + _groups.VerifyNoOtherCalls(); _departments.VerifyNoOtherCalls(); _membership.VerifyNoOtherCalls(); + } + + [Test] + public void A_group_outside_the_department_is_refused() + { + _groups.Setup(g => g.GetAllGroupsByDepartmentIdAsync(7)).ReturnsAsync(new[] { Group(1, "Station 1"), new DepartmentGroup { DepartmentGroupId = 9, DepartmentId = 8, Name = "Other", Members = new List() } }); + + Assert.ThrowsAsync(async () => await _subjects.ReadAsync(Actor, "empty-groups", CancellationToken.None)); + } + + private static ConfigurationReport Report(params (string RuleId, RuleResult Result)[] findings) + { + var snapshot = new ConfigurationSnapshot(7, "admin", "1", DateTime.UtcNow, true, new Dictionary()); + return new ConfigurationReport(snapshot, findings.Select(f => new ConfigurationFinding(f.RuleId, "people", FindingSeverity.Warning, f.Result, + "Rule." + f.RuleId, "RuleWhy." + f.RuleId, "RuleNext." + f.RuleId, "/User/Groups/Index", Array.Empty(), "1", DateTime.UtcNow)).ToArray(), new[] { "people" }); + } + + private static (AdminAssistService Service, Mock Access, Mock Source) Service() + { + var access = new Mock(); + access.Setup(a => a.CanAccessAsync(Actor, true, It.IsAny())).ReturnsAsync(true); + var source = new Mock(); + source.SetupGet(s => s.RuleIds).Returns(new[] { "empty-groups" }); + source.Setup(s => s.ReadAsync(Actor, "empty-groups", It.IsAny())).ReturnsAsync(new[] { new FindingSubject("3", "Hazmat") }); + var service = new AdminAssistService(access.Object, new ConfigurationCatalog(), Mock.Of(), Mock.Of(), + TimeProvider.System, new[] { source.Object }); + return (service, access, source); + } + + [Test] + public async Task Only_a_failing_finding_is_named() + { + var (service, _, source) = Service(); + + var failing = await service.GetFindingSubjectsAsync(Actor, true, Report(("empty-groups", RuleResult.Fail), ("unit-type", RuleResult.Fail))); + Assert.That(failing.Keys, Is.EqualTo(new[] { "empty-groups" })); + Assert.That(failing["empty-groups"].Single().Name, Is.EqualTo("Hazmat")); + + foreach (var result in new[] { RuleResult.Pass, RuleResult.Unknown, RuleResult.NotApplicable }) + Assert.That(await service.GetFindingSubjectsAsync(Actor, true, Report(("empty-groups", result))), Is.Empty, result.ToString()); + source.Verify(s => s.ReadAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public void Names_are_not_read_without_access() + { + var (service, access, source) = Service(); + access.Setup(a => a.CanAccessAsync(Actor, true, It.IsAny())).ReturnsAsync(false); + + Assert.ThrowsAsync(async () => await service.GetFindingSubjectsAsync(Actor, true, Report(("empty-groups", RuleResult.Fail)))); + source.Verify(s => s.ReadAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task A_failed_name_read_leaves_the_finding_without_names() + { + var (service, _, source) = Service(); + source.Setup(s => s.ReadAsync(Actor, "empty-groups", It.IsAny())).ThrowsAsync(new InvalidOperationException("Scope or row bound exceeded.")); + + Assert.That(await service.GetFindingSubjectsAsync(Actor, true, Report(("empty-groups", RuleResult.Fail))), Is.Empty); + } + + [Test] + public void Caller_cancellation_is_not_swallowed() + { + var (service, _, source) = Service(); + using var cancellation = new CancellationTokenSource(); + source.Setup(s => s.ReadAsync(Actor, "empty-groups", It.IsAny())).Returns(async () => { cancellation.Cancel(); await Task.Yield(); cancellation.Token.ThrowIfCancellationRequested(); return Array.Empty(); }); + + Assert.CatchAsync(async () => await service.GetFindingSubjectsAsync(Actor, true, Report(("empty-groups", RuleResult.Fail)), cancellation.Token)); + } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/OperatingProfileScreenTests.cs b/Tests/Resgrid.Tests/AdminAssist/OperatingProfileScreenTests.cs new file mode 100644 index 000000000..7592726a6 --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/OperatingProfileScreenTests.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.Linq; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Services; +using Resgrid.Web.Areas.User.Controllers; +using Resgrid.Web.Helpers; + +namespace Resgrid.Tests.AdminAssist +{ + /// Season start and end are picked as a month and a day and stored as MM-DD. + [TestFixture] + public class SeasonMonthDayTests + { + private static bool Valid(string start, string end) + { + var profile = new DepartmentOperatingProfile { SeasonStartMonthDay = start, SeasonEndMonthDay = end }; + return Validator.TryValidateObject(profile, new ValidationContext(profile), new List(), true); + } + + [TestCase("03", "15", "03-15")] + [TestCase("3", "5", "03-05")] + [TestCase("", "", null)] + [TestCase(null, null, null)] + public void Compose_builds_MM_DD_or_no_season(string month, string day, string expected) => + Assert.That(SeasonMonthDay.Compose(month, day), Is.EqualTo(expected)); + + [TestCase("03", "")] + [TestCase("", "15")] + [TestCase("02", "30")] + [TestCase("13", "01")] + [TestCase("x", "1")] + [TestCase("-1", "5")] + public void A_partial_or_impossible_pick_fails_profile_validation_instead_of_clearing_the_season(string month, string day) => + Assert.That(Valid(SeasonMonthDay.Compose(month, day), "11-15"), Is.False); + + [Test] + public void Every_day_a_picker_offers_is_a_valid_season_boundary_and_the_next_one_is_not() + { + for (var month = 1; month <= 12; month++) + { + var last = SeasonMonthDay.DaysIn(month); + Assert.That(Valid(SeasonMonthDay.Compose(month.ToString(), last.ToString()), "01-01"), Is.True, "month " + month); + if (last < 31) Assert.That(Valid(SeasonMonthDay.Compose(month.ToString(), (last + 1).ToString()), "01-01"), Is.False, "month " + month); + } + Assert.That(SeasonMonthDay.DaysIn(2), Is.EqualTo(29), "A season boundary may fall on the leap day."); + } + + [TestCase("03-15", 3, 15)] + [TestCase("02-29", 2, 29)] + [TestCase(null, 0, 0)] + [TestCase("3-15", 0, 0)] + [TestCase("ab-cd", 0, 0)] + public void Split_reads_a_stored_value(string value, int month, int day) => + Assert.That(SeasonMonthDay.Split(value), Is.EqualTo((month, day))); + + private static string[] Locales => Resgrid.Localization.SupportedLocales.SupportedLanguagesMap.Keys.ToArray(); + + [TestCaseSource(nameof(Locales))] + public void Month_names_are_the_twelve_Gregorian_months_in_the_UI_language(string locale) + { + var culture = CultureInfo.GetCultureInfo(locale); + var names = SeasonMonthDay.MonthNames(culture); + var gregorian = (CultureInfo)culture.Clone(); + if (gregorian.DateTimeFormat.Calendar is not GregorianCalendar) + gregorian.DateTimeFormat.Calendar = culture.OptionalCalendars.OfType().First(); + + Assert.That(names, Has.Length.EqualTo(12)); + for (var month = 1; month <= 12; month++) + { + var expected = new DateTime(2001, month, 1).ToString("MMMM", gregorian); + Assert.That(names[month - 1], Is.EqualTo(culture.TextInfo.ToUpper(expected[0]) + expected[1..]), locale + " month " + month); + } + } + } + + /// The operating profile screen composes picked seasons and offers reference pickers only when it redisplays. + [TestFixture, NonParallelizable] + public class OperatingProfileControllerTests + { + private readonly Dictionary _mocks = new(); + private IHttpContextAccessor _previousAccessor; + private DepartmentController _controller; + private DepartmentOperatingProfile _saved; + + private Mock Of() where T : class => (Mock)_mocks[typeof(T)]; + + [SetUp] + public void SetUp() + { + var constructor = typeof(DepartmentController).GetConstructors().Single(); + var arguments = constructor.GetParameters().Select(parameter => + { + var mock = (Mock)Activator.CreateInstance(typeof(Mock<>).MakeGenericType(parameter.ParameterType)); + _mocks[parameter.ParameterType] = mock; + return mock.Object; + }).ToArray(); + _controller = (DepartmentController)constructor.Invoke(arguments); + + _previousAccessor = ClaimsAuthorizationHelper._httpContextAccessor; + var http = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { new Claim(ClaimTypes.PrimarySid, "admin"), new Claim(ClaimTypes.PrimaryGroupSid, "7") }, "Test")) }; + ClaimsAuthorizationHelper._httpContextAccessor = new HttpContextAccessor { HttpContext = http }; + _controller.ControllerContext = new ControllerContext { HttpContext = http }; + _controller.TempData = new TempDataDictionary(http, Mock.Of()); + _controller.ObjectValidator = Mock.Of(); + + Of().Setup(f => f.EvaluateFreshAsync(FeatureFlagKeys.AdminSetup, 7)).ReturnsAsync(new FeatureFlagEvaluation { IsEnabled = true }); + Of().Setup(d => d.GetDepartmentMemberAsync("admin", 7, true)).ReturnsAsync(new DepartmentMember { DepartmentId = 7, UserId = "admin", IsAdmin = true }); + Of().Setup(d => d.GetDepartmentByIdAsync(7, true)).ReturnsAsync(new Department { DepartmentId = 7, TimeZone = "Pacific Standard Time" }); + Of().Setup(g => g.GetAllGroupsForDepartmentUnlimitedThinAsync(7)) + .ReturnsAsync(new List { new() { DepartmentGroupId = 13, Name = "Station 2" }, new() { DepartmentGroupId = 12, Name = "Station 1" } }); + Of().Setup(s => s.GetOperatingProfileDocumentOptionsAsync(7, It.IsAny())) + .ReturnsAsync(new List { new() { DocumentId = 40, Name = "Staffing policy", Category = "Policies" }, new() { DocumentId = 39, Name = "Loose note" } }); + Of().Setup(s => s.SetOperatingProfileAsync(7, It.IsAny(), "admin", It.IsAny())) + .Callback((_, profile, _, _) => _saved = profile).ReturnsAsync(new DepartmentSetting()); + } + + [TearDown] + public void TearDown() => ClaimsAuthorizationHelper._httpContextAccessor = _previousAccessor; + + [Test] + public async Task Saving_stores_the_picked_season_as_MM_DD_and_confirms() + { + var result = await _controller.OperatingProfile(new DepartmentOperatingProfile { Revision = 3, SiteGroupReferences = new() { "12", "" } }, "11", "15", "02", "29", default); + + Assert.That(result, Is.InstanceOf()); + Assert.That(_saved.SeasonStartMonthDay, Is.EqualTo("11-15")); + Assert.That(_saved.SeasonEndMonthDay, Is.EqualTo("02-29")); + Assert.That(_saved.SiteGroupReferences, Is.EqualTo(new[] { "12" })); + Assert.That(_controller.TempData["OperatingProfileSaved"], Is.EqualTo(true)); + // The pickers are only needed when the form is shown again. + Of().Verify(s => s.GetOperatingProfileDocumentOptionsAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Blank_pickers_save_no_season() + { + await _controller.OperatingProfile(new DepartmentOperatingProfile { SeasonStartMonthDay = "03-01", SeasonEndMonthDay = "09-30" }, "", "", "", "", default); + + Assert.That(_saved.SeasonStartMonthDay, Is.Null); + Assert.That(_saved.SeasonEndMonthDay, Is.Null); + } + + [Test] + public async Task A_redisplay_offers_department_groups_and_current_documents_without_reading_file_contents() + { + Of().Setup(s => s.SetOperatingProfileAsync(7, It.IsAny(), "admin", It.IsAny())) + .ThrowsAsync(new AdminAssistConcurrencyException()); + + var result = await _controller.OperatingProfile(new DepartmentOperatingProfile(), "", "", "", "", default); + + Assert.That(result, Is.InstanceOf()); + Assert.That(_controller.Response.StatusCode, Is.EqualTo(409)); + var groups = (List)_controller.ViewData["ProfileGroups"]; + Assert.That(groups.Select(g => (g.Text, g.Value)), Is.EqualTo(new[] { ("Station 1", "12"), ("Station 2", "13") })); + var documents = (List)_controller.ViewData["ProfileDocuments"]; + Assert.That(documents.Select(d => (d.Text, d.Value, d.Group?.Name)), Is.EqualTo(new[] { ("Loose note", "39", (string)null), ("Staffing policy", "40", "Policies") })); + Of().Verify(p => p.ResolveDocumentsForReadAsync(7, It.Is>(d => d.Count == 2), null, "admin", false, It.IsAny()), Times.Once); + Of().VerifyNoOtherCalls(); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/RecordsQualityAndTelemetryTests.cs b/Tests/Resgrid.Tests/Rms/RecordsQualityAndTelemetryTests.cs index e5377ea5d..18aaf8621 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsQualityAndTelemetryTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsQualityAndTelemetryTests.cs @@ -58,8 +58,11 @@ public async Task Sampling_is_deterministic_bounded_and_skips_reviewed_records() [Test] public async Task Scoring_is_weighted_complete_and_never_by_the_author_and_trends_aggregate() { - var rubric = await Rubric(); + // The draw is ordered by a hash of the rubric id, a new GUID each run, so which records are sampled varies. A sample one + // larger than any author's record count always holds a second author to score; five of twelve was one author 1.5% of runs. + var rubric = await Rubric(size: _finalized.GroupBy(r => r.AuthorUserId).Max(g => g.Count()) + 1); var sample = await _h.QualityService.SampleAsync(Dept, Admin, rubric.RmsQualityRubricId, DateTime.UtcNow.AddDays(-30)); + sample.Select(r => r.AuthorUserId).Distinct().Should().HaveCount(2, "the author trend compares two authors"); var review = sample.First(); Func partial = () => _h.QualityService.ScoreAsync(Dept, Admin, review.RmsQualityReviewId, new List { new RmsQualityFinding { Key = "times", Score = 100 } }, null, false); await partial.Should().ThrowAsync(); diff --git a/Tests/Resgrid.Tests/Web/User/OperatingProfileRenderingTests.cs b/Tests/Resgrid.Tests/Web/User/OperatingProfileRenderingTests.cs new file mode 100644 index 000000000..cb1a1a122 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/User/OperatingProfileRenderingTests.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.IO; +using File = System.IO.File; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Resgrid.Model.AdminAssist; +using Resgrid.Web.Helpers; + +namespace Resgrid.Tests.Web.User +{ + // Renders the production operating profile form body with a representative profile and picker options. + [Area("User")] + public class OperatingProfileRenderingController : Controller + { + public IActionResult Show() + { + // Admin Assist field help is matched to the owning screen's route, so render as that screen. + RouteData.Values["controller"] = "Department"; + RouteData.Values["action"] = "OperatingProfile"; + ViewData["DepartmentTimeZone"] = "Pacific Standard Time"; + ViewData["AdminAssistAvailable"] = true; + ViewData["ProfileGroups"] = new List { new("Station 1", "12"), new("Station 2", "13") }; + var policies = new SelectListGroup { Name = "Policies" }; + ViewData["ProfileDocuments"] = new List { new("Loose note", "39"), new("Staffing policy", "40") { Group = policies }, new("Qualification policy", "41") { Group = policies } }; + return PartialView("/Areas/User/Views/Department/OperatingProfile.cshtml", new DepartmentOperatingProfile + { + Revision = 4, Archetypes = new() { "fire", "ems" }, WorkforceMix = "combination", DeclaredMemberCount = 48, DispatchModel = "central", + OperatingHours = "seasonal", MutualAid = true, ExpectedEmailPollIntervalMinutes = 15, SeasonStartMonthDay = "11-15", SeasonEndMonthDay = "02-29", + LanguageCodes = new() { "en", "es" }, AccessibilityNeeds = new() { "large-text" }, SiteGroupReferences = new() { "12", "99" }, + StaffingPolicyReferences = new() { "40" }, AuthoritativeSystemReferences = new() { "cad-primary" } + }); + } + } + + [TestFixture, NonParallelizable] + public class OperatingProfileRenderingTests + { + [Test] + public async Task Profile_form_uses_pickers_and_number_inputs_and_keeps_Admin_Assist_field_help() + { + var root = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (root != null && !File.Exists(Path.Combine(root.FullName, "Resgrid.sln"))) root = root.Parent; + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { ContentRootPath = Path.Combine(root!.FullName, "Web", "Resgrid.Web"), EnvironmentName = "Testing" }); + builder.Logging.ClearProviders(); builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Services.AddHttpContextAccessor(); builder.Services.AddLocalization(); builder.Services.AddWebOptimizer(); + // The real catalog and an allowing access check, so asp-for editors carry their Admin Assist field help. + var access = new Mock(); + access.Setup(a => a.CanAccessAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(true); + builder.Services.AddSingleton(new Resgrid.AdminAssist.ConfigurationCatalog()); + builder.Services.AddSingleton(access.Object); + builder.Services.AddDataProtection().UseEphemeralDataProtectionProvider(); + builder.Services.AddControllersWithViews() + .AddApplicationPart(typeof(Resgrid.Web.Areas.User.Controllers.DepartmentController).Assembly) + .AddApplicationPart(typeof(OperatingProfileRenderingController).Assembly); + await using var app = builder.Build(); + var previous = ClaimsAuthorizationHelper._httpContextAccessor; + ClaimsAuthorizationHelper._httpContextAccessor = app.Services.GetRequiredService(); + app.Use(async (context, next) => { + context.User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.PrimarySid, "admin"), new Claim(ClaimTypes.PrimaryGroupSid, "77") }, "Test")); + try { await next(); } catch (Exception ex) { context.Response.StatusCode = 500; await context.Response.WriteAsync(ex.ToString()); } + }); + app.UseRequestLocalization(new RequestLocalizationOptions().SetDefaultCulture("en").AddSupportedCultures("en").AddSupportedUICultures("en")); + app.MapControllerRoute("areas", "{area:exists}/{controller}/{action=Index}/{id?}"); + try + { + await app.StartAsync(); + using var client = new HttpClient { BaseAddress = new Uri(app.Urls.Single()) }; + + var response = await client.GetAsync("/User/OperatingProfileRendering/Show"); + var html = await response.Content.ReadAsStringAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK, html); + await File.WriteAllTextAsync(Path.Combine(Path.GetTempPath(), "resgrid-operating-profile.html"), html); + + // Seasons: month and day pickers, preselected, with days a month cannot have unavailable. + html.Should().Contain("name=\"SeasonStartMonth\"").And.Contain("name=\"SeasonStartDay\"").And.Contain("name=\"SeasonEndMonth\"").And.Contain("name=\"SeasonEndDay\""); + html.Should().Contain("").And.Contain(""); + html.Should().Contain("").And.Contain(""); + html.Should().Contain(""); + html.Should().NotContain("MM-DD").And.NotContain("name=\"SeasonStartMonthDay\""); + + // Numbers: numeric inputs with their range, and no unobtrusive validation to switch off the browser's own checks. + html.Should().MatchRegex("]*type=\"number\"[^>]*min=\"0\" max=\"1000000\" step=\"1\"[^>]*name=\"DeclaredMemberCount\"[^>]*value=\"48\""); + html.Should().MatchRegex("]*type=\"number\"[^>]*min=\"1\" max=\"10080\" step=\"1\"[^>]*name=\"ExpectedEmailPollIntervalMinutes\"[^>]*value=\"15\""); + html.Should().NotContain("data-val=\"true\""); + html.Should().Contain("name=\"Revision\" value=\"4\""); + + // References: pickers of department groups and documents, grouped by category, keeping a stale choice removable. + html.Should().MatchRegex("]*autofocus=\"autofocus\""); + await File.WriteAllTextAsync(Path.Combine(Path.GetTempPath(), "resgrid-operating-profile-linked.html"), linked); + } + finally { ClaimsAuthorizationHelper._httpContextAccessor = previous; await app.StopAsync(); } + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/AdminAssistController.cs b/Web/Resgrid.Web.Services/Controllers/v4/AdminAssistController.cs index ab6fce6cd..f8467d99d 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/AdminAssistController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/AdminAssistController.cs @@ -31,7 +31,11 @@ public sealed class AdminAssistController(IAdminAssistService service, IAdminAss /// Read fresh, authorized configuration evidence and setup progress. [HttpGet("Overview")] public Task Overview(bool setup, CancellationToken cancellationToken) => ExecuteAsync(async () => - await service.GetOverviewAsync(Actor, setup, cancellationToken)); + { + var overview = await service.GetOverviewAsync(Actor, setup, cancellationToken); + // Names for failing findings (e.g. which groups are empty) are for this attended page only. + return overview with { FindingSubjects = await service.GetFindingSubjectsAsync(Actor, setup, overview.Report, cancellationToken) }; + }); /// Read the release-pinned public setup and feature catalog. [HttpGet("Catalog")] diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/adminAssist/AdminAssistElement.tsx b/Web/Resgrid.Web/Areas/User/Apps/src/components/adminAssist/AdminAssistElement.tsx index 3f2266702..129ef8844 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/adminAssist/AdminAssistElement.tsx +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/adminAssist/AdminAssistElement.tsx @@ -13,7 +13,7 @@ import SecurityPreview from './SecurityPreview'; import SetupWizard from './SetupWizard'; import AreaSetupChoice from './AreaSetupChoice'; import { ModuleCard, ModuleReadiness, isKey, tiers, type Module } from './ModuleViews'; -import { Chip, FindingGroups, FindingRow, Ibox, MetricRow, ResultChip, SeverityChip, byPriority, format, isSuggestion, type Finding } from './SetupVisuals'; +import { Chip, FindingGroups, FindingRow, FindingSubjectsContext, Ibox, MetricRow, ResultChip, SeverityChip, byPriority, format, isSuggestion, type Finding, type FindingSubject } from './SetupVisuals'; import AskPanel from './AskPanel'; import PlansPanel, { type PlanSource } from './PlansPanel'; import TroubleshootPanel from './TroubleshootPanel'; @@ -46,12 +46,20 @@ type Overview = { report: { verified: number; required: number; unknown: number; failed: number; hasCriticalUncertainty: boolean; selectedAreas: string[]; uncheckedAreaIds: string[]; findings: Finding[]; snapshot: { asOfUtc: string; consistent: boolean; revision: string; evidence: Record } }; access: { capabilityId: string; state: string; reasonCodes: string[]; canConfigure: boolean; subscriptionDestination: string | null; destination: string | null }[]; capabilitySetup?: { capabilityId: string; state: string; opportunityKey: string; guidanceKey: string; ruleIds: string[] }[]; + findingSubjects?: Record | null; }; type History = { id: string; occurredOnUtc: string; action: string; subjectId: string; beforeCode: string; afterCode: string }; type WorkItem = { adminAssistFindingId: string; ruleId: string; result: number; reviewStatus: number; ownerId: string | null; reviewOn: string | null; exceptionUntil: string | null; content: string | null; revision: number; lastObservedOn: string }; type SearchHit = { id: string; titleKey: string; excerpt: string; sourcePath: string; anchor: string; packVersion: string; locale: string }; type Followup = { digestsAvailable: boolean; preferences: { revision: number; digestEnabled: boolean; quietStartHour: number; quietEndHour: number; lastAttemptOutcome: string | null }; worker: { lastEvaluatedOn: string | null } }; const endpoint = 'api/v4/AdminAssist/'; +// Admin Assist tabs: Setup Wizard, Setup Report and Explore, then Admin AI, which holds the workspace tools. The page runs in +// setup mode until Admin.Assist and Ai.AdminAssist are both on; Admin AI is shown but disabled until then. +const aiTabIds = ['ask', 'overview', 'health', 'worklist', 'reference', 'history', 'plans', 'troubleshoot']; +const tabsFor = (catalog: Catalog, setup: boolean) => ({ + primary: [...(catalog.canSetup ? ['wizard'] : []), 'report', 'explore'], + ai: setup ? [] : aiTabIds.filter(id => id === 'ask' ? catalog.askAvailable : id === 'plans' ? catalog.plansAvailable : id === 'troubleshoot' ? catalog.troubleshootingAvailable : true), +}); // No external URLs, scheme-relative links or redirects from catalog data. const safeLocalLink = (url: string | null | undefined) => url && /^\/User\/[A-Za-z0-9]+\/[A-Za-z0-9]+(?:\?aa=[A-Za-z0-9._-]+)?$/.test(url) ? url : undefined; @@ -110,9 +118,13 @@ export default function AdminAssistElement({ page, setup, loadingLabel, errorLab window.addEventListener('resgrid:adp-reveal-changed', refreshProtection); return () => window.removeEventListener('resgrid:adp-reveal-changed', refreshProtection); }, [reload]); - useEffect(() => { if (catalog && !catalog.plansAvailable && tab === 'plans') setTab('overview'); }, [catalog, tab]); - useEffect(() => { if (catalog && !catalog.troubleshootingAvailable && tab === 'troubleshoot') setTab('overview'); }, [catalog, tab]); - useEffect(() => { if (catalog && !catalog.askAvailable && tab === 'ask') setTab('overview'); }, [catalog, tab]); + // A tab that is not available here (the wizard without setup rights, an Admin AI tool that is off, or Admin AI itself before + // it launches) opens the nearest one that is. + useEffect(() => { + if (!catalog) return; + const { primary, ai } = tabsFor(catalog, setup); + if (!primary.includes(tab) && !ai.includes(tab)) setTab(aiTabIds.includes(tab) && ai.length > 0 ? ai[0] : primary[0]); + }, [catalog, setup, tab]); async function save(operation: string, targetId: string | null = null, choice: string | null = null, reasonCode: string | null = null, revisitOnUtc: string | null = null) { if (!overview || !catalog || busy) return; @@ -208,7 +220,8 @@ export default function AdminAssistElement({ page, setup, loadingLabel, errorLab const operatingPacks = overview.report.snapshot.evidence?.operatingPackIds; const selectedPackIds = operatingPacks?.state === 'Known' ? (operatingPacks.code ?? '').split(',').filter(Boolean) : []; const suggestedAreas = new Set(catalog.packs.filter(pack => selectedPackIds.includes(pack.id)).flatMap(pack => pack.areaIds)); - const tabs = setup ? ['wizard', 'report', 'explore'] : ['overview', ...(catalog.canSetup ? ['wizard'] : []), 'report', 'explore', 'health', 'worklist', 'reference', 'history', ...(catalog.plansAvailable ? ['plans'] : []), ...(catalog.troubleshootingAvailable ? ['troubleshoot'] : []), ...(catalog.askAvailable ? ['ask'] : [])]; + const { primary: tabs, ai: aiTabs } = tabsFor(catalog, setup); + const inAi = aiTabs.includes(tab); const selected = (finding: Finding) => finding.areaId === 'security' || finding.scopeIndependent || report.selectedAreas.includes(finding.areaId); const scoped = report.findings.filter(selected); const next = scoped.filter(f => f.result === 'Fail' && !isSuggestion(f)).sort(byPriority).slice(0, 5); @@ -222,6 +235,9 @@ export default function AdminAssistElement({ page, setup, loadingLabel, errorLab const reviewState: 'current' | 'changed' | 'none' = !workspace.reviewEvidence ? 'none' : workspace.reviewEvidence.catalogVersion !== catalog.version || workspace.reviewEvidence.snapshotRevision !== report.snapshot.revision || (workspace.reviewEvidence.scopeRevision ?? 0) !== (workspace.scopeRevision ?? 0) ? 'changed' : 'current'; const showTab = (id: string) => { setTab(id); if (id === 'history') void loadHistory(true); if (id === 'worklist') void loadWorklist(); }; + const tabLink = (id: string) =>
  • + { event.preventDefault(); if (!busy) showTab(id); }}>{ui(id)} +
  • ; const explore = (search = '', addons = false) => { setQuery(search); setAddonsOnly(addons); setTab('explore'); }; const planFrom = catalog.plansAvailable ? (finding: Finding) => { setPlanSource({ source: 'finding', sourceId: finding.ruleId, goal: t(finding.titleKey) }); setTab('plans'); } : undefined; const link = (url: string) => localLink(url); @@ -277,10 +293,14 @@ export default function AdminAssistElement({ page, setup, loadingLabel, errorLab ; const healthFindings = report.findings.filter(f => (!healthArea || f.areaId === healthArea) && (!healthResult || f.result === healthResult)); - return
    - + return
    + + {inAi && }

    {ui(tab)}

    {busy &&