From 8b2f6f8e5f04b4c1fb6795f0687746f41d88aa2d Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 27 Mar 2026 23:14:12 -0700 Subject: [PATCH 1/7] RE1-T107 Added checkin timers to calls --- .gitignore | 1 + .../Areas/User/Department/Department.ar.resx | 129 ++++++ .../Areas/User/Department/Department.de.resx | 129 ++++++ .../Areas/User/Department/Department.en.resx | 129 ++++++ .../Areas/User/Department/Department.es.resx | 129 ++++++ .../Areas/User/Department/Department.fr.resx | 129 ++++++ .../Areas/User/Department/Department.it.resx | 129 ++++++ .../Areas/User/Department/Department.pl.resx | 129 ++++++ .../Areas/User/Department/Department.resx | 129 ++++++ .../Areas/User/Department/Department.sv.resx | 129 ++++++ .../Areas/User/Department/Department.uk.resx | 129 ++++++ .../Areas/User/Dispatch/Call.ar.resx | 22 + .../Areas/User/Dispatch/Call.de.resx | 66 +++ .../Areas/User/Dispatch/Call.en.resx | 66 +++ .../Areas/User/Dispatch/Call.es.resx | 66 +++ .../Areas/User/Dispatch/Call.fr.resx | 66 +++ .../Areas/User/Dispatch/Call.it.resx | 66 +++ .../Areas/User/Dispatch/Call.pl.resx | 66 +++ .../Areas/User/Dispatch/Call.sv.resx | 66 +++ .../Areas/User/Dispatch/Call.uk.resx | 66 +++ .../Areas/User/Templates/Templates.ar.resx | 3 + .../Areas/User/Templates/Templates.de.resx | 3 + .../Areas/User/Templates/Templates.en.resx | 3 + .../Areas/User/Templates/Templates.es.resx | 1 + .../Areas/User/Templates/Templates.fr.resx | 3 + .../Areas/User/Templates/Templates.it.resx | 3 + .../Areas/User/Templates/Templates.pl.resx | 3 + .../Areas/User/Templates/Templates.sv.resx | 3 + .../Areas/User/Templates/Templates.uk.resx | 3 + Core/Resgrid.Model/AuditLogTypes.cs | 12 +- Core/Resgrid.Model/Call.cs | 2 + Core/Resgrid.Model/CallQuickTemplate.cs | 2 + Core/Resgrid.Model/CheckInRecord.cs | 50 +++ Core/Resgrid.Model/CheckInTimerConfig.cs | 50 +++ Core/Resgrid.Model/CheckInTimerOverride.cs | 54 +++ Core/Resgrid.Model/CheckInTimerStatus.cs | 25 ++ Core/Resgrid.Model/CheckInTimerTargetType.cs | 13 + Core/Resgrid.Model/DepartmentSettingTypes.cs | 1 + .../Repositories/ICheckInRecordRepository.cs | 14 + .../ICheckInTimerConfigRepository.cs | 11 + .../ICheckInTimerOverrideRepository.cs | 11 + Core/Resgrid.Model/ResolvedCheckInTimer.cs | 19 + .../Services/ICheckInTimerService.cs | 30 ++ .../Services/IDepartmentSettingsService.cs | 2 + Core/Resgrid.Services/CheckInTimerService.cs | 236 ++++++++++ .../DepartmentSettingsService.cs | 10 + Core/Resgrid.Services/ServicesModule.cs | 1 + .../Migrations/M0056_AddingCheckInTimers.cs | 117 +++++ .../Migrations/M0056_AddingCheckInTimersPg.cs | 117 +++++ .../CheckInRecordRepository.cs | 183 ++++++++ .../CheckInTimerConfigRepository.cs | 109 +++++ .../CheckInTimerOverrideRepository.cs | 108 +++++ .../Configs/SqlConfiguration.cs | 14 + .../Modules/ApiDataModule.cs | 5 + .../Modules/DataModule.cs | 5 + .../Modules/NonWebDataModule.cs | 5 + .../Modules/TestingDataModule.cs | 5 + .../SelectCheckInRecordsByCallIdQuery.cs | 33 ++ ...nRecordsByDepartmentIdAndDateRangeQuery.cs | 33 ++ ...InTimerConfigByDepartmentAndTargetQuery.cs | 33 ++ ...tCheckInTimerConfigsByDepartmentIdQuery.cs | 33 ++ ...heckInTimerOverridesByDepartmentIdQuery.cs | 33 ++ .../SelectLastCheckInForUnitOnCallQuery.cs | 33 ++ .../SelectLastCheckInForUserOnCallQuery.cs | 33 ++ ...electMatchingCheckInTimerOverridesQuery.cs | 33 ++ .../PostgreSql/PostgreSqlConfiguration.cs | 49 ++ .../SqlServer/SqlServerConfiguration.cs | 47 ++ .../Services/CheckInTimerServiceTests.cs | 271 +++++++++++ .../Controllers/v4/CallsController.cs | 10 + .../Controllers/v4/CheckInTimersController.cs | 420 ++++++++++++++++++ Web/Resgrid.Web.Services/Hubs/EventingHub.cs | 16 + .../Models/v4/Calls/CallResult.cs | 5 + .../Models/v4/Calls/NewCallInput.cs | 5 + .../v4/CheckInTimers/CheckInTimerModels.cs | 185 ++++++++ .../Resgrid.Web.Services.xml | 70 +++ .../User/Controllers/DepartmentController.cs | 111 ++++- .../User/Controllers/DispatchController.cs | 149 ++++++- .../Departments/DispatchSettingsView.cs | 14 +- .../User/Models/Dispatch/CallExportView.cs | 2 + .../Models/Dispatch/PerformCheckInInput.cs | 12 + .../Views/Department/DispatchSettings.cshtml | 402 ++++++++++++++--- .../User/Views/Department/Settings.cshtml | 2 +- .../User/Views/Dispatch/CallExport.cshtml | 77 ++++ .../Areas/User/Views/Dispatch/NewCall.cshtml | 12 + .../User/Views/Dispatch/UpdateCall.cshtml | 12 + .../Areas/User/Views/Dispatch/ViewCall.cshtml | 91 ++++ .../Areas/User/Views/Templates/Edit.cshtml | 10 + .../Areas/User/Views/Templates/New.cshtml | 10 + .../resgrid.dispatch.checkin-timers.js | 202 +++++++++ .../dispatch/resgrid.dispatch.newcall.js | 6 + 90 files changed, 5453 insertions(+), 78 deletions(-) create mode 100644 Core/Resgrid.Model/CheckInRecord.cs create mode 100644 Core/Resgrid.Model/CheckInTimerConfig.cs create mode 100644 Core/Resgrid.Model/CheckInTimerOverride.cs create mode 100644 Core/Resgrid.Model/CheckInTimerStatus.cs create mode 100644 Core/Resgrid.Model/CheckInTimerTargetType.cs create mode 100644 Core/Resgrid.Model/Repositories/ICheckInRecordRepository.cs create mode 100644 Core/Resgrid.Model/Repositories/ICheckInTimerConfigRepository.cs create mode 100644 Core/Resgrid.Model/Repositories/ICheckInTimerOverrideRepository.cs create mode 100644 Core/Resgrid.Model/ResolvedCheckInTimer.cs create mode 100644 Core/Resgrid.Model/Services/ICheckInTimerService.cs create mode 100644 Core/Resgrid.Services/CheckInTimerService.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0056_AddingCheckInTimers.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0056_AddingCheckInTimersPg.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/CheckInRecordRepository.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/CheckInTimerConfigRepository.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/CheckInTimerOverrideRepository.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInRecordsByCallIdQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInRecordsByDepartmentIdAndDateRangeQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInTimerConfigByDepartmentAndTargetQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInTimerConfigsByDepartmentIdQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInTimerOverridesByDepartmentIdQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectLastCheckInForUnitOnCallQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectLastCheckInForUserOnCallQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectMatchingCheckInTimerOverridesQuery.cs create mode 100644 Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/CheckInTimersController.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/CheckInTimers/CheckInTimerModels.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/Dispatch/PerformCheckInInput.cs create mode 100644 Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.checkin-timers.js diff --git a/.gitignore b/.gitignore index 2d0321e8..0d0bebd7 100644 --- a/.gitignore +++ b/.gitignore @@ -273,3 +273,4 @@ Web/Resgrid.WebCore/wwwroot/lib/* /Web/Resgrid.Web/wwwroot/lib .dual-graph/ .claude/settings.local.json +.claude/settings.local.json diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx index c3cd0b00..0cc0d142 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx @@ -507,4 +507,133 @@ عند إرسال وحدة لحالة وكان المستخدمون معيّنون على أدوار، ستُعيَّن حالة المستخدمين إلى "على الوحدة". + + إعدادات المكالمات والإرسال + + + المكالمات والإرسال + + + تم حفظ إعدادات المكالمات والإرسال بنجاح. + + + إعدادات مؤقت تسجيل الوصول + + + الإعدادات العامة + + + تفعيل مؤقتات تسجيل الوصول تلقائياً للمكالمات الجديدة + + + عند التفعيل، ستحصل جميع المكالمات الجديدة تلقائياً على مؤقتات تسجيل الوصول. + + + تكوينات المؤقت الافتراضية + + + تحدد هذه فترات تسجيل الوصول الافتراضية لكل نوع هدف. + + + التجاوزات حسب نوع المكالمة / الأولوية + + + التجاوزات لها الأولوية على المؤقتات الافتراضية عندما تتطابق المكالمة مع النوع و/أو الأولوية المحددة. + + + نوع الهدف + + + نوع الوحدة + + + المدة (دقائق) + + + حد التحذير (دقائق) + + + مفعل + + + معرف نوع المكالمة + + + أولوية المكالمة + + + إضافة تكوين مؤقت + + + إضافة تجاوز + + + إضافة مؤقت افتراضي جديد + + + إضافة تجاوز جديد + + + -- لا شيء -- + + + -- أي -- + + + الأفراد + + + نوع الوحدة + + + قائد الحادث + + + مساءلة الأفراد + + + التعرض للمواد الخطرة + + + تدوير القطاع + + + إعادة التأهيل + + + اتركه فارغاً لأي + + + نعم + + + لا + + + حذف تكوين المؤقت هذا؟ + + + حذف هذا التجاوز؟ + + + افتراضي + + + تجاوز + + + منخفضة + + + متوسطة + + + عالية + + + طوارئ + + + حفظ الإعدادات العامة + diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx index 31936537..b9e830c3 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx @@ -458,4 +458,133 @@ When a unit submits a status and users are setup on roles, the users status will be set to On Unit. + + Einsatz- & Alarmierungseinstellungen + + + Einsatz & Alarmierung + + + Einsatz- & Alarmierungseinstellungen erfolgreich gespeichert. + + + Check-In-Timer-Einstellungen + + + Allgemeine Einstellungen + + + Check-In-Timer für neue Einsätze automatisch aktivieren + + + Wenn aktiviert, werden alle neuen Einsätze automatisch mit Check-In-Timern versehen. + + + Standard-Timer-Konfigurationen + + + Diese definieren die Standard-Check-In-Intervalle für jeden Zieltyp. + + + Überschreibungen nach Einsatztyp / Priorität + + + Überschreibungen haben Vorrang vor Standard-Timern wenn ein Einsatz dem angegebenen Typ und/oder Priorität entspricht. + + + Zieltyp + + + Einheitentyp + + + Dauer (Minuten) + + + Warnschwelle (Minuten) + + + Aktiviert + + + Einsatztyp-ID + + + Einsatzpriorität + + + Timer-Konfiguration hinzufügen + + + Überschreibung hinzufügen + + + Neuen Standard-Timer hinzufügen + + + Neue Überschreibung hinzufügen + + + -- Keine -- + + + -- Beliebig -- + + + Personal + + + Einheitentyp + + + EL (Einsatzleiter) + + + PAR (Personalverantwortung) + + + Gefahrstoffexposition + + + Sektorrotation + + + Rehabilitation + + + leer lassen für beliebig + + + Ja + + + Nein + + + Diese Timer-Konfiguration löschen? + + + Diese Überschreibung löschen? + + + Standard + + + Überschreibung + + + Niedrig + + + Mittel + + + Hoch + + + Notfall + + + Allgemeine Einstellungen speichern + diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx index 5fc6146b..a06e2bf0 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx @@ -507,4 +507,133 @@ When a unit submits a status and users are setup on roles, the users status will be set to On Unit. + + Call & Dispatch Settings + + + Call & Dispatch Settings + + + Successfully saved Call & Dispatch Settings. + + + Check-In Timer Settings + + + General Settings + + + Auto-Enable Check-In Timers for New Calls + + + When enabled, all new calls will automatically have check-in timers turned on. + + + Default Timer Configurations + + + These define the default check-in intervals for each target type. They apply to all calls unless overridden by call type/priority rules below. + + + Call Type / Priority Overrides + + + Overrides take precedence over default timers when a call matches the specified type and/or priority. + + + Target Type + + + Unit Type + + + Duration (minutes) + + + Warning Threshold (minutes) + + + Enabled + + + Call Type ID + + + Call Priority + + + Add Timer Config + + + Add Override + + + Add New Default Timer + + + Add New Override + + + -- None -- + + + -- Any -- + + + Personnel + + + Unit Type + + + IC (Incident Commander) + + + PAR (Personnel Accountability) + + + Hazmat Exposure + + + Sector Rotation + + + Rehab + + + leave blank for any + + + Yes + + + No + + + Delete this timer configuration? + + + Delete this override? + + + Default + + + Override + + + Low + + + Medium + + + High + + + Emergency + + + Save General Settings + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx index abfee4b0..14fa14c7 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx @@ -393,4 +393,133 @@ + + Configuraciones de Llamadas y Despacho + + + Llamadas y Despacho + + + Configuraciones de llamadas y despacho guardadas exitosamente. + + + Configuraciones de Temporizador de Registro + + + Configuraciones Generales + + + Habilitar Automáticamente los Temporizadores de Registro para Nuevas Llamadas + + + Cuando está habilitado, todas las llamadas nuevas tendrán automáticamente los temporizadores de registro activados. + + + Configuraciones Predeterminadas de Temporizador + + + Estos definen los intervalos predeterminados de registro para cada tipo de objetivo. + + + Anulaciones por Tipo de Llamada / Prioridad + + + Las anulaciones tienen prioridad sobre los temporizadores predeterminados cuando una llamada coincide con el tipo y/o prioridad especificados. + + + Tipo de Objetivo + + + Tipo de Unidad + + + Duración (minutos) + + + Umbral de Advertencia (minutos) + + + Habilitado + + + ID de Tipo de Llamada + + + Prioridad de Llamada + + + Agregar Configuración de Temporizador + + + Agregar Anulación + + + Agregar Nuevo Temporizador Predeterminado + + + Agregar Nueva Anulación + + + -- Ninguno -- + + + -- Cualquiera -- + + + Personal + + + Tipo de Unidad + + + CI (Comandante de Incidente) + + + PAR (Rendición de Cuentas del Personal) + + + Exposición a Materiales Peligrosos + + + Rotación de Sector + + + Rehabilitación + + + dejar en blanco para cualquiera + + + + + + No + + + ¿Eliminar esta configuración de temporizador? + + + ¿Eliminar esta anulación? + + + Predeterminado + + + Anulación + + + Baja + + + Media + + + Alta + + + Emergencia + + + Guardar Configuraciones Generales + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx index 4296c6cb..1b9373ee 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx @@ -458,4 +458,133 @@ When a unit submits a status and users are setup on roles, the users status will be set to On Unit. + + Paramètres d'Appels et de Dispatch + + + Appels et Dispatch + + + Paramètres d'appels et de dispatch enregistrés avec succès. + + + Paramètres des Minuteries de Pointage + + + Paramètres Généraux + + + Activer automatiquement les minuteries de pointage pour les nouveaux appels + + + Lorsqu'activé, tous les nouveaux appels auront automatiquement les minuteries de pointage activées. + + + Configurations de Minuterie par Défaut + + + Ceux-ci définissent les intervalles de pointage par défaut pour chaque type de cible. + + + Remplacements par Type d'Appel / Priorité + + + Les remplacements ont priorité sur les minuteries par défaut lorsqu'un appel correspond au type et/ou à la priorité spécifiés. + + + Type de Cible + + + Type d'Unité + + + Durée (minutes) + + + Seuil d'Avertissement (minutes) + + + Activé + + + ID du Type d'Appel + + + Priorité d'Appel + + + Ajouter une Configuration de Minuterie + + + Ajouter un Remplacement + + + Ajouter une Nouvelle Minuterie par Défaut + + + Ajouter un Nouveau Remplacement + + + -- Aucun -- + + + -- N'importe quel -- + + + Personnel + + + Type d'Unité + + + CI (Commandant d'Intervention) + + + PAR (Responsabilité du Personnel) + + + Exposition aux Matières Dangereuses + + + Rotation de Secteur + + + Réhabilitation + + + laisser vide pour n'importe quel + + + Oui + + + Non + + + Supprimer cette configuration de minuterie? + + + Supprimer ce remplacement? + + + Par Défaut + + + Remplacement + + + Basse + + + Moyenne + + + Haute + + + Urgence + + + Enregistrer les Paramètres Généraux + diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx index 2533ad15..ba6767fc 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx @@ -458,4 +458,133 @@ When a unit submits a status and users are setup on roles, the users status will be set to On Unit. + + Impostazioni Chiamate e Invio + + + Chiamate e Invio + + + Impostazioni chiamate e invio salvate con successo. + + + Impostazioni Timer di Registrazione + + + Impostazioni Generali + + + Abilita automaticamente i timer di registrazione per le nuove chiamate + + + Quando abilitato, tutte le nuove chiamate avranno automaticamente i timer di registrazione attivati. + + + Configurazioni Timer Predefinite + + + Questi definiscono gli intervalli di registrazione predefiniti per ogni tipo di obiettivo. + + + Sovrascritture per Tipo di Chiamata / Priorità + + + Le sovrascritture hanno la precedenza sui timer predefiniti quando una chiamata corrisponde al tipo e/o alla priorità specificati. + + + Tipo di Obiettivo + + + Tipo di Unità + + + Durata (minuti) + + + Soglia di Avviso (minuti) + + + Abilitato + + + ID Tipo di Chiamata + + + Priorità di Chiamata + + + Aggiungi Configurazione Timer + + + Aggiungi Sovrascrittura + + + Aggiungi Nuovo Timer Predefinito + + + Aggiungi Nuova Sovrascrittura + + + -- Nessuno -- + + + -- Qualsiasi -- + + + Personale + + + Tipo di Unità + + + CI (Comandante dell'Incidente) + + + PAR (Responsabilità del Personale) + + + Esposizione a Materiali Pericolosi + + + Rotazione del Settore + + + Riabilitazione + + + lasciare vuoto per qualsiasi + + + + + + No + + + Eliminare questa configurazione del timer? + + + Eliminare questa sovrascrittura? + + + Predefinito + + + Sovrascrittura + + + Bassa + + + Media + + + Alta + + + Emergenza + + + Salva Impostazioni Generali + diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx index a2d579da..a631b278 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx @@ -458,4 +458,133 @@ When a unit submits a status and users are setup on roles, the users status will be set to On Unit. + + Ustawienia Wezwań i Dyspozycji + + + Wezwania i Dyspozycja + + + Pomyślnie zapisano ustawienia wezwań i dyspozycji. + + + Ustawienia Czasomierza Meldowania + + + Ustawienia Ogólne + + + Automatycznie włącz czasomierze meldowania dla nowych wezwań + + + Po włączeniu wszystkie nowe wezwania będą automatycznie miały włączone czasomierze meldowania. + + + Domyślne Konfiguracje Czasomierza + + + Definiują domyślne interwały meldowania dla każdego typu celu. + + + Nadpisania według Typu Wezwania / Priorytetu + + + Nadpisania mają pierwszeństwo przed domyślnymi czasomierzami gdy wezwanie pasuje do określonego typu i/lub priorytetu. + + + Typ Celu + + + Typ Jednostki + + + Czas trwania (minuty) + + + Próg Ostrzeżenia (minuty) + + + Włączony + + + ID Typu Wezwania + + + Priorytet Wezwania + + + Dodaj Konfigurację Czasomierza + + + Dodaj Nadpisanie + + + Dodaj Nowy Domyślny Czasomierz + + + Dodaj Nowe Nadpisanie + + + -- Brak -- + + + -- Dowolny -- + + + Personel + + + Typ Jednostki + + + KD (Kierownik Działań) + + + PAR (Odpowiedzialność Personelu) + + + Narażenie na Materiały Niebezpieczne + + + Rotacja Sektora + + + Rehabilitacja + + + pozostaw puste dla dowolnego + + + Tak + + + Nie + + + Usunąć tę konfigurację czasomierza? + + + Usunąć to nadpisanie? + + + Domyślny + + + Nadpisanie + + + Niski + + + Średni + + + Wysoki + + + Nagły + + + Zapisz Ustawienia Ogólne + diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.resx index 4eb30502..b4dd50ce 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.resx @@ -192,4 +192,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx index c253c7ea..ba2beaa2 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx @@ -458,4 +458,133 @@ When a unit submits a status and users are setup on roles, the users status will be set to On Unit. + + Larm- & Utskicksinställningar + + + Larm & Utskick + + + Larm- och utskicksinställningar sparade. + + + Inställningar för Incheckningstimer + + + Allmänna Inställningar + + + Aktivera incheckningstimers automatiskt för nya larm + + + När aktiverat kommer alla nya larm automatiskt att ha incheckningstimers påslagna. + + + Standardkonfigurationer för Timer + + + Dessa definierar standardintervaller för incheckning för varje måltyp. + + + Åsidosättningar per Larmtyp / Prioritet + + + Åsidosättningar har företräde framför standardtimers när ett larm matchar angiven typ och/eller prioritet. + + + Måltyp + + + Enhetstyp + + + Varaktighet (minuter) + + + Varningströskel (minuter) + + + Aktiverad + + + Larmtyp-ID + + + Larmprioritet + + + Lägg till Timerkonfiguration + + + Lägg till Åsidosättning + + + Lägg till Ny Standardtimer + + + Lägg till Ny Åsidosättning + + + -- Ingen -- + + + -- Valfri -- + + + Personal + + + Enhetstyp + + + IL (Insatsledare) + + + PAR (Personalredovisning) + + + Farligt Gods-exponering + + + Sektorrotation + + + Rehabilitering + + + lämna tomt för valfri + + + Ja + + + Nej + + + Ta bort denna timerkonfiguration? + + + Ta bort denna åsidosättning? + + + Standard + + + Åsidosättning + + + Låg + + + Medel + + + Hög + + + Nödsituation + + + Spara Allmänna Inställningar + diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx index ae03d8e9..c62c3489 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx @@ -458,4 +458,133 @@ When a unit submits a status and users are setup on roles, the users status will be set to On Unit. + + Налаштування Викликів та Диспетчеризації + + + Виклики та Диспетчеризація + + + Налаштування викликів та диспетчеризації успішно збережено. + + + Налаштування Таймера Реєстрації + + + Загальні Налаштування + + + Автоматично вмикати таймери реєстрації для нових викликів + + + Коли увімкнено, всі нові виклики автоматично матимуть увімкнені таймери реєстрації. + + + Стандартні Конфігурації Таймера + + + Вони визначають стандартні інтервали реєстрації для кожного типу цілі. + + + Перевизначення за Типом Виклику / Пріоритетом + + + Перевизначення мають пріоритет над стандартними таймерами коли виклик відповідає вказаному типу та/або пріоритету. + + + Тип Цілі + + + Тип Підрозділу + + + Тривалість (хвилини) + + + Поріг Попередження (хвилини) + + + Увімкнено + + + ID Типу Виклику + + + Пріоритет Виклику + + + Додати Конфігурацію Таймера + + + Додати Перевизначення + + + Додати Новий Стандартний Таймер + + + Додати Нове Перевизначення + + + -- Немає -- + + + -- Будь-який -- + + + Персонал + + + Тип Підрозділу + + + КІ (Командир Інциденту) + + + PAR (Відповідальність Персоналу) + + + Вплив Небезпечних Матеріалів + + + Ротація Сектора + + + Реабілітація + + + залиште порожнім для будь-якого + + + Так + + + Ні + + + Видалити цю конфігурацію таймера? + + + Видалити це перевизначення? + + + Стандартний + + + Перевизначення + + + Низький + + + Середній + + + Високий + + + Надзвичайний + + + Зберегти Загальні Налаштування + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx index 09a2f23f..c50c8844 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx @@ -135,4 +135,26 @@ عرض البلاغ عرض النص هذا عنوان what3words. + مؤقتات تسجيل الوصول + تفعيل مؤقتات تسجيل الوصول + مؤقتات تسجيل الوصول + الهدف + النوع + آخر تسجيل وصول + الوقت المتبقي + الحالة + الإجراءات + تسجيل الوصول + عرض سجل تسجيل الوصول + الطابع الزمني + من + النوع + الوحدة + الموقع + ملاحظة + تكوين مؤقت تسجيل الوصول + سجل تسجيل الوصول + المدة (دقائق) + حد التحذير (دقائق) + المصدر diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx index 12366fca..7851beef 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx @@ -458,4 +458,70 @@ This is a what3words address. + + Check-In-Timer + + + Check-In-Timer aktivieren + + + Check-In-Timer + + + Ziel + + + Typ + + + Letzter Check-In + + + Verbleibende Zeit + + + Status + + + Aktionen + + + Einchecken + + + Check-In-Verlauf anzeigen + + + Zeitstempel + + + Wer + + + Typ + + + Einheit + + + Standort + + + Notiz + + + Check-In-Timer-Konfiguration + + + Check-In-Protokoll + + + Dauer (Min) + + + Warnschwelle (Min) + + + Quelle + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx index 30e59a0b..edf9c74c 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx @@ -507,4 +507,70 @@ This is a what3words address. + + Check-In Timers + + + Enable Check-In Timers + + + Check-In Timers + + + Target + + + Type + + + Last Check-In + + + Time Remaining + + + Status + + + Actions + + + Check In + + + Show Check-In History + + + Timestamp + + + Who + + + Type + + + Unit + + + Location + + + Note + + + Check-In Timer Configuration + + + Check-In Log + + + Duration (min) + + + Warning Threshold (min) + + + Source + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx index 7b2db68b..2e8bbc40 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx @@ -507,4 +507,70 @@ Esta es una dirección de what3words. + + Temporizadores de Registro + + + Habilitar Temporizadores de Registro + + + Temporizadores de Registro + + + Objetivo + + + Tipo + + + Último Registro + + + Tiempo Restante + + + Estado + + + Acciones + + + Registrarse + + + Mostrar Historial de Registros + + + Marca de Tiempo + + + Quién + + + Tipo + + + Unidad + + + Ubicación + + + Nota + + + Configuración del Temporizador de Registro + + + Registro de Check-In + + + Duración (min) + + + Umbral de Advertencia (min) + + + Fuente + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx index aa590699..8b4ee8f6 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx @@ -458,4 +458,70 @@ This is a what3words address. + + Minuteries de Pointage + + + Activer les Minuteries de Pointage + + + Minuteries de Pointage + + + Cible + + + Type + + + Dernier Pointage + + + Temps Restant + + + Statut + + + Actions + + + Pointer + + + Afficher l'Historique des Pointages + + + Horodatage + + + Qui + + + Type + + + Unité + + + Emplacement + + + Note + + + Configuration des Minuteries de Pointage + + + Journal de Pointage + + + Durée (min) + + + Seuil d'Avertissement (min) + + + Source + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx index a990af88..12f4f3d9 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx @@ -458,4 +458,70 @@ This is a what3words address. + + Timer di Registrazione + + + Abilita Timer di Registrazione + + + Timer di Registrazione + + + Obiettivo + + + Tipo + + + Ultima Registrazione + + + Tempo Rimanente + + + Stato + + + Azioni + + + Registrarsi + + + Mostra Cronologia Registrazioni + + + Marca Temporale + + + Chi + + + Tipo + + + Unità + + + Posizione + + + Nota + + + Configurazione Timer di Registrazione + + + Registro Check-In + + + Durata (min) + + + Soglia di Avviso (min) + + + Fonte + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx index 03880aa1..56b2c540 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx @@ -458,4 +458,70 @@ This is a what3words address. + + Czasomierze Meldowania + + + Włącz Czasomierze Meldowania + + + Czasomierze Meldowania + + + Cel + + + Typ + + + Ostatnie Meldowanie + + + Pozostały Czas + + + Status + + + Akcje + + + Zamelduj się + + + Pokaż Historię Meldowań + + + Znacznik Czasu + + + Kto + + + Typ + + + Jednostka + + + Lokalizacja + + + Notatka + + + Konfiguracja Czasomierza Meldowania + + + Dziennik Meldowań + + + Czas trwania (min) + + + Próg Ostrzeżenia (min) + + + Źródło + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx index abc0b7e9..99ddf435 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx @@ -458,4 +458,70 @@ This is a what3words address. + + Incheckningstimers + + + Aktivera Incheckningstimers + + + Incheckningstimers + + + Mål + + + Typ + + + Senaste Incheckning + + + Återstående Tid + + + Status + + + Åtgärder + + + Checka In + + + Visa Incheckningshistorik + + + Tidsstämpel + + + Vem + + + Typ + + + Enhet + + + Plats + + + Anteckning + + + Incheckningstimer-konfiguration + + + Incheckningslogg + + + Varaktighet (min) + + + Varningströskel (min) + + + Källa + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx index e1e4dbce..a513d5c1 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx @@ -458,4 +458,70 @@ This is a what3words address. + + Таймери Реєстрації + + + Увімкнути Таймери Реєстрації + + + Таймери Реєстрації + + + Ціль + + + Тип + + + Остання Реєстрація + + + Залишок Часу + + + Статус + + + Дії + + + Зареєструватися + + + Показати Історію Реєстрацій + + + Мітка Часу + + + Хто + + + Тип + + + Підрозділ + + + Місцезнаходження + + + Примітка + + + Конфігурація Таймера Реєстрації + + + Журнал Реєстрацій + + + Тривалість (хв) + + + Поріг Попередження (хв) + + + Джерело + diff --git a/Core/Resgrid.Localization/Areas/User/Templates/Templates.ar.resx b/Core/Resgrid.Localization/Areas/User/Templates/Templates.ar.resx index 833ac212..e0b144b0 100644 --- a/Core/Resgrid.Localization/Areas/User/Templates/Templates.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Templates/Templates.ar.resx @@ -168,4 +168,7 @@ قالب البلاغ السريع + + تفعيل مؤقتات تسجيل الوصول + diff --git a/Core/Resgrid.Localization/Areas/User/Templates/Templates.de.resx b/Core/Resgrid.Localization/Areas/User/Templates/Templates.de.resx index 050aa6fe..741b29be 100644 --- a/Core/Resgrid.Localization/Areas/User/Templates/Templates.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Templates/Templates.de.resx @@ -119,4 +119,7 @@ Anruf-Schnellvorlage + + Check-In-Timer aktivieren + diff --git a/Core/Resgrid.Localization/Areas/User/Templates/Templates.en.resx b/Core/Resgrid.Localization/Areas/User/Templates/Templates.en.resx index b8f14b90..e69505c5 100644 --- a/Core/Resgrid.Localization/Areas/User/Templates/Templates.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Templates/Templates.en.resx @@ -168,4 +168,7 @@ Call Quick Template + + Enable Check-In Timers + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Templates/Templates.es.resx b/Core/Resgrid.Localization/Areas/User/Templates/Templates.es.resx index b942b06f..43671284 100644 --- a/Core/Resgrid.Localization/Areas/User/Templates/Templates.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Templates/Templates.es.resx @@ -115,4 +115,5 @@ Agregar plantilla Nota de llamada Plantilla rápida de llamada + Habilitar Temporizadores de Registro \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Templates/Templates.fr.resx b/Core/Resgrid.Localization/Areas/User/Templates/Templates.fr.resx index 10ada4f4..89e75e88 100644 --- a/Core/Resgrid.Localization/Areas/User/Templates/Templates.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Templates/Templates.fr.resx @@ -119,4 +119,7 @@ Modèle rapide d'appel + + Activer les Minuteries de Pointage + diff --git a/Core/Resgrid.Localization/Areas/User/Templates/Templates.it.resx b/Core/Resgrid.Localization/Areas/User/Templates/Templates.it.resx index d98f37d3..abdc19f2 100644 --- a/Core/Resgrid.Localization/Areas/User/Templates/Templates.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Templates/Templates.it.resx @@ -119,4 +119,7 @@ Modello rapido chiamata + + Abilita Timer di Registrazione + diff --git a/Core/Resgrid.Localization/Areas/User/Templates/Templates.pl.resx b/Core/Resgrid.Localization/Areas/User/Templates/Templates.pl.resx index f2b18943..3c199f56 100644 --- a/Core/Resgrid.Localization/Areas/User/Templates/Templates.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Templates/Templates.pl.resx @@ -119,4 +119,7 @@ Szablon szybkiego zgłoszenia + + Włącz Czasomierze Meldowania + diff --git a/Core/Resgrid.Localization/Areas/User/Templates/Templates.sv.resx b/Core/Resgrid.Localization/Areas/User/Templates/Templates.sv.resx index 4af9eaf4..1390103f 100644 --- a/Core/Resgrid.Localization/Areas/User/Templates/Templates.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Templates/Templates.sv.resx @@ -119,4 +119,7 @@ Snabbmall för samtal + + Aktivera Incheckningstimers + diff --git a/Core/Resgrid.Localization/Areas/User/Templates/Templates.uk.resx b/Core/Resgrid.Localization/Areas/User/Templates/Templates.uk.resx index db1a3c10..4d0daff1 100644 --- a/Core/Resgrid.Localization/Areas/User/Templates/Templates.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Templates/Templates.uk.resx @@ -119,4 +119,7 @@ Шаблон швидкого виклику + + Увімкнути Таймери Реєстрації + diff --git a/Core/Resgrid.Model/AuditLogTypes.cs b/Core/Resgrid.Model/AuditLogTypes.cs index 7c93dc27..0377f71a 100644 --- a/Core/Resgrid.Model/AuditLogTypes.cs +++ b/Core/Resgrid.Model/AuditLogTypes.cs @@ -124,6 +124,16 @@ public enum AuditLogTypes RouteStopCheckedOut, RouteStopSkipped, RouteDeviationDetected, - RouteDeviationAcknowledged + RouteDeviationAcknowledged, + // Check-In Timers + CheckInTimerConfigCreated, + CheckInTimerConfigUpdated, + CheckInTimerConfigDeleted, + CheckInTimerOverrideCreated, + CheckInTimerOverrideUpdated, + CheckInTimerOverrideDeleted, + CheckInPerformed, + CheckInTimerEnabledOnCall, + CheckInTimerDisabledOnCall } } diff --git a/Core/Resgrid.Model/Call.cs b/Core/Resgrid.Model/Call.cs index b55e8b5e..7a843eb3 100644 --- a/Core/Resgrid.Model/Call.cs +++ b/Core/Resgrid.Model/Call.cs @@ -179,6 +179,8 @@ public class Call : IEntity public string IndoorMapFloorId { get; set; } + public bool CheckInTimersEnabled { get; set; } + [NotMapped] [JsonIgnore] public object IdValue diff --git a/Core/Resgrid.Model/CallQuickTemplate.cs b/Core/Resgrid.Model/CallQuickTemplate.cs index 80f94956..99ec9a05 100644 --- a/Core/Resgrid.Model/CallQuickTemplate.cs +++ b/Core/Resgrid.Model/CallQuickTemplate.cs @@ -38,6 +38,8 @@ public class CallQuickTemplate : IEntity public DateTime CreatedOn { get; set; } + public bool? CheckInTimersEnabled { get; set; } + [NotMapped] [JsonIgnore] public object IdValue diff --git a/Core/Resgrid.Model/CheckInRecord.cs b/Core/Resgrid.Model/CheckInRecord.cs new file mode 100644 index 00000000..ccea2fdb --- /dev/null +++ b/Core/Resgrid.Model/CheckInRecord.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + public class CheckInRecord : IEntity + { + public string CheckInRecordId { get; set; } + + public int DepartmentId { get; set; } + + public int CallId { get; set; } + + public int CheckInType { get; set; } + + public string UserId { get; set; } + + public int? UnitId { get; set; } + + public string Latitude { get; set; } + + public string Longitude { get; set; } + + public DateTime Timestamp { get; set; } + + public string Note { get; set; } + + [NotMapped] + public string TableName => "CheckInRecords"; + + [NotMapped] + public string IdName => "CheckInRecordId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return CheckInRecordId; } + set { CheckInRecordId = (string)value; } + } + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/CheckInTimerConfig.cs b/Core/Resgrid.Model/CheckInTimerConfig.cs new file mode 100644 index 00000000..ce4bf7e2 --- /dev/null +++ b/Core/Resgrid.Model/CheckInTimerConfig.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + public class CheckInTimerConfig : IEntity + { + public string CheckInTimerConfigId { get; set; } + + public int DepartmentId { get; set; } + + public int TimerTargetType { get; set; } + + public int? UnitTypeId { get; set; } + + public int DurationMinutes { get; set; } + + public int WarningThresholdMinutes { get; set; } + + public bool IsEnabled { get; set; } + + public string CreatedByUserId { get; set; } + + public DateTime CreatedOn { get; set; } + + public DateTime? UpdatedOn { get; set; } + + [NotMapped] + public string TableName => "CheckInTimerConfigs"; + + [NotMapped] + public string IdName => "CheckInTimerConfigId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return CheckInTimerConfigId; } + set { CheckInTimerConfigId = (string)value; } + } + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/CheckInTimerOverride.cs b/Core/Resgrid.Model/CheckInTimerOverride.cs new file mode 100644 index 00000000..416cf582 --- /dev/null +++ b/Core/Resgrid.Model/CheckInTimerOverride.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + public class CheckInTimerOverride : IEntity + { + public string CheckInTimerOverrideId { get; set; } + + public int DepartmentId { get; set; } + + public int? CallTypeId { get; set; } + + public int? CallPriority { get; set; } + + public int TimerTargetType { get; set; } + + public int? UnitTypeId { get; set; } + + public int DurationMinutes { get; set; } + + public int WarningThresholdMinutes { get; set; } + + public bool IsEnabled { get; set; } + + public string CreatedByUserId { get; set; } + + public DateTime CreatedOn { get; set; } + + public DateTime? UpdatedOn { get; set; } + + [NotMapped] + public string TableName => "CheckInTimerOverrides"; + + [NotMapped] + public string IdName => "CheckInTimerOverrideId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return CheckInTimerOverrideId; } + set { CheckInTimerOverrideId = (string)value; } + } + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/CheckInTimerStatus.cs b/Core/Resgrid.Model/CheckInTimerStatus.cs new file mode 100644 index 00000000..7f2f7cf5 --- /dev/null +++ b/Core/Resgrid.Model/CheckInTimerStatus.cs @@ -0,0 +1,25 @@ +using System; + +namespace Resgrid.Model +{ + public class CheckInTimerStatus + { + public int TargetType { get; set; } + + public string TargetEntityId { get; set; } + + public string TargetName { get; set; } + + public int? UnitId { get; set; } + + public DateTime? LastCheckIn { get; set; } + + public int DurationMinutes { get; set; } + + public int WarningThresholdMinutes { get; set; } + + public double ElapsedMinutes { get; set; } + + public string Status { get; set; } + } +} diff --git a/Core/Resgrid.Model/CheckInTimerTargetType.cs b/Core/Resgrid.Model/CheckInTimerTargetType.cs new file mode 100644 index 00000000..e3826879 --- /dev/null +++ b/Core/Resgrid.Model/CheckInTimerTargetType.cs @@ -0,0 +1,13 @@ +namespace Resgrid.Model +{ + public enum CheckInTimerTargetType + { + Personnel = 0, + UnitType = 1, + IC = 2, + PAR = 3, + HazmatExposure = 4, + SectorRotation = 5, + Rehab = 6 + } +} diff --git a/Core/Resgrid.Model/DepartmentSettingTypes.cs b/Core/Resgrid.Model/DepartmentSettingTypes.cs index 7634f1ec..99c05316 100644 --- a/Core/Resgrid.Model/DepartmentSettingTypes.cs +++ b/Core/Resgrid.Model/DepartmentSettingTypes.cs @@ -39,5 +39,6 @@ public enum DepartmentSettingTypes PersonnelOnUnitSetUnitStatus = 35, Require2FAForAdmins = 36, PaddleCustomerId = 37, + CheckInTimersAutoEnableForNewCalls = 38, } } diff --git a/Core/Resgrid.Model/Repositories/ICheckInRecordRepository.cs b/Core/Resgrid.Model/Repositories/ICheckInRecordRepository.cs new file mode 100644 index 00000000..a1ef2e4f --- /dev/null +++ b/Core/Resgrid.Model/Repositories/ICheckInRecordRepository.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + public interface ICheckInRecordRepository : IRepository + { + Task> GetByCallIdAsync(int callId); + Task GetLastCheckInForUserOnCallAsync(int callId, string userId); + Task GetLastCheckInForUnitOnCallAsync(int callId, int unitId); + Task> GetByDepartmentIdAndDateRangeAsync(int departmentId, DateTime start, DateTime end); + } +} diff --git a/Core/Resgrid.Model/Repositories/ICheckInTimerConfigRepository.cs b/Core/Resgrid.Model/Repositories/ICheckInTimerConfigRepository.cs new file mode 100644 index 00000000..9a306519 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/ICheckInTimerConfigRepository.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + public interface ICheckInTimerConfigRepository : IRepository + { + Task> GetByDepartmentIdAsync(int departmentId); + Task GetByDepartmentAndTargetAsync(int departmentId, int timerTargetType, int? unitTypeId); + } +} diff --git a/Core/Resgrid.Model/Repositories/ICheckInTimerOverrideRepository.cs b/Core/Resgrid.Model/Repositories/ICheckInTimerOverrideRepository.cs new file mode 100644 index 00000000..43da7877 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/ICheckInTimerOverrideRepository.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + public interface ICheckInTimerOverrideRepository : IRepository + { + Task> GetByDepartmentIdAsync(int departmentId); + Task> GetMatchingOverridesAsync(int departmentId, int? callTypeId, int? callPriority); + } +} diff --git a/Core/Resgrid.Model/ResolvedCheckInTimer.cs b/Core/Resgrid.Model/ResolvedCheckInTimer.cs new file mode 100644 index 00000000..19186403 --- /dev/null +++ b/Core/Resgrid.Model/ResolvedCheckInTimer.cs @@ -0,0 +1,19 @@ +namespace Resgrid.Model +{ + public class ResolvedCheckInTimer + { + public int TargetType { get; set; } + + public int? UnitTypeId { get; set; } + + public string TargetEntityId { get; set; } + + public string TargetName { get; set; } + + public int DurationMinutes { get; set; } + + public int WarningThresholdMinutes { get; set; } + + public bool IsFromOverride { get; set; } + } +} diff --git a/Core/Resgrid.Model/Services/ICheckInTimerService.cs b/Core/Resgrid.Model/Services/ICheckInTimerService.cs new file mode 100644 index 00000000..09a5bde2 --- /dev/null +++ b/Core/Resgrid.Model/Services/ICheckInTimerService.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + public interface ICheckInTimerService + { + // Configuration CRUD + Task> GetTimerConfigsForDepartmentAsync(int departmentId); + Task SaveTimerConfigAsync(CheckInTimerConfig config, CancellationToken cancellationToken = default); + Task DeleteTimerConfigAsync(string configId, CancellationToken cancellationToken = default); + + // Override CRUD + Task> GetTimerOverridesForDepartmentAsync(int departmentId); + Task SaveTimerOverrideAsync(CheckInTimerOverride ovr, CancellationToken cancellationToken = default); + Task DeleteTimerOverrideAsync(string overrideId, CancellationToken cancellationToken = default); + + // Timer Resolution + Task> ResolveAllTimersForCallAsync(Call call); + + // Check-in Operations + Task PerformCheckInAsync(CheckInRecord record, CancellationToken cancellationToken = default); + Task> GetCheckInsForCallAsync(int callId); + Task GetLastCheckInAsync(int callId, string userId, int? unitId); + + // Timer Status Computation + Task> GetActiveTimerStatusesForCallAsync(Call call); + } +} diff --git a/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs b/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs index 7c99446e..62d5d4c0 100644 --- a/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs +++ b/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs @@ -285,5 +285,7 @@ public interface IDepartmentSettingsService Task GetDepartmentIdForPaddleCustomerIdAsync(string paddleCustomerId, bool bypassCache = false); Task GetPaddleCustomerIdForDepartmentAsync(int departmentId); + + Task GetCheckInTimersAutoEnableForNewCallsAsync(int departmentId); } } diff --git a/Core/Resgrid.Services/CheckInTimerService.cs b/Core/Resgrid.Services/CheckInTimerService.cs new file mode 100644 index 00000000..e1670008 --- /dev/null +++ b/Core/Resgrid.Services/CheckInTimerService.cs @@ -0,0 +1,236 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + public class CheckInTimerService : ICheckInTimerService + { + private readonly ICheckInTimerConfigRepository _configRepository; + private readonly ICheckInTimerOverrideRepository _overrideRepository; + private readonly ICheckInRecordRepository _recordRepository; + + public CheckInTimerService( + ICheckInTimerConfigRepository configRepository, + ICheckInTimerOverrideRepository overrideRepository, + ICheckInRecordRepository recordRepository) + { + _configRepository = configRepository; + _overrideRepository = overrideRepository; + _recordRepository = recordRepository; + } + + #region Configuration CRUD + + public async Task> GetTimerConfigsForDepartmentAsync(int departmentId) + { + var configs = await _configRepository.GetByDepartmentIdAsync(departmentId); + return configs?.ToList() ?? new List(); + } + + public async Task SaveTimerConfigAsync(CheckInTimerConfig config, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(config.CheckInTimerConfigId)) + config.CreatedOn = DateTime.UtcNow; + else + config.UpdatedOn = DateTime.UtcNow; + + return await _configRepository.SaveOrUpdateAsync(config, cancellationToken); + } + + public async Task DeleteTimerConfigAsync(string configId, CancellationToken cancellationToken = default) + { + var config = await _configRepository.GetByIdAsync(configId); + if (config == null) + return false; + + return await _configRepository.DeleteAsync(config, cancellationToken); + } + + #endregion Configuration CRUD + + #region Override CRUD + + public async Task> GetTimerOverridesForDepartmentAsync(int departmentId) + { + var overrides = await _overrideRepository.GetByDepartmentIdAsync(departmentId); + return overrides?.ToList() ?? new List(); + } + + public async Task SaveTimerOverrideAsync(CheckInTimerOverride ovr, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(ovr.CheckInTimerOverrideId)) + ovr.CreatedOn = DateTime.UtcNow; + else + ovr.UpdatedOn = DateTime.UtcNow; + + return await _overrideRepository.SaveOrUpdateAsync(ovr, cancellationToken); + } + + public async Task DeleteTimerOverrideAsync(string overrideId, CancellationToken cancellationToken = default) + { + var ovr = await _overrideRepository.GetByIdAsync(overrideId); + if (ovr == null) + return false; + + return await _overrideRepository.DeleteAsync(ovr, cancellationToken); + } + + #endregion Override CRUD + + #region Timer Resolution + + public async Task> ResolveAllTimersForCallAsync(Call call) + { + if (call == null || !call.CheckInTimersEnabled) + return new List(); + + var defaults = await _configRepository.GetByDepartmentIdAsync(call.DepartmentId); + var defaultList = defaults?.Where(c => c.IsEnabled).ToList() ?? new List(); + + // Parse call type as int for override matching + int? callTypeId = null; + if (!string.IsNullOrWhiteSpace(call.Type) && int.TryParse(call.Type, out int parsedType)) + callTypeId = parsedType; + + var overrides = await _overrideRepository.GetMatchingOverridesAsync(call.DepartmentId, callTypeId, call.Priority); + var overrideList = overrides?.ToList() ?? new List(); + + var resolved = new Dictionary(); + + // First, populate from defaults + foreach (var def in defaultList) + { + var key = $"{def.TimerTargetType}_{def.UnitTypeId}"; + resolved[key] = new ResolvedCheckInTimer + { + TargetType = def.TimerTargetType, + UnitTypeId = def.UnitTypeId, + DurationMinutes = def.DurationMinutes, + WarningThresholdMinutes = def.WarningThresholdMinutes, + IsFromOverride = false + }; + } + + // Then, apply overrides with scoring: type+priority=3, type-only=2, priority-only=1 + var scoredOverrides = overrideList + .Select(o => new + { + Override = o, + Score = (o.CallTypeId.HasValue && o.CallPriority.HasValue) ? 3 + : o.CallTypeId.HasValue ? 2 + : o.CallPriority.HasValue ? 1 + : 0 + }) + .OrderByDescending(x => x.Score) + .ToList(); + + foreach (var scored in scoredOverrides) + { + var o = scored.Override; + var key = $"{o.TimerTargetType}_{o.UnitTypeId}"; + + // Only apply if this is the best scoring override for this key + if (!resolved.ContainsKey(key) || !resolved[key].IsFromOverride) + { + resolved[key] = new ResolvedCheckInTimer + { + TargetType = o.TimerTargetType, + UnitTypeId = o.UnitTypeId, + DurationMinutes = o.DurationMinutes, + WarningThresholdMinutes = o.WarningThresholdMinutes, + IsFromOverride = true + }; + } + } + + return resolved.Values.ToList(); + } + + #endregion Timer Resolution + + #region Check-in Operations + + public async Task PerformCheckInAsync(CheckInRecord record, CancellationToken cancellationToken = default) + { + record.Timestamp = DateTime.UtcNow; + return await _recordRepository.SaveOrUpdateAsync(record, cancellationToken); + } + + public async Task> GetCheckInsForCallAsync(int callId) + { + var records = await _recordRepository.GetByCallIdAsync(callId); + return records?.ToList() ?? new List(); + } + + public async Task GetLastCheckInAsync(int callId, string userId, int? unitId) + { + if (unitId.HasValue) + return await _recordRepository.GetLastCheckInForUnitOnCallAsync(callId, unitId.Value); + + return await _recordRepository.GetLastCheckInForUserOnCallAsync(callId, userId); + } + + #endregion Check-in Operations + + #region Timer Status Computation + + public async Task> GetActiveTimerStatusesForCallAsync(Call call) + { + if (call == null || !call.CheckInTimersEnabled || call.State != (int)CallStates.Active) + return new List(); + + var resolvedTimers = await ResolveAllTimersForCallAsync(call); + if (!resolvedTimers.Any()) + return new List(); + + var checkIns = await _recordRepository.GetByCallIdAsync(call.CallId); + var checkInList = checkIns?.ToList() ?? new List(); + + var statuses = new List(); + var now = DateTime.UtcNow; + + foreach (var timer in resolvedTimers) + { + // Find the latest check-in matching this timer target + var relevantCheckIns = checkInList + .Where(c => c.CheckInType == timer.TargetType) + .OrderByDescending(c => c.Timestamp) + .FirstOrDefault(); + + var baseTime = relevantCheckIns?.Timestamp ?? call.LoggedOn; + var elapsed = (now - baseTime).TotalMinutes; + + string status; + if (elapsed < timer.DurationMinutes) + status = "Green"; + else if (elapsed < timer.DurationMinutes + timer.WarningThresholdMinutes) + status = "Warning"; + else + status = "Critical"; + + statuses.Add(new CheckInTimerStatus + { + TargetType = timer.TargetType, + TargetEntityId = timer.TargetEntityId, + TargetName = timer.TargetName ?? ((CheckInTimerTargetType)timer.TargetType).ToString(), + UnitId = timer.UnitTypeId.HasValue ? timer.UnitTypeId : null, + LastCheckIn = relevantCheckIns?.Timestamp, + DurationMinutes = timer.DurationMinutes, + WarningThresholdMinutes = timer.WarningThresholdMinutes, + ElapsedMinutes = Math.Round(elapsed, 1), + Status = status + }); + } + + return statuses; + } + + #endregion Timer Status Computation + } +} diff --git a/Core/Resgrid.Services/DepartmentSettingsService.cs b/Core/Resgrid.Services/DepartmentSettingsService.cs index 257be104..9aac66f4 100644 --- a/Core/Resgrid.Services/DepartmentSettingsService.cs +++ b/Core/Resgrid.Services/DepartmentSettingsService.cs @@ -787,5 +787,15 @@ async Task getSetting() return null; } + + public async Task GetCheckInTimersAutoEnableForNewCallsAsync(int departmentId) + { + var s = await GetSettingByDepartmentIdType(departmentId, DepartmentSettingTypes.CheckInTimersAutoEnableForNewCalls); + + if (s != null && bool.TryParse(s.Setting, out bool result)) + return result; + + return false; + } } } diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index 793d0405..59b19aa8 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -86,6 +86,7 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); // UDF Services builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0056_AddingCheckInTimers.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0056_AddingCheckInTimers.cs new file mode 100644 index 00000000..f0588b24 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0056_AddingCheckInTimers.cs @@ -0,0 +1,117 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(56)] + public class M0056_AddingCheckInTimers : Migration + { + public override void Up() + { + // ── CheckInTimerConfigs ───────────────────────────────── + Create.Table("CheckInTimerConfigs") + .WithColumn("CheckInTimerConfigId").AsString(128).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("TimerTargetType").AsInt32().NotNullable() + .WithColumn("UnitTypeId").AsInt32().Nullable() + .WithColumn("DurationMinutes").AsInt32().NotNullable() + .WithColumn("WarningThresholdMinutes").AsInt32().NotNullable() + .WithColumn("IsEnabled").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("CreatedByUserId").AsString(128).NotNullable() + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("UpdatedOn").AsDateTime2().Nullable(); + + Create.ForeignKey("FK_CheckInTimerConfigs_Departments") + .FromTable("CheckInTimerConfigs").ForeignColumn("DepartmentId") + .ToTable("Departments").PrimaryColumn("DepartmentId"); + + Create.Index("IX_CheckInTimerConfigs_DepartmentId") + .OnTable("CheckInTimerConfigs") + .OnColumn("DepartmentId"); + + Create.UniqueConstraint("UQ_CheckInTimerConfigs_Dept_Target_Unit") + .OnTable("CheckInTimerConfigs") + .Columns("DepartmentId", "TimerTargetType", "UnitTypeId"); + + // ── CheckInTimerOverrides ────────────────────────────── + Create.Table("CheckInTimerOverrides") + .WithColumn("CheckInTimerOverrideId").AsString(128).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("CallTypeId").AsInt32().Nullable() + .WithColumn("CallPriority").AsInt32().Nullable() + .WithColumn("TimerTargetType").AsInt32().NotNullable() + .WithColumn("UnitTypeId").AsInt32().Nullable() + .WithColumn("DurationMinutes").AsInt32().NotNullable() + .WithColumn("WarningThresholdMinutes").AsInt32().NotNullable() + .WithColumn("IsEnabled").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("CreatedByUserId").AsString(128).NotNullable() + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("UpdatedOn").AsDateTime2().Nullable(); + + Create.ForeignKey("FK_CheckInTimerOverrides_Departments") + .FromTable("CheckInTimerOverrides").ForeignColumn("DepartmentId") + .ToTable("Departments").PrimaryColumn("DepartmentId"); + + Create.Index("IX_CheckInTimerOverrides_DepartmentId") + .OnTable("CheckInTimerOverrides") + .OnColumn("DepartmentId"); + + Create.UniqueConstraint("UQ_CheckInTimerOverrides_Dept_Call_Target_Unit") + .OnTable("CheckInTimerOverrides") + .Columns("DepartmentId", "CallTypeId", "CallPriority", "TimerTargetType", "UnitTypeId"); + + // ── CheckInRecords ───────────────────────────────────── + Create.Table("CheckInRecords") + .WithColumn("CheckInRecordId").AsString(128).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("CallId").AsInt32().NotNullable() + .WithColumn("CheckInType").AsInt32().NotNullable() + .WithColumn("UserId").AsString(128).NotNullable() + .WithColumn("UnitId").AsInt32().Nullable() + .WithColumn("Latitude").AsString(50).Nullable() + .WithColumn("Longitude").AsString(50).Nullable() + .WithColumn("Timestamp").AsDateTime2().NotNullable() + .WithColumn("Note").AsString(1000).Nullable(); + + Create.ForeignKey("FK_CheckInRecords_Departments") + .FromTable("CheckInRecords").ForeignColumn("DepartmentId") + .ToTable("Departments").PrimaryColumn("DepartmentId"); + + Create.ForeignKey("FK_CheckInRecords_Calls") + .FromTable("CheckInRecords").ForeignColumn("CallId") + .ToTable("Calls").PrimaryColumn("CallId"); + + Create.Index("IX_CheckInRecords_CallId") + .OnTable("CheckInRecords") + .OnColumn("CallId"); + + Create.Index("IX_CheckInRecords_DepartmentId_Timestamp") + .OnTable("CheckInRecords") + .OnColumn("DepartmentId").Ascending() + .OnColumn("Timestamp").Descending(); + + // ── Alter Calls ──────────────────────────────────────── + Alter.Table("Calls") + .AddColumn("CheckInTimersEnabled").AsBoolean().NotNullable().WithDefaultValue(false); + + // ── Alter CallQuickTemplates ──────────────────────────── + Alter.Table("CallQuickTemplates") + .AddColumn("CheckInTimersEnabled").AsBoolean().Nullable(); + } + + public override void Down() + { + Delete.Column("CheckInTimersEnabled").FromTable("CallQuickTemplates"); + Delete.Column("CheckInTimersEnabled").FromTable("Calls"); + + Delete.ForeignKey("FK_CheckInRecords_Calls").OnTable("CheckInRecords"); + Delete.ForeignKey("FK_CheckInRecords_Departments").OnTable("CheckInRecords"); + Delete.Table("CheckInRecords"); + + Delete.ForeignKey("FK_CheckInTimerOverrides_Departments").OnTable("CheckInTimerOverrides"); + Delete.Table("CheckInTimerOverrides"); + + Delete.ForeignKey("FK_CheckInTimerConfigs_Departments").OnTable("CheckInTimerConfigs"); + Delete.Table("CheckInTimerConfigs"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0056_AddingCheckInTimersPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0056_AddingCheckInTimersPg.cs new file mode 100644 index 00000000..75f844c7 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0056_AddingCheckInTimersPg.cs @@ -0,0 +1,117 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(56)] + public class M0056_AddingCheckInTimersPg : Migration + { + public override void Up() + { + // ── checkIntimerconfigs ───────────────────────────────── + Create.Table("checkintimerconfigs") + .WithColumn("checkintimerconfigid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("timertargettype").AsInt32().NotNullable() + .WithColumn("unittypeid").AsInt32().Nullable() + .WithColumn("durationminutes").AsInt32().NotNullable() + .WithColumn("warningthresholdminutes").AsInt32().NotNullable() + .WithColumn("isenabled").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("createdbyuserid").AsCustom("citext").NotNullable() + .WithColumn("createdon").AsDateTime().NotNullable() + .WithColumn("updatedon").AsDateTime().Nullable(); + + Create.ForeignKey("fk_checkintimerconfigs_departments") + .FromTable("checkintimerconfigs").ForeignColumn("departmentid") + .ToTable("departments").PrimaryColumn("departmentid"); + + Create.Index("ix_checkintimerconfigs_departmentid") + .OnTable("checkintimerconfigs") + .OnColumn("departmentid"); + + Create.UniqueConstraint("uq_checkintimerconfigs_dept_target_unit") + .OnTable("checkintimerconfigs") + .Columns("departmentid", "timertargettype", "unittypeid"); + + // ── checkintimeroverrides ────────────────────────────── + Create.Table("checkintimeroverrides") + .WithColumn("checkintimeroverrideid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("calltypeid").AsInt32().Nullable() + .WithColumn("callpriority").AsInt32().Nullable() + .WithColumn("timertargettype").AsInt32().NotNullable() + .WithColumn("unittypeid").AsInt32().Nullable() + .WithColumn("durationminutes").AsInt32().NotNullable() + .WithColumn("warningthresholdminutes").AsInt32().NotNullable() + .WithColumn("isenabled").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("createdbyuserid").AsCustom("citext").NotNullable() + .WithColumn("createdon").AsDateTime().NotNullable() + .WithColumn("updatedon").AsDateTime().Nullable(); + + Create.ForeignKey("fk_checkintimeroverrides_departments") + .FromTable("checkintimeroverrides").ForeignColumn("departmentid") + .ToTable("departments").PrimaryColumn("departmentid"); + + Create.Index("ix_checkintimeroverrides_departmentid") + .OnTable("checkintimeroverrides") + .OnColumn("departmentid"); + + Create.UniqueConstraint("uq_checkintimeroverrides_dept_call_target_unit") + .OnTable("checkintimeroverrides") + .Columns("departmentid", "calltypeid", "callpriority", "timertargettype", "unittypeid"); + + // ── checkinrecords ───────────────────────────────────── + Create.Table("checkinrecords") + .WithColumn("checkinrecordid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("callid").AsInt32().NotNullable() + .WithColumn("checkintype").AsInt32().NotNullable() + .WithColumn("userid").AsCustom("citext").NotNullable() + .WithColumn("unitid").AsInt32().Nullable() + .WithColumn("latitude").AsCustom("citext").Nullable() + .WithColumn("longitude").AsCustom("citext").Nullable() + .WithColumn("timestamp").AsDateTime().NotNullable() + .WithColumn("note").AsCustom("citext").Nullable(); + + Create.ForeignKey("fk_checkinrecords_departments") + .FromTable("checkinrecords").ForeignColumn("departmentid") + .ToTable("departments").PrimaryColumn("departmentid"); + + Create.ForeignKey("fk_checkinrecords_calls") + .FromTable("checkinrecords").ForeignColumn("callid") + .ToTable("calls").PrimaryColumn("callid"); + + Create.Index("ix_checkinrecords_callid") + .OnTable("checkinrecords") + .OnColumn("callid"); + + Create.Index("ix_checkinrecords_departmentid_timestamp") + .OnTable("checkinrecords") + .OnColumn("departmentid").Ascending() + .OnColumn("timestamp").Descending(); + + // ── Alter calls ──────────────────────────────────────── + Alter.Table("calls") + .AddColumn("checkintimersenabled").AsBoolean().NotNullable().WithDefaultValue(false); + + // ── Alter callquicktemplates ──────────────────────────── + Alter.Table("callquicktemplates") + .AddColumn("checkintimersenabled").AsBoolean().Nullable(); + } + + public override void Down() + { + Delete.Column("checkintimersenabled").FromTable("callquicktemplates"); + Delete.Column("checkintimersenabled").FromTable("calls"); + + Delete.ForeignKey("fk_checkinrecords_calls").OnTable("checkinrecords"); + Delete.ForeignKey("fk_checkinrecords_departments").OnTable("checkinrecords"); + Delete.Table("checkinrecords"); + + Delete.ForeignKey("fk_checkintimeroverrides_departments").OnTable("checkintimeroverrides"); + Delete.Table("checkintimeroverrides"); + + Delete.ForeignKey("fk_checkintimerconfigs_departments").OnTable("checkintimerconfigs"); + Delete.Table("checkintimerconfigs"); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/CheckInRecordRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/CheckInRecordRepository.cs new file mode 100644 index 00000000..b49b1a48 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/CheckInRecordRepository.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Framework; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; +using Resgrid.Repositories.DataRepository.Queries.CheckIns; + +namespace Resgrid.Repositories.DataRepository +{ + public class CheckInRecordRepository : RepositoryBase, ICheckInRecordRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly SqlConfiguration _sqlConfiguration; + private readonly IQueryFactory _queryFactory; + private readonly IUnitOfWork _unitOfWork; + + public CheckInRecordRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, + IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _sqlConfiguration = sqlConfiguration; + _queryFactory = queryFactory; + _unitOfWork = unitOfWork; + } + + public async Task> GetByCallIdAsync(int callId) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("CallId", callId); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, param: dynamicParameters, transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + + public async Task GetLastCheckInForUserOnCallAsync(int callId, string userId) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("CallId", callId); + dynamicParameters.Add("UserId", userId); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, param: dynamicParameters, transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + return (await selectFunction(conn)).FirstOrDefault(); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + return (await selectFunction(conn)).FirstOrDefault(); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + + public async Task GetLastCheckInForUnitOnCallAsync(int callId, int unitId) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("CallId", callId); + dynamicParameters.Add("UnitId", unitId); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, param: dynamicParameters, transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + return (await selectFunction(conn)).FirstOrDefault(); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + return (await selectFunction(conn)).FirstOrDefault(); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + + public async Task> GetByDepartmentIdAndDateRangeAsync(int departmentId, DateTime start, DateTime end) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("DepartmentId", departmentId); + dynamicParameters.Add("StartDate", start); + dynamicParameters.Add("EndDate", end); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, param: dynamicParameters, transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/CheckInTimerConfigRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/CheckInTimerConfigRepository.cs new file mode 100644 index 00000000..8ba38255 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/CheckInTimerConfigRepository.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Framework; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; +using Resgrid.Repositories.DataRepository.Queries.CheckIns; + +namespace Resgrid.Repositories.DataRepository +{ + public class CheckInTimerConfigRepository : RepositoryBase, ICheckInTimerConfigRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly SqlConfiguration _sqlConfiguration; + private readonly IQueryFactory _queryFactory; + private readonly IUnitOfWork _unitOfWork; + + public CheckInTimerConfigRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, + IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _sqlConfiguration = sqlConfiguration; + _queryFactory = queryFactory; + _unitOfWork = unitOfWork; + } + + public async Task> GetByDepartmentIdAsync(int departmentId) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("DepartmentId", departmentId); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, param: dynamicParameters, transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + + public async Task GetByDepartmentAndTargetAsync(int departmentId, int timerTargetType, int? unitTypeId) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("DepartmentId", departmentId); + dynamicParameters.Add("TimerTargetType", timerTargetType); + dynamicParameters.Add("UnitTypeId", unitTypeId); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, param: dynamicParameters, transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + return (await selectFunction(conn)).FirstOrDefault(); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + return (await selectFunction(conn)).FirstOrDefault(); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/CheckInTimerOverrideRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/CheckInTimerOverrideRepository.cs new file mode 100644 index 00000000..bb79599e --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/CheckInTimerOverrideRepository.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Framework; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; +using Resgrid.Repositories.DataRepository.Queries.CheckIns; + +namespace Resgrid.Repositories.DataRepository +{ + public class CheckInTimerOverrideRepository : RepositoryBase, ICheckInTimerOverrideRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly SqlConfiguration _sqlConfiguration; + private readonly IQueryFactory _queryFactory; + private readonly IUnitOfWork _unitOfWork; + + public CheckInTimerOverrideRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, + IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _sqlConfiguration = sqlConfiguration; + _queryFactory = queryFactory; + _unitOfWork = unitOfWork; + } + + public async Task> GetByDepartmentIdAsync(int departmentId) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("DepartmentId", departmentId); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, param: dynamicParameters, transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + + public async Task> GetMatchingOverridesAsync(int departmentId, int? callTypeId, int? callPriority) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("DepartmentId", departmentId); + dynamicParameters.Add("CallTypeId", callTypeId); + dynamicParameters.Add("CallPriority", callPriority); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, param: dynamicParameters, transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs index 49e071c1..70373f99 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs @@ -509,6 +509,20 @@ protected SqlConfiguration() { } public string SelectUnacknowledgedRouteDeviationsByDepartmentQuery { get; set; } #endregion Routes + #region CheckIns + public string CheckInTimerConfigsTableName { get; set; } + public string CheckInTimerOverridesTableName { get; set; } + public string CheckInRecordsTableName { get; set; } + public string SelectCheckInTimerConfigsByDepartmentIdQuery { get; set; } + public string SelectCheckInTimerConfigByDepartmentAndTargetQuery { get; set; } + public string SelectCheckInTimerOverridesByDepartmentIdQuery { get; set; } + public string SelectMatchingCheckInTimerOverridesQuery { get; set; } + public string SelectCheckInRecordsByCallIdQuery { get; set; } + public string SelectLastCheckInForUserOnCallQuery { get; set; } + public string SelectLastCheckInForUnitOnCallQuery { get; set; } + public string SelectCheckInRecordsByDepartmentIdAndDateRangeQuery { get; set; } + #endregion CheckIns + // Identity #region Table Names diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs index daeb4232..1488dbe0 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs @@ -185,6 +185,11 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // CheckIn Repositories + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + // SSO Repositories builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs index 333148f5..5321df74 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -184,6 +184,11 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // CheckIn Repositories + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + // SSO Repositories builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs index 18119d65..3d97b768 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs @@ -184,6 +184,11 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // CheckIn Repositories + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + // SSO Repositories builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs index d0df4789..0540c911 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs @@ -184,6 +184,11 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // CheckIn Repositories + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + // SSO Repositories builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInRecordsByCallIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInRecordsByCallIdQuery.cs new file mode 100644 index 00000000..cf66d71a --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInRecordsByCallIdQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.CheckIns +{ + public class SelectCheckInRecordsByCallIdQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectCheckInRecordsByCallIdQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectCheckInRecordsByCallIdQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.CheckInRecordsTableName, + _sqlConfiguration.ParameterNotation, + new string[] { "%CALLID%" }, + new string[] { "CallId" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInRecordsByDepartmentIdAndDateRangeQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInRecordsByDepartmentIdAndDateRangeQuery.cs new file mode 100644 index 00000000..1ab630f8 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInRecordsByDepartmentIdAndDateRangeQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.CheckIns +{ + public class SelectCheckInRecordsByDepartmentIdAndDateRangeQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectCheckInRecordsByDepartmentIdAndDateRangeQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectCheckInRecordsByDepartmentIdAndDateRangeQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.CheckInRecordsTableName, + _sqlConfiguration.ParameterNotation, + new string[] { "%DID%", "%STARTDATE%", "%ENDDATE%" }, + new string[] { "DepartmentId", "StartDate", "EndDate" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInTimerConfigByDepartmentAndTargetQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInTimerConfigByDepartmentAndTargetQuery.cs new file mode 100644 index 00000000..097b5801 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInTimerConfigByDepartmentAndTargetQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.CheckIns +{ + public class SelectCheckInTimerConfigByDepartmentAndTargetQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectCheckInTimerConfigByDepartmentAndTargetQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectCheckInTimerConfigByDepartmentAndTargetQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.CheckInTimerConfigsTableName, + _sqlConfiguration.ParameterNotation, + new string[] { "%DID%", "%TTT%", "%UTID%" }, + new string[] { "DepartmentId", "TimerTargetType", "UnitTypeId" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInTimerConfigsByDepartmentIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInTimerConfigsByDepartmentIdQuery.cs new file mode 100644 index 00000000..3b1b05d1 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInTimerConfigsByDepartmentIdQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.CheckIns +{ + public class SelectCheckInTimerConfigsByDepartmentIdQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectCheckInTimerConfigsByDepartmentIdQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectCheckInTimerConfigsByDepartmentIdQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.CheckInTimerConfigsTableName, + _sqlConfiguration.ParameterNotation, + new string[] { "%DID%" }, + new string[] { "DepartmentId" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInTimerOverridesByDepartmentIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInTimerOverridesByDepartmentIdQuery.cs new file mode 100644 index 00000000..69d04bbd --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInTimerOverridesByDepartmentIdQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.CheckIns +{ + public class SelectCheckInTimerOverridesByDepartmentIdQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectCheckInTimerOverridesByDepartmentIdQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectCheckInTimerOverridesByDepartmentIdQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.CheckInTimerOverridesTableName, + _sqlConfiguration.ParameterNotation, + new string[] { "%DID%" }, + new string[] { "DepartmentId" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectLastCheckInForUnitOnCallQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectLastCheckInForUnitOnCallQuery.cs new file mode 100644 index 00000000..861feee4 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectLastCheckInForUnitOnCallQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.CheckIns +{ + public class SelectLastCheckInForUnitOnCallQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectLastCheckInForUnitOnCallQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectLastCheckInForUnitOnCallQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.CheckInRecordsTableName, + _sqlConfiguration.ParameterNotation, + new string[] { "%CALLID%", "%UNITID%" }, + new string[] { "CallId", "UnitId" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectLastCheckInForUserOnCallQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectLastCheckInForUserOnCallQuery.cs new file mode 100644 index 00000000..8bddf111 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectLastCheckInForUserOnCallQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.CheckIns +{ + public class SelectLastCheckInForUserOnCallQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectLastCheckInForUserOnCallQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectLastCheckInForUserOnCallQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.CheckInRecordsTableName, + _sqlConfiguration.ParameterNotation, + new string[] { "%CALLID%", "%UID%" }, + new string[] { "CallId", "UserId" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectMatchingCheckInTimerOverridesQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectMatchingCheckInTimerOverridesQuery.cs new file mode 100644 index 00000000..8f980f05 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectMatchingCheckInTimerOverridesQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.CheckIns +{ + public class SelectMatchingCheckInTimerOverridesQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectMatchingCheckInTimerOverridesQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectMatchingCheckInTimerOverridesQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.CheckInTimerOverridesTableName, + _sqlConfiguration.ParameterNotation, + new string[] { "%DID%", "%CTID%", "%CPRI%" }, + new string[] { "DepartmentId", "CallTypeId", "CallPriority" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs index fbec5279..1a90cdeb 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs @@ -1566,6 +1566,55 @@ SELECT d.* ORDER BY d.DetectedOn DESC"; #endregion Routes + #region CheckIns + CheckInTimerConfigsTableName = "CheckInTimerConfigs"; + CheckInTimerOverridesTableName = "CheckInTimerOverrides"; + CheckInRecordsTableName = "CheckInRecords"; + + SelectCheckInTimerConfigsByDepartmentIdQuery = @" + SELECT * + FROM %SCHEMA%.%TABLENAME% + WHERE DepartmentId = %DID%"; + SelectCheckInTimerConfigByDepartmentAndTargetQuery = @" + SELECT * + FROM %SCHEMA%.%TABLENAME% + WHERE DepartmentId = %DID% AND TimerTargetType = %TTT% + AND (UnitTypeId = %UTID% OR (%UTID% IS NULL AND UnitTypeId IS NULL))"; + SelectCheckInTimerOverridesByDepartmentIdQuery = @" + SELECT * + FROM %SCHEMA%.%TABLENAME% + WHERE DepartmentId = %DID%"; + SelectMatchingCheckInTimerOverridesQuery = @" + SELECT * + FROM %SCHEMA%.%TABLENAME% + WHERE DepartmentId = %DID% AND IsEnabled = true + AND (CallTypeId = %CTID% OR CallTypeId IS NULL) + AND (CallPriority = %CPRI% OR CallPriority IS NULL)"; + SelectCheckInRecordsByCallIdQuery = @" + SELECT * + FROM %SCHEMA%.%TABLENAME% + WHERE CallId = %CALLID% + ORDER BY Timestamp DESC"; + SelectLastCheckInForUserOnCallQuery = @" + SELECT * + FROM %SCHEMA%.%TABLENAME% + WHERE CallId = %CALLID% AND UserId = %UID% + ORDER BY Timestamp DESC + LIMIT 1"; + SelectLastCheckInForUnitOnCallQuery = @" + SELECT * + FROM %SCHEMA%.%TABLENAME% + WHERE CallId = %CALLID% AND UnitId = %UNITID% + ORDER BY Timestamp DESC + LIMIT 1"; + SelectCheckInRecordsByDepartmentIdAndDateRangeQuery = @" + SELECT * + FROM %SCHEMA%.%TABLENAME% + WHERE DepartmentId = %DID% + AND Timestamp >= %STARTDATE% AND Timestamp <= %ENDDATE% + ORDER BY Timestamp DESC"; + #endregion CheckIns + #region User Defined Fields UdfDefinitionsTableName = "UdfDefinitions"; UdfFieldsTableName = "UdfFields"; diff --git a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs index f733589d..0fde83fd 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs @@ -1530,6 +1530,53 @@ SELECT d.* ORDER BY d.[DetectedOn] DESC"; #endregion Routes + #region CheckIns + CheckInTimerConfigsTableName = "CheckInTimerConfigs"; + CheckInTimerOverridesTableName = "CheckInTimerOverrides"; + CheckInRecordsTableName = "CheckInRecords"; + + SelectCheckInTimerConfigsByDepartmentIdQuery = @" + SELECT * + FROM %SCHEMA%.%TABLENAME% + WHERE [DepartmentId] = %DID%"; + SelectCheckInTimerConfigByDepartmentAndTargetQuery = @" + SELECT * + FROM %SCHEMA%.%TABLENAME% + WHERE [DepartmentId] = %DID% AND [TimerTargetType] = %TTT% + AND ([UnitTypeId] = %UTID% OR (%UTID% IS NULL AND [UnitTypeId] IS NULL))"; + SelectCheckInTimerOverridesByDepartmentIdQuery = @" + SELECT * + FROM %SCHEMA%.%TABLENAME% + WHERE [DepartmentId] = %DID%"; + SelectMatchingCheckInTimerOverridesQuery = @" + SELECT * + FROM %SCHEMA%.%TABLENAME% + WHERE [DepartmentId] = %DID% AND [IsEnabled] = 1 + AND ([CallTypeId] = %CTID% OR [CallTypeId] IS NULL) + AND ([CallPriority] = %CPRI% OR [CallPriority] IS NULL)"; + SelectCheckInRecordsByCallIdQuery = @" + SELECT * + FROM %SCHEMA%.%TABLENAME% + WHERE [CallId] = %CALLID% + ORDER BY [Timestamp] DESC"; + SelectLastCheckInForUserOnCallQuery = @" + SELECT TOP 1 * + FROM %SCHEMA%.%TABLENAME% + WHERE [CallId] = %CALLID% AND [UserId] = %UID% + ORDER BY [Timestamp] DESC"; + SelectLastCheckInForUnitOnCallQuery = @" + SELECT TOP 1 * + FROM %SCHEMA%.%TABLENAME% + WHERE [CallId] = %CALLID% AND [UnitId] = %UNITID% + ORDER BY [Timestamp] DESC"; + SelectCheckInRecordsByDepartmentIdAndDateRangeQuery = @" + SELECT * + FROM %SCHEMA%.%TABLENAME% + WHERE [DepartmentId] = %DID% + AND [Timestamp] >= %STARTDATE% AND [Timestamp] <= %ENDDATE% + ORDER BY [Timestamp] DESC"; + #endregion CheckIns + #region User Defined Fields UdfDefinitionsTableName = "UdfDefinitions"; UdfFieldsTableName = "UdfFields"; diff --git a/Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs b/Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs new file mode 100644 index 00000000..cc48cd30 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs @@ -0,0 +1,271 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class CheckInTimerServiceTests + { + private Mock _configRepo; + private Mock _overrideRepo; + private Mock _recordRepo; + private CheckInTimerService _service; + + [SetUp] + public void SetUp() + { + _configRepo = new Mock(); + _overrideRepo = new Mock(); + _recordRepo = new Mock(); + _service = new CheckInTimerService(_configRepo.Object, _overrideRepo.Object, _recordRepo.Object); + } + + #region Timer Resolution + + [Test] + public async Task ResolveAllTimersForCallAsync_DefaultConfigReturned_WhenNoOverrideExists() + { + var call = new Call { CallId = 1, DepartmentId = 10, Priority = 0, CheckInTimersEnabled = true }; + 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()); + + var result = await _service.ResolveAllTimersForCallAsync(call); + + result.Should().HaveCount(1); + result[0].DurationMinutes.Should().Be(30); + result[0].IsFromOverride.Should().BeFalse(); + } + + [Test] + public async Task ResolveAllTimersForCallAsync_TypePriorityOverride_WinsOverTypeOnly() + { + var call = new Call { CallId = 1, DepartmentId = 10, Type = "1", Priority = 3, CheckInTimersEnabled = true }; + var configs = new List + { + new CheckInTimerConfig { TimerTargetType = 0, DurationMinutes = 30, WarningThresholdMinutes = 5, IsEnabled = true } + }; + var overrides = new List + { + new CheckInTimerOverride { TimerTargetType = 0, CallTypeId = 1, CallPriority = null, DurationMinutes = 20, WarningThresholdMinutes = 3, IsEnabled = true }, + new CheckInTimerOverride { TimerTargetType = 0, CallTypeId = 1, CallPriority = 3, DurationMinutes = 10, WarningThresholdMinutes = 2, IsEnabled = true } + }; + _configRepo.Setup(x => x.GetByDepartmentIdAsync(10)).ReturnsAsync(configs); + _overrideRepo.Setup(x => x.GetMatchingOverridesAsync(10, 1, 3)).ReturnsAsync(overrides); + + var result = await _service.ResolveAllTimersForCallAsync(call); + + result.Should().HaveCount(1); + result[0].DurationMinutes.Should().Be(10); + result[0].IsFromOverride.Should().BeTrue(); + } + + [Test] + public async Task ResolveAllTimersForCallAsync_TypeOnlyOverride_WinsOverPriorityOnly() + { + var call = new Call { CallId = 1, DepartmentId = 10, Type = "1", Priority = 3, CheckInTimersEnabled = true }; + var configs = new List(); + var overrides = new List + { + new CheckInTimerOverride { TimerTargetType = 0, CallTypeId = null, CallPriority = 3, DurationMinutes = 25, WarningThresholdMinutes = 5, IsEnabled = true }, + new CheckInTimerOverride { TimerTargetType = 0, CallTypeId = 1, CallPriority = null, DurationMinutes = 15, WarningThresholdMinutes = 3, IsEnabled = true } + }; + _configRepo.Setup(x => x.GetByDepartmentIdAsync(10)).ReturnsAsync(configs); + _overrideRepo.Setup(x => x.GetMatchingOverridesAsync(10, 1, 3)).ReturnsAsync(overrides); + + var result = await _service.ResolveAllTimersForCallAsync(call); + + result.Should().HaveCount(1); + result[0].DurationMinutes.Should().Be(15); + result[0].IsFromOverride.Should().BeTrue(); + } + + [Test] + public async Task ResolveAllTimersForCallAsync_ReturnsEmpty_WhenNoConfigForTargetType() + { + var call = new Call { CallId = 1, DepartmentId = 10, CheckInTimersEnabled = true }; + _configRepo.Setup(x => x.GetByDepartmentIdAsync(10)).ReturnsAsync(new List()); + _overrideRepo.Setup(x => x.GetMatchingOverridesAsync(10, null, 0)).ReturnsAsync(new List()); + + var result = await _service.ResolveAllTimersForCallAsync(call); + + result.Should().BeEmpty(); + } + + #endregion Timer Resolution + + #region Timer Status + + [Test] + public async Task GetActiveTimerStatusesForCallAsync_Green_WhenElapsedLessThanDuration() + { + var call = new Call { CallId = 1, DepartmentId = 10, State = (int)CallStates.Active, CheckInTimersEnabled = true, LoggedOn = DateTime.UtcNow.AddMinutes(-5) }; + 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.GetActiveTimerStatusesForCallAsync(call); + + result.Should().HaveCount(1); + result[0].Status.Should().Be("Green"); + } + + [Test] + public async Task GetActiveTimerStatusesForCallAsync_Warning_WhenElapsedBetweenDurationAndThreshold() + { + var call = new Call { CallId = 1, DepartmentId = 10, State = (int)CallStates.Active, CheckInTimersEnabled = true, LoggedOn = DateTime.UtcNow.AddMinutes(-32) }; + 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.GetActiveTimerStatusesForCallAsync(call); + + result.Should().HaveCount(1); + result[0].Status.Should().Be("Warning"); + } + + [Test] + public async Task GetActiveTimerStatusesForCallAsync_Critical_WhenElapsedExceedsThreshold() + { + var call = new Call { CallId = 1, DepartmentId = 10, State = (int)CallStates.Active, CheckInTimersEnabled = true, LoggedOn = DateTime.UtcNow.AddMinutes(-40) }; + 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.GetActiveTimerStatusesForCallAsync(call); + + result.Should().HaveCount(1); + result[0].Status.Should().Be("Critical"); + } + + [Test] + public async Task GetActiveTimerStatusesForCallAsync_EmptyList_ForClosedCalls() + { + var call = new Call { CallId = 1, DepartmentId = 10, State = (int)CallStates.Closed, CheckInTimersEnabled = true }; + + var result = await _service.GetActiveTimerStatusesForCallAsync(call); + + result.Should().BeEmpty(); + } + + [Test] + public async Task GetActiveTimerStatusesForCallAsync_EmptyList_WhenTimersNotEnabled() + { + var call = new Call { CallId = 1, DepartmentId = 10, State = (int)CallStates.Active, CheckInTimersEnabled = false }; + + var result = await _service.GetActiveTimerStatusesForCallAsync(call); + + result.Should().BeEmpty(); + } + + #endregion Timer Status + + #region Check-in Operations + + [Test] + public async Task PerformCheckInAsync_SavesRecordWithTimestamp() + { + var record = new CheckInRecord { DepartmentId = 10, CallId = 1, CheckInType = 0, UserId = "user1", Latitude = "40.7", Longitude = "-74.0" }; + _recordRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((CheckInRecord r, CancellationToken ct, bool b) => { r.CheckInRecordId = "new-id"; return r; }); + + var result = await _service.PerformCheckInAsync(record); + + result.Should().NotBeNull(); + result.Timestamp.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + result.Latitude.Should().Be("40.7"); + result.Longitude.Should().Be("-74.0"); + _recordRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task GetLastCheckInAsync_ReturnsUserCheckIn_WhenNoUnitId() + { + var checkIn = new CheckInRecord { CheckInRecordId = "ci1", UserId = "user1", CallId = 1 }; + _recordRepo.Setup(x => x.GetLastCheckInForUserOnCallAsync(1, "user1")).ReturnsAsync(checkIn); + + var result = await _service.GetLastCheckInAsync(1, "user1", null); + + result.Should().NotBeNull(); + result.CheckInRecordId.Should().Be("ci1"); + } + + [Test] + public async Task GetLastCheckInAsync_ReturnsUnitCheckIn_WhenUnitIdProvided() + { + var checkIn = new CheckInRecord { CheckInRecordId = "ci2", UnitId = 5, CallId = 1 }; + _recordRepo.Setup(x => x.GetLastCheckInForUnitOnCallAsync(1, 5)).ReturnsAsync(checkIn); + + var result = await _service.GetLastCheckInAsync(1, "user1", 5); + + result.Should().NotBeNull(); + result.CheckInRecordId.Should().Be("ci2"); + result.UnitId.Should().Be(5); + } + + #endregion Check-in Operations + + #region CRUD + + [Test] + public async Task SaveTimerConfigAsync_SetsCreatedOn_ForNewConfig() + { + var config = new CheckInTimerConfig { DepartmentId = 10, TimerTargetType = 0, DurationMinutes = 30, WarningThresholdMinutes = 5 }; + _configRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((CheckInTimerConfig c, CancellationToken ct, bool b) => c); + + var result = await _service.SaveTimerConfigAsync(config); + + result.CreatedOn.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + } + + [Test] + public async Task SaveTimerConfigAsync_SetsUpdatedOn_ForExistingConfig() + { + var config = new CheckInTimerConfig { CheckInTimerConfigId = "existing-id", DepartmentId = 10, TimerTargetType = 0, DurationMinutes = 30, WarningThresholdMinutes = 5 }; + _configRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((CheckInTimerConfig c, CancellationToken ct, bool b) => c); + + var result = await _service.SaveTimerConfigAsync(config); + + result.UpdatedOn.Should().NotBeNull(); + result.UpdatedOn.Value.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + } + + [Test] + public async Task DeleteTimerConfigAsync_ReturnsFalse_WhenConfigNotFound() + { + _configRepo.Setup(x => x.GetByIdAsync("non-existent")).ReturnsAsync((CheckInTimerConfig)null); + + var result = await _service.DeleteTimerConfigAsync("non-existent"); + + result.Should().BeFalse(); + } + + #endregion CRUD + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs index 790c63f0..9fbda5d7 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs @@ -609,6 +609,14 @@ public async Task> SaveCall([FromBody] NewCallInput call.LoggedOn = DateTime.UtcNow; + if (newCallInput.CheckInTimersEnabled.HasValue) + call.CheckInTimersEnabled = newCallInput.CheckInTimersEnabled.Value; + else + { + var autoEnable = await _departmentSettingsService.GetCheckInTimersAutoEnableForNewCallsAsync(DepartmentId); + call.CheckInTimersEnabled = autoEnable; + } + if (!String.IsNullOrWhiteSpace(newCallInput.Type) && newCallInput.Type != "No Type") { var callTypes = await _callsService.GetCallTypesForDepartmentAsync(DepartmentId); @@ -1670,6 +1678,8 @@ public static CallResultData ConvertCall(Call call, List proto callResult.IncidentId = call.IncidentNumber; callResult.Type = call.Type; + callResult.CheckInTimersEnabled = call.CheckInTimersEnabled; + callResult.Protocols = new List(); if (protocol != null && protocol.Any()) { diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CheckInTimersController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CheckInTimersController.cs new file mode 100644 index 00000000..516b9bcd --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/CheckInTimersController.cs @@ -0,0 +1,420 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4.CheckInTimers; +using System.Linq; +using System.Net.Mime; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Check-in timer operations for call accountability + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + public class CheckInTimersController : V4AuthenticatedApiControllerbase + { + private readonly ICheckInTimerService _checkInTimerService; + private readonly ICallsService _callsService; + private readonly IDepartmentSettingsService _departmentSettingsService; + + public CheckInTimersController( + ICheckInTimerService checkInTimerService, + ICallsService callsService, + IDepartmentSettingsService departmentSettingsService) + { + _checkInTimerService = checkInTimerService; + _callsService = callsService; + _departmentSettingsService = departmentSettingsService; + } + + #region Timer Configuration + + /// + /// Gets all timer configurations for the department + /// + [HttpGet("GetTimerConfigs")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Department_View)] + public async Task> GetTimerConfigs() + { + var result = new CheckInTimerConfigResult(); + var configs = await _checkInTimerService.GetTimerConfigsForDepartmentAsync(DepartmentId); + + result.Data = configs.Select(c => new CheckInTimerConfigResultData + { + CheckInTimerConfigId = c.CheckInTimerConfigId, + DepartmentId = c.DepartmentId, + TimerTargetType = c.TimerTargetType, + TimerTargetTypeName = ((CheckInTimerTargetType)c.TimerTargetType).ToString(), + UnitTypeId = c.UnitTypeId, + DurationMinutes = c.DurationMinutes, + WarningThresholdMinutes = c.WarningThresholdMinutes, + IsEnabled = c.IsEnabled, + CreatedOn = c.CreatedOn, + UpdatedOn = c.UpdatedOn + }).ToList(); + + result.PageSize = result.Data.Count; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); + } + + /// + /// Creates or updates a timer configuration + /// + [HttpPost("SaveTimerConfig")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [Authorize(Policy = ResgridResources.Department_Update)] + public async Task> SaveTimerConfig([FromBody] CheckInTimerConfigInput input, CancellationToken cancellationToken) + { + var result = new SaveCheckInTimerConfigResult(); + + var config = new CheckInTimerConfig + { + CheckInTimerConfigId = input.CheckInTimerConfigId, + DepartmentId = DepartmentId, + TimerTargetType = input.TimerTargetType, + UnitTypeId = input.UnitTypeId, + DurationMinutes = input.DurationMinutes, + WarningThresholdMinutes = input.WarningThresholdMinutes, + IsEnabled = input.IsEnabled, + CreatedByUserId = UserId + }; + + var saved = await _checkInTimerService.SaveTimerConfigAsync(config, cancellationToken); + + result.Id = saved.CheckInTimerConfigId; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); + } + + /// + /// Deletes a timer configuration + /// + [HttpDelete("DeleteTimerConfig")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Department_Update)] + public async Task> DeleteTimerConfig(string configId, CancellationToken cancellationToken) + { + var result = new SaveCheckInTimerConfigResult(); + + var deleted = await _checkInTimerService.DeleteTimerConfigAsync(configId, cancellationToken); + if (!deleted) + return NotFound(); + + result.Id = configId; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); + } + + #endregion Timer Configuration + + #region Timer Overrides + + /// + /// Gets all timer overrides for the department + /// + [HttpGet("GetTimerOverrides")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Department_View)] + public async Task> GetTimerOverrides() + { + var result = new CheckInTimerOverrideResult(); + var overrides = await _checkInTimerService.GetTimerOverridesForDepartmentAsync(DepartmentId); + + result.Data = overrides.Select(o => new CheckInTimerOverrideResultData + { + CheckInTimerOverrideId = o.CheckInTimerOverrideId, + DepartmentId = o.DepartmentId, + CallTypeId = o.CallTypeId, + CallPriority = o.CallPriority, + TimerTargetType = o.TimerTargetType, + TimerTargetTypeName = ((CheckInTimerTargetType)o.TimerTargetType).ToString(), + UnitTypeId = o.UnitTypeId, + DurationMinutes = o.DurationMinutes, + WarningThresholdMinutes = o.WarningThresholdMinutes, + IsEnabled = o.IsEnabled, + CreatedOn = o.CreatedOn, + UpdatedOn = o.UpdatedOn + }).ToList(); + + result.PageSize = result.Data.Count; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); + } + + /// + /// Creates or updates a timer override + /// + [HttpPost("SaveTimerOverride")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [Authorize(Policy = ResgridResources.Department_Update)] + public async Task> SaveTimerOverride([FromBody] CheckInTimerOverrideInput input, CancellationToken cancellationToken) + { + var result = new SaveCheckInTimerOverrideResult(); + + var ovr = new CheckInTimerOverride + { + CheckInTimerOverrideId = input.CheckInTimerOverrideId, + DepartmentId = DepartmentId, + CallTypeId = input.CallTypeId, + CallPriority = input.CallPriority, + TimerTargetType = input.TimerTargetType, + UnitTypeId = input.UnitTypeId, + DurationMinutes = input.DurationMinutes, + WarningThresholdMinutes = input.WarningThresholdMinutes, + IsEnabled = input.IsEnabled, + CreatedByUserId = UserId + }; + + var saved = await _checkInTimerService.SaveTimerOverrideAsync(ovr, cancellationToken); + + result.Id = saved.CheckInTimerOverrideId; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); + } + + /// + /// Deletes a timer override + /// + [HttpDelete("DeleteTimerOverride")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Department_Update)] + public async Task> DeleteTimerOverride(string overrideId, CancellationToken cancellationToken) + { + var result = new SaveCheckInTimerOverrideResult(); + + var deleted = await _checkInTimerService.DeleteTimerOverrideAsync(overrideId, cancellationToken); + if (!deleted) + return NotFound(); + + result.Id = overrideId; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); + } + + #endregion Timer Overrides + + #region Timer Status & Resolution + + /// + /// Gets the resolved timers for a specific call + /// + [HttpGet("GetTimersForCall")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Call_View)] + public async Task> GetTimersForCall(int callId) + { + var result = new ResolvedCheckInTimerResult(); + + var call = await _callsService.GetCallByIdAsync(callId); + if (call == null || call.DepartmentId != DepartmentId) + return NotFound(); + + var timers = await _checkInTimerService.ResolveAllTimersForCallAsync(call); + + result.Data = timers.Select(t => new ResolvedCheckInTimerResultData + { + TargetType = t.TargetType, + TargetTypeName = ((CheckInTimerTargetType)t.TargetType).ToString(), + UnitTypeId = t.UnitTypeId, + TargetEntityId = t.TargetEntityId, + TargetName = t.TargetName ?? ((CheckInTimerTargetType)t.TargetType).ToString(), + DurationMinutes = t.DurationMinutes, + WarningThresholdMinutes = t.WarningThresholdMinutes, + IsFromOverride = t.IsFromOverride + }).ToList(); + + result.PageSize = result.Data.Count; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); + } + + /// + /// Gets real-time timer statuses with color coding for a call + /// + [HttpGet("GetTimerStatuses")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Call_View)] + public async Task> GetTimerStatuses(int callId) + { + var result = new CheckInTimerStatusResult(); + + var call = await _callsService.GetCallByIdAsync(callId); + if (call == null || call.DepartmentId != DepartmentId) + return NotFound(); + + var statuses = await _checkInTimerService.GetActiveTimerStatusesForCallAsync(call); + + result.Data = statuses.Select(s => new CheckInTimerStatusResultData + { + TargetType = s.TargetType, + TargetTypeName = ((CheckInTimerTargetType)s.TargetType).ToString(), + TargetEntityId = s.TargetEntityId, + TargetName = s.TargetName, + UnitId = s.UnitId, + LastCheckIn = s.LastCheckIn, + DurationMinutes = s.DurationMinutes, + WarningThresholdMinutes = s.WarningThresholdMinutes, + ElapsedMinutes = s.ElapsedMinutes, + Status = s.Status + }).ToList(); + + result.PageSize = result.Data.Count; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); + } + + #endregion Timer Status & Resolution + + #region Check-in Operations + + /// + /// Records a check-in for a call with optional geolocation + /// + [HttpPost("PerformCheckIn")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Call_Update)] + public async Task> PerformCheckIn([FromBody] PerformCheckInInput input, CancellationToken cancellationToken) + { + var result = new PerformCheckInResult(); + + var call = await _callsService.GetCallByIdAsync(input.CallId); + if (call == null || call.DepartmentId != DepartmentId) + return NotFound(); + + if (!call.CheckInTimersEnabled || call.State != (int)CallStates.Active) + return BadRequest("Check-in timers are not enabled or call is not active."); + + var record = new CheckInRecord + { + DepartmentId = DepartmentId, + CallId = input.CallId, + CheckInType = input.CheckInType, + UserId = UserId, + UnitId = input.UnitId, + Latitude = input.Latitude, + Longitude = input.Longitude, + Note = input.Note + }; + + var saved = await _checkInTimerService.PerformCheckInAsync(record, cancellationToken); + + result.Id = saved.CheckInRecordId; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); + } + + /// + /// Gets all check-in records for a call + /// + [HttpGet("GetCheckInHistory")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Call_View)] + public async Task> GetCheckInHistory(int callId) + { + var result = new CheckInRecordResult(); + + var call = await _callsService.GetCallByIdAsync(callId); + if (call == null || call.DepartmentId != DepartmentId) + return NotFound(); + + var records = await _checkInTimerService.GetCheckInsForCallAsync(callId); + + result.Data = records.Select(r => new CheckInRecordResultData + { + CheckInRecordId = r.CheckInRecordId, + CallId = r.CallId, + CheckInType = r.CheckInType, + CheckInTypeName = ((CheckInTimerTargetType)r.CheckInType).ToString(), + UserId = r.UserId, + UnitId = r.UnitId, + Latitude = r.Latitude, + Longitude = r.Longitude, + Timestamp = r.Timestamp, + Note = r.Note + }).ToList(); + + result.PageSize = result.Data.Count; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); + } + + #endregion Check-in Operations + + #region Toggle Timers + + /// + /// Enables or disables check-in timers on a call + /// + [HttpPut("ToggleCallTimers")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Call_Update)] + public async Task> ToggleCallTimers(int callId, bool enabled, CancellationToken cancellationToken) + { + var result = new ToggleCallTimersResult(); + + var call = await _callsService.GetCallByIdAsync(callId); + if (call == null || call.DepartmentId != DepartmentId) + return NotFound(); + + call.CheckInTimersEnabled = enabled; + await _callsService.SaveCallAsync(call, cancellationToken); + + result.Id = callId.ToString(); + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); + } + + #endregion Toggle Timers + } +} diff --git a/Web/Resgrid.Web.Services/Hubs/EventingHub.cs b/Web/Resgrid.Web.Services/Hubs/EventingHub.cs index 91837c8f..c8c3deb3 100644 --- a/Web/Resgrid.Web.Services/Hubs/EventingHub.cs +++ b/Web/Resgrid.Web.Services/Hubs/EventingHub.cs @@ -97,5 +97,21 @@ public async Task DepartmentUpdated(int departmentId) if (group != null) await group.SendAsync("departmentUpdated"); } + + public async Task CheckInPerformed(int departmentId, int callId, string checkInRecordId) + { + var group = Clients.Group(departmentId.ToString()); + + if (group != null) + await group.SendAsync("checkInPerformed", callId, checkInRecordId); + } + + public async Task CheckInTimersUpdated(int departmentId, int callId) + { + var group = Clients.Group(departmentId.ToString()); + + if (group != null) + await group.SendAsync("checkInTimersUpdated", callId); + } } } diff --git a/Web/Resgrid.Web.Services/Models/v4/Calls/CallResult.cs b/Web/Resgrid.Web.Services/Models/v4/Calls/CallResult.cs index 46d7b61c..5386c1de 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Calls/CallResult.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Calls/CallResult.cs @@ -167,5 +167,10 @@ public class CallResultData /// User Defined Field values for this call /// public List UdfValues { get; set; } + + /// + /// Whether check-in timers are enabled for this call + /// + public bool CheckInTimersEnabled { get; set; } } } diff --git a/Web/Resgrid.Web.Services/Models/v4/Calls/NewCallInput.cs b/Web/Resgrid.Web.Services/Models/v4/Calls/NewCallInput.cs index 6185d6a1..6655142e 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Calls/NewCallInput.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Calls/NewCallInput.cs @@ -112,5 +112,10 @@ public class NewCallInput /// Indoor Map Floor Id for the call location /// public string IndoorMapFloorId { get; set; } + + /// + /// Enable check-in timers for this call. Leave null to use department default. + /// + public bool? CheckInTimersEnabled { get; set; } } } diff --git a/Web/Resgrid.Web.Services/Models/v4/CheckInTimers/CheckInTimerModels.cs b/Web/Resgrid.Web.Services/Models/v4/CheckInTimers/CheckInTimerModels.cs new file mode 100644 index 00000000..07ca2b15 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/CheckInTimers/CheckInTimerModels.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Resgrid.Web.Services.Models.v4.CheckInTimers +{ + // ── Config ────────────────────────────────────────────────── + + public class CheckInTimerConfigResult : StandardApiResponseV4Base + { + public List Data { get; set; } + } + + public class CheckInTimerConfigResultData + { + public string CheckInTimerConfigId { get; set; } + public int DepartmentId { get; set; } + public int TimerTargetType { get; set; } + public string TimerTargetTypeName { get; set; } + public int? UnitTypeId { get; set; } + public int DurationMinutes { get; set; } + public int WarningThresholdMinutes { get; set; } + public bool IsEnabled { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime? UpdatedOn { get; set; } + } + + public class CheckInTimerConfigInput + { + public string CheckInTimerConfigId { get; set; } + + [Required] + public int TimerTargetType { get; set; } + + public int? UnitTypeId { get; set; } + + [Required] + public int DurationMinutes { get; set; } + + [Required] + public int WarningThresholdMinutes { get; set; } + + public bool IsEnabled { get; set; } = true; + } + + public class SaveCheckInTimerConfigResult : StandardApiResponseV4Base + { + public string Id { get; set; } + } + + // ── Override ──────────────────────────────────────────────── + + public class CheckInTimerOverrideResult : StandardApiResponseV4Base + { + public List Data { get; set; } + } + + public class CheckInTimerOverrideResultData + { + public string CheckInTimerOverrideId { get; set; } + public int DepartmentId { get; set; } + public int? CallTypeId { get; set; } + public int? CallPriority { get; set; } + public int TimerTargetType { get; set; } + public string TimerTargetTypeName { get; set; } + public int? UnitTypeId { get; set; } + public int DurationMinutes { get; set; } + public int WarningThresholdMinutes { get; set; } + public bool IsEnabled { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime? UpdatedOn { get; set; } + } + + public class CheckInTimerOverrideInput + { + public string CheckInTimerOverrideId { get; set; } + public int? CallTypeId { get; set; } + public int? CallPriority { get; set; } + + [Required] + public int TimerTargetType { get; set; } + + public int? UnitTypeId { get; set; } + + [Required] + public int DurationMinutes { get; set; } + + [Required] + public int WarningThresholdMinutes { get; set; } + + public bool IsEnabled { get; set; } = true; + } + + public class SaveCheckInTimerOverrideResult : StandardApiResponseV4Base + { + public string Id { get; set; } + } + + // ── Timer Status ──────────────────────────────────────────── + + public class CheckInTimerStatusResult : StandardApiResponseV4Base + { + public List Data { get; set; } + } + + public class CheckInTimerStatusResultData + { + public int TargetType { get; set; } + public string TargetTypeName { get; set; } + public string TargetEntityId { get; set; } + public string TargetName { get; set; } + public int? UnitId { get; set; } + public DateTime? LastCheckIn { get; set; } + public int DurationMinutes { get; set; } + public int WarningThresholdMinutes { get; set; } + public double ElapsedMinutes { get; set; } + public string Status { get; set; } + } + + // ── Check-in ──────────────────────────────────────────────── + + public class PerformCheckInInput + { + [Required] + public int CallId { get; set; } + + [Required] + public int CheckInType { get; set; } + + public string Latitude { get; set; } + public string Longitude { get; set; } + public int? UnitId { get; set; } + public string Note { get; set; } + } + + public class PerformCheckInResult : StandardApiResponseV4Base + { + public string Id { get; set; } + } + + public class CheckInRecordResult : StandardApiResponseV4Base + { + public List Data { get; set; } + } + + public class CheckInRecordResultData + { + public string CheckInRecordId { get; set; } + public int CallId { get; set; } + public int CheckInType { get; set; } + public string CheckInTypeName { get; set; } + public string UserId { get; set; } + public int? UnitId { get; set; } + public string Latitude { get; set; } + public string Longitude { get; set; } + public DateTime Timestamp { get; set; } + public string Note { get; set; } + } + + // ── Resolved Timers ───────────────────────────────────────── + + public class ResolvedCheckInTimerResult : StandardApiResponseV4Base + { + public List Data { get; set; } + } + + public class ResolvedCheckInTimerResultData + { + public int TargetType { get; set; } + public string TargetTypeName { get; set; } + public int? UnitTypeId { get; set; } + public string TargetEntityId { get; set; } + public string TargetName { get; set; } + public int DurationMinutes { get; set; } + public int WarningThresholdMinutes { get; set; } + public bool IsFromOverride { get; set; } + } + + // ── Toggle ────────────────────────────────────────────────── + + public class ToggleCallTimersResult : StandardApiResponseV4Base + { + public string Id { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index d3787cbc..eb1a1422 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -303,6 +303,66 @@ + + + Check-in timer operations for call accountability + + + + + Gets all timer configurations for the department + + + + + Creates or updates a timer configuration + + + + + Deletes a timer configuration + + + + + Gets all timer overrides for the department + + + + + Creates or updates a timer override + + + + + Deletes a timer override + + + + + Gets the resolved timers for a specific call + + + + + Gets real-time timer statuses with color coding for a call + + + + + Records a check-in for a call with optional geolocation + + + + + Gets all check-in records for a call + + + + + Enables or disables check-in timers on a call + + Generic configuration api endpoints @@ -4728,6 +4788,11 @@ User Defined Field values for this call + + + Whether check-in timers are enabled for this call + + Input information to close a call @@ -4973,6 +5038,11 @@ Indoor Map Floor Id for the call location + + + Enable check-in timers for this call. Leave null to use department default. + + Gets the calls current scheduled but not yet dispatched diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs index 85672210..e4f596fc 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs @@ -61,6 +61,7 @@ public class DepartmentController : SecureBaseController private readonly IDocumentsService _documentsService; private readonly INotesService _notesService; private readonly IContactsService _contactsService; + private readonly ICheckInTimerService _checkInTimerService; public DepartmentController(IDepartmentsService departmentsService, IUsersService usersService, IActionLogsService actionLogsService, IEmailService emailService, IDepartmentGroupsService departmentGroupsService, IUserProfileService userProfileService, IDeleteService deleteService, @@ -68,7 +69,7 @@ public DepartmentController(IDepartmentsService departmentsService, IUsersServic ILimitsService limitsService, ICallsService callsService, IDepartmentSettingsService departmentSettingsService, IUnitsService unitsService, ICertificationService certificationService, INumbersService numbersService, IScheduledTasksService scheduledTasksService, IPersonnelRolesService personnelRolesService, IEventAggregator eventAggregator, ICustomStateService customStateService, ICqrsProvider cqrsProvider, IPrinterProvider printerProvider, IQueueService queueService, - IDocumentsService documentsService, INotesService notesService, IContactsService contactsService) + IDocumentsService documentsService, INotesService notesService, IContactsService contactsService, ICheckInTimerService checkInTimerService) { _departmentsService = departmentsService; _usersService = usersService; @@ -97,6 +98,7 @@ public DepartmentController(IDepartmentsService departmentsService, IUsersServic _documentsService = documentsService; _notesService = notesService; _contactsService = contactsService; + _checkInTimerService = checkInTimerService; } #endregion Private Members and Constructors @@ -1688,6 +1690,13 @@ public async Task DispatchSettings() model.UnitDispatchAlsoDispatchToGroup = await _departmentSettingsService.GetUnitDispatchAlsoDispatchToGroupAsync(DepartmentId); model.PersonnelOnUnitSetUnitStatus = await _departmentSettingsService.GetPersonnelOnUnitSetUnitStatusAsync(DepartmentId); + // Check-In Timer data + model.AutoEnableCheckInTimers = await _departmentSettingsService.GetCheckInTimersAutoEnableForNewCallsAsync(DepartmentId); + model.TimerConfigs = await _checkInTimerService.GetTimerConfigsForDepartmentAsync(DepartmentId); + model.TimerOverrides = await _checkInTimerService.GetTimerOverridesForDepartmentAsync(DepartmentId); + model.UnitTypes = (await _unitsService.GetUnitTypesForDepartmentAsync(DepartmentId))?.ToList() ?? new List(); + model.CallTypes = await _callsService.GetCallTypesForDepartmentAsync(DepartmentId) ?? new List(); + return View(model); } @@ -1734,16 +1743,116 @@ await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, model.Un await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, model.PersonnelOnUnitSetUnitStatus.ToString(), DepartmentSettingTypes.PersonnelOnUnitSetUnitStatus, cancellationToken); + // Save check-in timer auto-enable setting + await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, model.AutoEnableCheckInTimers.ToString(), + DepartmentSettingTypes.CheckInTimersAutoEnableForNewCalls, cancellationToken); + + // Reload timer data for re-display + model.TimerConfigs = await _checkInTimerService.GetTimerConfigsForDepartmentAsync(DepartmentId); + model.TimerOverrides = await _checkInTimerService.GetTimerOverridesForDepartmentAsync(DepartmentId); + model.UnitTypes = (await _unitsService.GetUnitTypesForDepartmentAsync(DepartmentId))?.ToList() ?? new List(); + model.CallTypes = await _callsService.GetCallTypesForDepartmentAsync(DepartmentId) ?? new List(); + model.SaveSuccess = true; return View(model); } + // Reload timer data even on validation failure + model.TimerConfigs = await _checkInTimerService.GetTimerConfigsForDepartmentAsync(DepartmentId); + model.TimerOverrides = await _checkInTimerService.GetTimerOverridesForDepartmentAsync(DepartmentId); + model.UnitTypes = (await _unitsService.GetUnitTypesForDepartmentAsync(DepartmentId))?.ToList() ?? new List(); + model.CallTypes = await _callsService.GetCallTypesForDepartmentAsync(DepartmentId) ?? new List(); + model.SaveSuccess = false; return View(model); } #endregion Dispatch Settings + #region Check-In Timer Settings + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Department_Update)] + public async Task SaveCheckInTimerConfig(string configId, int timerTargetType, int? unitTypeId, + int durationMinutes, int warningThresholdMinutes, bool isEnabled, CancellationToken cancellationToken) + { + if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId)) + return Unauthorized(); + + var config = new CheckInTimerConfig + { + CheckInTimerConfigId = string.IsNullOrWhiteSpace(configId) ? null : configId, + DepartmentId = DepartmentId, + TimerTargetType = timerTargetType, + UnitTypeId = unitTypeId, + DurationMinutes = durationMinutes, + WarningThresholdMinutes = warningThresholdMinutes, + IsEnabled = isEnabled, + CreatedByUserId = UserId + }; + + await _checkInTimerService.SaveTimerConfigAsync(config, cancellationToken); + + return RedirectToAction("DispatchSettings"); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Department_Update)] + public async Task DeleteCheckInTimerConfig(string configId, CancellationToken cancellationToken) + { + if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId)) + return Unauthorized(); + + await _checkInTimerService.DeleteTimerConfigAsync(configId, cancellationToken); + + return RedirectToAction("DispatchSettings"); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Department_Update)] + public async Task SaveCheckInTimerOverride(string overrideId, int? callTypeId, int? callPriority, + int timerTargetType, int? unitTypeId, int durationMinutes, int warningThresholdMinutes, bool isEnabled, CancellationToken cancellationToken) + { + if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId)) + return Unauthorized(); + + var ovr = new CheckInTimerOverride + { + CheckInTimerOverrideId = string.IsNullOrWhiteSpace(overrideId) ? null : overrideId, + DepartmentId = DepartmentId, + CallTypeId = callTypeId, + CallPriority = callPriority, + TimerTargetType = timerTargetType, + UnitTypeId = unitTypeId, + DurationMinutes = durationMinutes, + WarningThresholdMinutes = warningThresholdMinutes, + IsEnabled = isEnabled, + CreatedByUserId = UserId + }; + + await _checkInTimerService.SaveTimerOverrideAsync(ovr, cancellationToken); + + return RedirectToAction("DispatchSettings"); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Department_Update)] + public async Task DeleteCheckInTimerOverride(string overrideId, CancellationToken cancellationToken) + { + if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId)) + return Unauthorized(); + + await _checkInTimerService.DeleteTimerOverrideAsync(overrideId, cancellationToken); + + return RedirectToAction("DispatchSettings"); + } + + #endregion Check-In Timer Settings + #region Mapping Settings [HttpGet] diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs index 516de53b..416e6880 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs @@ -65,6 +65,7 @@ public class DispatchController : SecureBaseController private readonly IContactsService _contactsService; private readonly IUserDefinedFieldsService _userDefinedFieldsService; private readonly IUdfRenderingService _udfRenderingService; + private readonly ICheckInTimerService _checkInTimerService; public DispatchController(IDepartmentsService departmentsService, IUsersService usersService, ICallsService callsService, IDepartmentGroupsService departmentGroupsService, ICommunicationService communicationService, IQueueService queueService, @@ -73,7 +74,8 @@ public DispatchController(IDepartmentsService departmentsService, IUsersService IUnitsService unitsService, IActionLogsService actionLogsService, IEventAggregator eventAggregator, ICustomStateService customStateService, ITemplatesService templatesService, IPdfProvider pdfProvider, IProtocolsService protocolsService, IFormsService formsService, IShiftsService shiftsService, IContactsService contactsService, - IUserDefinedFieldsService userDefinedFieldsService, IUdfRenderingService udfRenderingService) + IUserDefinedFieldsService userDefinedFieldsService, IUdfRenderingService udfRenderingService, + ICheckInTimerService checkInTimerService) { _departmentsService = departmentsService; _usersService = usersService; @@ -99,6 +101,7 @@ public DispatchController(IDepartmentsService departmentsService, IUsersService _contactsService = contactsService; _userDefinedFieldsService = userDefinedFieldsService; _udfRenderingService = udfRenderingService; + _checkInTimerService = checkInTimerService; } #endregion Private Members and Constructors @@ -227,6 +230,10 @@ public async Task NewCall(NewCallView model, IFormCollection coll if (!String.IsNullOrEmpty(model.Latitude) && !String.IsNullOrEmpty(model.Longitude)) model.Call.GeoLocationData = string.Format("{0},{1}", model.Latitude, model.Longitude); + // Check-in timers + var checkInTimersValue = collection["Call.CheckInTimersEnabled"].FirstOrDefault(); + model.Call.CheckInTimersEnabled = !string.IsNullOrEmpty(checkInTimersValue) && checkInTimersValue.Contains("true", StringComparison.OrdinalIgnoreCase); + // Indoor map zone var indoorMapZoneId = collection["IndoorMapZoneId"].FirstOrDefault(); var indoorMapFloorId = collection["IndoorMapFloorId"].FirstOrDefault(); @@ -617,6 +624,9 @@ public async Task UpdateCall(UpdateCallView model, IFormCollectio if (!String.IsNullOrEmpty(model.Latitude) && !String.IsNullOrEmpty(model.Longitude)) call.GeoLocationData = string.Format("{0},{1}", model.Latitude, model.Longitude); + var checkInTimersValue = collection["Call.CheckInTimersEnabled"].FirstOrDefault(); + call.CheckInTimersEnabled = !string.IsNullOrEmpty(checkInTimersValue) && checkInTimersValue.Contains("true", StringComparison.OrdinalIgnoreCase); + // Indoor map zone var indoorMapZoneId = collection["IndoorMapZoneId"].FirstOrDefault(); var indoorMapFloorId = collection["IndoorMapFloorId"].FirstOrDefault(); @@ -1636,6 +1646,137 @@ public async Task GetCallNotes(int callId) return Json(callNotes); } + [HttpGet] + public async Task GetCheckInTimerStatuses(int callId) + { + var call = await _callsService.GetCallByIdAsync(callId); + if (call == null || call.DepartmentId != DepartmentId) + return NotFound(); + + var statuses = await _checkInTimerService.GetActiveTimerStatusesForCallAsync(call); + + // Resolve target names from dispatched entities + var personnelNames = await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(DepartmentId); + var units = await _unitsService.GetUnitsForDepartmentAsync(DepartmentId); + call = await _callsService.PopulateCallData(call, true, false, false, false, true, false, false, false, false); + + var result = statuses.Select(s => + { + string targetName = ((CheckInTimerTargetType)s.TargetType).ToString(); + + if (s.TargetType == (int)CheckInTimerTargetType.Personnel) + { + // List dispatched personnel names + if (call.Dispatches != null && call.Dispatches.Any()) + { + var names = call.Dispatches + .Select(d => personnelNames.FirstOrDefault(p => p.UserId == d.UserId)) + .Where(n => n != null) + .Select(n => n.Name) + .ToList(); + if (names.Any()) + targetName = string.Join(", ", names); + } + } + else if (s.TargetType == (int)CheckInTimerTargetType.UnitType) + { + // List dispatched unit names matching the unit type + if (call.UnitDispatches != null && call.UnitDispatches.Any() && units != null) + { + var unitNames = call.UnitDispatches + .Select(d => units.FirstOrDefault(u => u.UnitId == d.UnitId)) + .Where(u => u != null) + .Select(u => u.Name) + .ToList(); + if (unitNames.Any()) + targetName = string.Join(", ", unitNames); + } + } + + return new + { + s.TargetType, + TargetTypeName = ((CheckInTimerTargetType)s.TargetType).ToString(), + TargetName = targetName, + s.UnitId, + s.LastCheckIn, + s.DurationMinutes, + s.WarningThresholdMinutes, + s.ElapsedMinutes, + s.Status + }; + }).ToList(); + + return Json(result); + } + + [HttpGet] + public async Task GetCheckInHistory(int callId) + { + var call = await _callsService.GetCallByIdAsync(callId); + if (call == null || call.DepartmentId != DepartmentId) + return NotFound(); + + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + var personnelNames = await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(DepartmentId); + var units = await _unitsService.GetUnitsForDepartmentAsync(DepartmentId); + var records = await _checkInTimerService.GetCheckInsForCallAsync(callId); + + var result = records.Select(r => + { + var personName = personnelNames.FirstOrDefault(p => p.UserId == r.UserId); + string unitName = null; + if (r.UnitId.HasValue && units != null) + { + var unit = units.FirstOrDefault(u => u.UnitId == r.UnitId.Value); + unitName = unit?.Name; + } + + return new + { + r.CheckInRecordId, + r.CallId, + r.CheckInType, + CheckInTypeName = ((CheckInTimerTargetType)r.CheckInType).ToString(), + PerformedBy = personName?.Name ?? r.UserId, + UnitName = unitName, + Timestamp = r.Timestamp.TimeConverterToString(department), + r.Note + }; + }).ToList(); + + return Json(result); + } + + [HttpPost] + public async Task PerformCheckIn([FromBody] PerformCheckInInput input, CancellationToken cancellationToken) + { + if (input == null || input.CallId <= 0) + return BadRequest(); + + var call = await _callsService.GetCallByIdAsync(input.CallId); + if (call == null || call.DepartmentId != DepartmentId) + return NotFound(); + + if (!call.CheckInTimersEnabled || call.State != (int)CallStates.Active) + return BadRequest(); + + var record = new CheckInRecord + { + DepartmentId = DepartmentId, + CallId = input.CallId, + CheckInType = input.CheckInType, + UserId = UserId, + UnitId = input.UnitId, + Latitude = input.Latitude, + Longitude = input.Longitude, + Note = input.Note + }; + + var saved = await _checkInTimerService.PerformCheckInAsync(record, cancellationToken); + return Json(new { Id = saved.CheckInRecordId }); + } + [HttpGet] [Authorize(Policy = ResgridResources.Call_View)] public async Task CallExport(int callId) @@ -1655,6 +1796,8 @@ public async Task CallExport(int callId) model.Names = await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(DepartmentId); model.ChildCalls = await _callsService.GetChildCallsForCallAsync(callId); model.Contacts = await _contactsService.GetAllContactsForDepartmentAsync(DepartmentId); + model.CheckInRecords = await _checkInTimerService.GetCheckInsForCallAsync(callId); + model.TimerConfigs = await _checkInTimerService.ResolveAllTimersForCallAsync(model.Call); return View(model); } @@ -1705,6 +1848,8 @@ public async Task CallExportEx(string query) model.Names = await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(call.DepartmentId); model.ChildCalls = await _callsService.GetChildCallsForCallAsync(call.CallId); model.Contacts = await _contactsService.GetAllContactsForDepartmentAsync(DepartmentId); + model.CheckInRecords = await _checkInTimerService.GetCheckInsForCallAsync(call.CallId); + model.TimerConfigs = await _checkInTimerService.ResolveAllTimersForCallAsync(model.Call); return View(model); } @@ -1734,6 +1879,8 @@ public async Task CallExportEx(string query) model.Names = await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(call.DepartmentId); model.ChildCalls = await _callsService.GetChildCallsForCallAsync(call.CallId); model.Contacts = await _contactsService.GetAllContactsForDepartmentAsync(DepartmentId); + model.CheckInRecords = await _checkInTimerService.GetCheckInsForCallAsync(call.CallId); + model.TimerConfigs = await _checkInTimerService.ResolveAllTimersForCallAsync(model.Call); if (!String.IsNullOrWhiteSpace(items[2]) && items[2] != "0") { diff --git a/Web/Resgrid.Web/Areas/User/Models/Departments/DispatchSettingsView.cs b/Web/Resgrid.Web/Areas/User/Models/Departments/DispatchSettingsView.cs index de3ce59d..59f74e66 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Departments/DispatchSettingsView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Departments/DispatchSettingsView.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Mvc.Rendering; +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.Rendering; using Resgrid.Model; namespace Resgrid.Web.Areas.User.Models.Departments @@ -17,6 +18,13 @@ public class DispatchSettingsView public bool PersonnelOnUnitSetUnitStatus { get; set; } + // Check-In Timer Settings + public bool AutoEnableCheckInTimers { get; set; } + public List TimerConfigs { get; set; } + public List TimerOverrides { get; set; } + public List UnitTypes { get; set; } + public List CallTypes { get; set; } + public bool? SaveSuccess { get; set; } public string Message { get; set; } @@ -24,6 +32,10 @@ public DispatchSettingsView() { ShiftDispatchStatus = -1; ShiftClearStatus = -1; + TimerConfigs = new List(); + TimerOverrides = new List(); + UnitTypes = new List(); + CallTypes = new List(); } } } diff --git a/Web/Resgrid.Web/Areas/User/Models/Dispatch/CallExportView.cs b/Web/Resgrid.Web/Areas/User/Models/Dispatch/CallExportView.cs index 97b9780b..ed1c9dc1 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Dispatch/CallExportView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Dispatch/CallExportView.cs @@ -23,5 +23,7 @@ public class CallExportView : BaseUserModel public List Names { get; set; } public List ChildCalls { get; set; } public List Contacts { get; set; } + public List CheckInRecords { get; set; } + public List TimerConfigs { get; set; } } } diff --git a/Web/Resgrid.Web/Areas/User/Models/Dispatch/PerformCheckInInput.cs b/Web/Resgrid.Web/Areas/User/Models/Dispatch/PerformCheckInInput.cs new file mode 100644 index 00000000..f96d58d5 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/Dispatch/PerformCheckInInput.cs @@ -0,0 +1,12 @@ +namespace Resgrid.Web.Areas.User.Models.Dispatch +{ + public class PerformCheckInInput + { + public int CallId { get; set; } + public int CheckInType { get; set; } + public string Latitude { get; set; } + public string Longitude { get; set; } + public int? UnitId { get; set; } + public string Note { get; set; } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Department/DispatchSettings.cshtml b/Web/Resgrid.Web/Areas/User/Views/Department/DispatchSettings.cshtml index bf45f6e0..be198a39 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Department/DispatchSettings.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Department/DispatchSettings.cshtml @@ -1,12 +1,12 @@ -@model Resgrid.Web.Areas.User.Models.Departments.DispatchSettingsView +@model Resgrid.Web.Areas.User.Models.Departments.DispatchSettingsView @inject IStringLocalizer localizer @{ - ViewBag.Title = "Resgrid | " + localizer["DispatchSettingsHeader"]; + ViewBag.Title = "Resgrid | " + localizer["CallDispatchSettingsHeader"]; }
-

@localizer["DispatchSettingsHeader"]

+

@localizer["CallDispatchSettingsHeader"]

-
-
-
-
-
-
-
- @Html.AntiForgeryToken() -
- - @if (Model.SaveSuccess.GetValueOrDefault()) - { -
- @localizer["SavedDispatchSettings"] -
- } - -

@localizer["GroupDispatchSettingsHeader"]

-
- -
-
-
- -
+
+
+
+ + @* ── Dispatch Settings Form ──────────────────── *@ +
+
+ + @Html.AntiForgeryToken() +
+ + @if (Model.SaveSuccess.GetValueOrDefault()) + { +
+ @localizer["SavedCallDispatchSettings"] +
+ } + +

@localizer["GroupDispatchSettingsHeader"]

+
+ +
+
+
+
-
- -
-
-
- -
+
+
+ +
+
+
+
-
- -
@Html.DropDownListFor(m => m.ShiftDispatchStatus, Model.StatusLevels, new { style = "width: 70%" })
-
-
- -
@Html.DropDownListFor(m => m.ShiftClearStatus, Model.StatusLevels, new { style = "width: 70%" })
-
+
+
+ +
@Html.DropDownListFor(m => m.ShiftDispatchStatus, Model.StatusLevels, new { style = "width: 70%" })
+
+
+ +
@Html.DropDownListFor(m => m.ShiftClearStatus, Model.StatusLevels, new { style = "width: 70%" })
+
-

@localizer["UnitDispatchSettingsHeader"]

-
- -
-
-
- -
+

@localizer["UnitDispatchSettingsHeader"]

+
+ +
+
+
+
-
- -
-
-
- -
+
+
+ +
+
+
+
-
- -
-
-
- - @localizer["PersonnelOnUnitSetUnitStatusHelp"] -
+
+
+ +
+
+
+ + @localizer["PersonnelOnUnitSetUnitStatusHelp"]
+
+ +
+

@localizer["CheckInTimerSettingsHeader"]

-
-
- @commonLocalizer["Cancel"] - +
+ +
+
+
+ + @localizer["CheckInTimerAutoEnableHelp"] +
-
+ +
+
+ @commonLocalizer["Cancel"] + +
+
+ +
+
+ + @* ── Default Timer Configurations (independent form) ── *@ +
+
@localizer["CheckInTimerDefaultConfigsHeader"]
+
+

@localizer["CheckInTimerDefaultConfigsHelp"]

+ + + + + + + + + + + + + + @foreach (var config in Model.TimerConfigs) + { + + + + + + + + + } + +
@localizer["CheckInTimerTargetTypeLabel"]@localizer["CheckInTimerUnitTypeLabel"]@localizer["CheckInTimerDurationLabel"]@localizer["CheckInTimerWarningLabel"]@localizer["CheckInTimerEnabledLabel"]
@((Resgrid.Model.CheckInTimerTargetType)config.TimerTargetType) + @if (config.UnitTypeId.HasValue) + { + var ut = Model.UnitTypes.FirstOrDefault(u => u.UnitTypeId == config.UnitTypeId.Value); + @(ut != null ? ut.Type : config.UnitTypeId.ToString()) + } + @config.DurationMinutes@config.WarningThresholdMinutes@(config.IsEnabled ? localizer["CheckInTimerYes"] : localizer["CheckInTimerNo"]) +
+ @Html.AntiForgeryToken() + + +
+
+ +
+

@localizer["CheckInTimerAddNewDefault"]

+
+ @Html.AntiForgeryToken() +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+
+ + @* ── Call Type / Priority Overrides (independent form) ── *@ +
+
@localizer["CheckInTimerOverridesHeader"]
+
+

@localizer["CheckInTimerOverridesHelp"]

+ + + + + + + + + + + + + + + + @foreach (var ovr in Model.TimerOverrides) + { + + + + + + + + + + + } + +
@localizer["CheckInTimerCallTypeLabel"]@localizer["CheckInTimerCallPriorityLabel"]@localizer["CheckInTimerTargetTypeLabel"]@localizer["CheckInTimerUnitTypeLabel"]@localizer["CheckInTimerDurationLabel"]@localizer["CheckInTimerWarningLabel"]@localizer["CheckInTimerEnabledLabel"]
+ @if (ovr.CallTypeId.HasValue) + { + var ct = Model.CallTypes.FirstOrDefault(c => c.CallTypeId == ovr.CallTypeId.Value); + @(ct != null ? ct.Type : ovr.CallTypeId.ToString()) + } + else + { + @localizer["CheckInTimerAnyOption"] + } + @(ovr.CallPriority.HasValue ? ((Resgrid.Model.CallPriority)ovr.CallPriority.Value).ToString() : localizer["CheckInTimerAnyOption"].Value)@((Resgrid.Model.CheckInTimerTargetType)ovr.TimerTargetType) + @if (ovr.UnitTypeId.HasValue) + { + var ut = Model.UnitTypes.FirstOrDefault(u => u.UnitTypeId == ovr.UnitTypeId.Value); + @(ut != null ? ut.Type : ovr.UnitTypeId.ToString()) + } + @ovr.DurationMinutes@ovr.WarningThresholdMinutes@(ovr.IsEnabled ? localizer["CheckInTimerYes"] : localizer["CheckInTimerNo"]) +
+ @Html.AntiForgeryToken() + + +
+
+ +
+

@localizer["CheckInTimerAddNewOverride"]

+
+ @Html.AntiForgeryToken() +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+
+
+
+
- +
@section Scripts { diff --git a/Web/Resgrid.Web/Areas/User/Views/Department/Settings.cshtml b/Web/Resgrid.Web/Areas/User/Views/Department/Settings.cshtml index 7b07eaaa..a042c9c8 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Department/Settings.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Department/Settings.cshtml @@ -19,7 +19,7 @@
- @localizer["DispatchSettings"] + @localizer["CallDispatchSettingsNav"] @localizer["ShiftSettings"] @localizer["MappingSettingsHeader"] @localizer["ApiRssSettings"] diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExport.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExport.cshtml index f7456a35..c7089df3 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExport.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExport.cshtml @@ -2,6 +2,8 @@ @using Resgrid.Model.Helpers @using Resgrid.Web @using Resgrid.Web.Helpers +@using Microsoft.Extensions.Localization +@inject IStringLocalizer localizer @model Resgrid.Web.Areas.User.Models.Dispatch.CallExportView @inject IStringLocalizer localizer @{ @@ -495,6 +497,80 @@
+ @if (Model.Call.CheckInTimersEnabled && Model.TimerConfigs != null && Model.TimerConfigs.Any()) + { +
+
+

@localizer["CheckInTimerConfigSection"]

+ + + + + + + + + + + @foreach (var timer in Model.TimerConfigs) + { + + + + + + + } + +
@localizer["CheckInTimerType"]@localizer["CheckInTimerDuration"]@localizer["CheckInTimerWarning"]@localizer["CheckInTimerSource"]
@((Resgrid.Model.CheckInTimerTargetType)timer.TargetType)@timer.DurationMinutes@timer.WarningThresholdMinutes@(timer.IsFromOverride ? "Override" : "Default")
+
+
+ } + + @if (Model.CheckInRecords != null && Model.CheckInRecords.Any()) + { +
+
+

@localizer["CheckInTimerLogSection"]

+ + + + + + + + + + + + @foreach (var record in Model.CheckInRecords.OrderBy(r => r.Timestamp)) + { + var personName = Model.Names?.FirstOrDefault(p => p.UserId == record.UserId); + string unitName = null; + if (record.UnitId.HasValue && Model.Units != null) + { + var unit = Model.Units.FirstOrDefault(u => u.UnitId == record.UnitId.Value); + unitName = unit?.Name; + } + var typeName = ((Resgrid.Model.CheckInTimerTargetType)record.CheckInType).ToString(); + if (!string.IsNullOrEmpty(unitName)) + { + typeName += " - " + unitName; + } + + + + + + + + } + +
@localizer["CheckInTimerHistoryTimestamp"]@localizer["CheckInTimerHistoryWho"]@localizer["CheckInTimerHistoryType"]@localizer["CheckInTimerHistoryLocation"]@localizer["CheckInTimerHistoryNote"]
@record.Timestamp.TimeConverterToString(Model.Department)@(personName?.Name ?? record.UserId)@typeName@((!string.IsNullOrEmpty(record.Latitude) && !string.IsNullOrEmpty(record.Longitude)) ? record.Latitude + ", " + record.Longitude : "")@record.Note
+
+
+ } +
@localizer["AttachedImages"] @@ -526,6 +602,7 @@ }
+
@DateTime.UtcNow.TimeConverterToString(Model.Department) diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml index 89d4e4e7..01b85437 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml @@ -402,6 +402,18 @@
} +
+
+ +
+
+ +
+
+
+
@commonLocalizer["Cancel"] diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/UpdateCall.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/UpdateCall.cshtml index 83b3165f..5edb9ea4 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/UpdateCall.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/UpdateCall.cshtml @@ -279,6 +279,18 @@ @Html.Raw(Model.UdfFormHtml)
} +
+
+ +
+
+ +
+
+
+
@commonLocalizer["Cancel"] diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml index d1fd9dbb..1a4ec818 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml @@ -771,6 +771,66 @@
+ @if (Model.Call.CheckInTimersEnabled) + { +
+
+
@localizer["CheckInTimersHeader"]
+
+
+ @if (Model.Call.State == (int)CallStates.Active) + { +
+ + + + + + + + + + + + +
@localizer["CheckInTimerTarget"]@localizer["CheckInTimerTimeRemaining"]@localizer["CheckInTimerStatus"]
...
+
+
+ @localizer["CheckInTimerShowHistory"] +
+ + + + + + + + + + + +
@localizer["CheckInTimerHistoryTimestamp"]@localizer["CheckInTimerHistoryWho"]@localizer["CheckInTimerHistoryType"]@localizer["CheckInTimerHistoryNote"]
+
+
+ } + else + { + + + + + + + + + + + +
@localizer["CheckInTimerHistoryTimestamp"]@localizer["CheckInTimerHistoryWho"]@localizer["CheckInTimerHistoryType"]@localizer["CheckInTimerHistoryNote"]
+ } +
+
+ }
@@ -792,10 +852,37 @@
+@if (Model.Call.CheckInTimersEnabled && Model.Call.State == (int)CallStates.Active) +{ + +} + @section Scripts { @@ -826,4 +913,8 @@ + @if (Model.Call.CheckInTimersEnabled) + { + + } } diff --git a/Web/Resgrid.Web/Areas/User/Views/Templates/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Templates/Edit.cshtml index 3ba9a204..f2c98c5c 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Templates/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Templates/Edit.cshtml @@ -93,6 +93,16 @@
+
+ +
+
+ +
+
+
@commonLocalizer["Cancel"] diff --git a/Web/Resgrid.Web/Areas/User/Views/Templates/New.cshtml b/Web/Resgrid.Web/Areas/User/Views/Templates/New.cshtml index ef23aff5..c8604336 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Templates/New.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Templates/New.cshtml @@ -92,6 +92,16 @@
+
+ +
+
+ +
+
+
@commonLocalizer["Cancel"] diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.checkin-timers.js b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.checkin-timers.js new file mode 100644 index 00000000..1c14d903 --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.checkin-timers.js @@ -0,0 +1,202 @@ +(function () { + 'use strict'; + + var timerStatuses = []; + var timerInterval = null; + + $(document).ready(function () { + // callState 0 = Active + if (typeof callState !== 'undefined' && callState === 0) { + loadTimerStatuses(); + timerInterval = setInterval(updateCountdowns, 1000); + } + loadCheckInHistory(); + }); + + function loadTimerStatuses() { + $.ajax({ + url: resgrid.absoluteBaseUrl + '/User/Dispatch/GetCheckInTimerStatuses?callId=' + callId, + contentType: 'application/json; charset=utf-8', + type: 'GET' + }).done(function (result) { + if (result) { + timerStatuses = result; + renderTimerStatuses(); + } + }); + } + + function renderTimerStatuses() { + var tbody = $('#checkInTimersBody'); + tbody.empty(); + + if (!timerStatuses || timerStatuses.length === 0) { + tbody.append('-'); + return; + } + + for (var i = 0; i < timerStatuses.length; i++) { + var t = timerStatuses[i]; + var statusBadge = getStatusBadge(t.Status); + var remaining = getRemainingTime(t); + + var row = '' + + '' + escapeHtml(t.TargetName || t.TargetTypeName) + '
' + escapeHtml(t.TargetTypeName) + '' + + '' + remaining + '' + + '' + statusBadge + '' + + '' + + ''; + tbody.append(row); + } + } + + function escapeHtml(str) { + if (!str) return ''; + var div = document.createElement('div'); + div.appendChild(document.createTextNode(str)); + return div.innerHTML; + } + + function getStatusBadge(status) { + switch (status) { + case 'Green': + return 'OK'; + case 'Warning': + return 'Warning'; + case 'Critical': + return 'OVERDUE'; + default: + return '' + status + ''; + } + } + + function getRemainingTime(timer) { + var remainingMin = timer.DurationMinutes - timer.ElapsedMinutes; + if (remainingMin <= 0) { + var overdueMin = Math.abs(remainingMin); + return '-' + formatMinutes(overdueMin); + } + return formatMinutes(remainingMin); + } + + function formatMinutes(min) { + var hours = Math.floor(min / 60); + var mins = Math.floor(min % 60); + var secs = Math.floor((min * 60) % 60); + if (hours > 0) return hours + 'h ' + mins + 'm ' + secs + 's'; + return mins + 'm ' + secs + 's'; + } + + function updateCountdowns() { + for (var i = 0; i < timerStatuses.length; i++) { + timerStatuses[i].ElapsedMinutes += (1.0 / 60.0); + + var status; + if (timerStatuses[i].ElapsedMinutes < timerStatuses[i].DurationMinutes) + status = 'Green'; + else if (timerStatuses[i].ElapsedMinutes < timerStatuses[i].DurationMinutes + timerStatuses[i].WarningThresholdMinutes) + status = 'Warning'; + else + status = 'Critical'; + timerStatuses[i].Status = status; + + $('.timer-remaining[data-index="' + i + '"]').text(getRemainingTime(timerStatuses[i])); + $('.timer-status[data-index="' + i + '"]').html(getStatusBadge(status)); + } + } + + function loadCheckInHistory() { + $.ajax({ + url: resgrid.absoluteBaseUrl + '/User/Dispatch/GetCheckInHistory?callId=' + callId, + contentType: 'application/json; charset=utf-8', + type: 'GET' + }).done(function (result) { + if (result) { + renderCheckInHistory(result); + } + }); + } + + function renderCheckInHistory(records) { + var tbody = $('#checkInHistoryBody'); + tbody.empty(); + + if (!records || records.length === 0) { + tbody.append('-'); + return; + } + + for (var i = 0; i < records.length; i++) { + var r = records[i]; + var who = escapeHtml(r.PerformedBy || ''); + var target = escapeHtml(r.CheckInTypeName || ''); + if (r.UnitName) { + target += ' - ' + escapeHtml(r.UnitName); + } + + var row = '' + + '' + escapeHtml(r.Timestamp) + '' + + '' + who + '' + + '' + target + '' + + '' + escapeHtml(r.Note || '') + '' + + ''; + tbody.append(row); + } + } + + // Open modal for check-in + window.showCheckInDialog = function (checkInType, unitId) { + $('#checkInTargetType').val(checkInType); + $('#checkInUnitId').val(unitId || ''); + $('#checkInNote').val(''); + $('#checkInModal').modal('show'); + }; + + // Wire up modal submit button + $(document).on('click', '#checkInSubmitBtn', function () { + var checkInType = parseInt($('#checkInTargetType').val()); + var unitIdVal = $('#checkInUnitId').val(); + var note = $('#checkInNote').val(); + + var input = { + CallId: callId, + CheckInType: checkInType, + UnitId: unitIdVal ? parseInt(unitIdVal) : null, + Note: note || null + }; + + $('#checkInSubmitBtn').prop('disabled', true); + + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition(function (pos) { + input.Latitude = pos.coords.latitude.toString(); + input.Longitude = pos.coords.longitude.toString(); + submitCheckIn(input); + }, function () { + submitCheckIn(input); + }, { timeout: 5000 }); + } else { + submitCheckIn(input); + } + }); + + function submitCheckIn(input) { + $.ajax({ + url: resgrid.absoluteBaseUrl + '/User/Dispatch/PerformCheckIn', + contentType: 'application/json; charset=utf-8', + type: 'POST', + data: JSON.stringify(input) + }).done(function (result) { + $('#checkInModal').modal('hide'); + $('#checkInSubmitBtn').prop('disabled', false); + if (result && result.Id) { + loadTimerStatuses(); + loadCheckInHistory(); + } + }).fail(function () { + $('#checkInSubmitBtn').prop('disabled', false); + $('#checkInModal').modal('hide'); + alert('Failed to perform check-in. Please try again.'); + }); + } +})(); diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.newcall.js b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.newcall.js index 5affd67b..24a55765 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.newcall.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.newcall.js @@ -318,6 +318,12 @@ var resgrid; if (data.CallPriority && data.CallPriority >= 0) { $('#CallPriority').val(data.CallPriority); } + + if (data.CheckInTimersEnabled === true) { + $('input[name="Call.CheckInTimersEnabled"]').prop('checked', true); + } else if (data.CheckInTimersEnabled === false) { + $('input[name="Call.CheckInTimersEnabled"]').prop('checked', false); + } } }); } From f3b7d845693cae3145f2fa6aa5f4b7727a535302 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 28 Mar 2026 12:44:11 -0700 Subject: [PATCH 2/7] RE1-T108 Adding calendar checkin/checkout and attendance reporting --- .../Areas/User/Calendar/Calendar.ar.resx | 99 ++++ .../Areas/User/Calendar/Calendar.de.resx | 99 ++++ .../Areas/User/Calendar/Calendar.en.resx | 99 ++++ .../Areas/User/Calendar/Calendar.es.resx | 99 ++++ .../Areas/User/Calendar/Calendar.fr.resx | 99 ++++ .../Areas/User/Calendar/Calendar.it.resx | 99 ++++ .../Areas/User/Calendar/Calendar.pl.resx | 99 ++++ .../Areas/User/Calendar/Calendar.sv.resx | 99 ++++ .../Areas/User/Calendar/Calendar.uk.resx | 99 ++++ .../Areas/User/Reports/Reports.ar.resx | 63 +++ .../Areas/User/Reports/Reports.de.resx | 63 +++ .../Areas/User/Reports/Reports.en.resx | 65 +++ .../Areas/User/Reports/Reports.es.resx | 63 +++ .../Areas/User/Reports/Reports.fr.resx | 63 +++ .../Areas/User/Reports/Reports.it.resx | 63 +++ .../Areas/User/Reports/Reports.pl.resx | 63 +++ .../Areas/User/Reports/Reports.sv.resx | 63 +++ .../Areas/User/Reports/Reports.uk.resx | 63 +++ Core/Resgrid.Model/AuditLogTypes.cs | 8 +- Core/Resgrid.Model/CalendarItem.cs | 3 + Core/Resgrid.Model/CalendarItemCheckIn.cs | 70 +++ .../Resgrid.Model/CalendarItemCheckInTypes.cs | 20 + .../ICalendarItemCheckInRepository.cs | 14 + .../Services/IAuthorizationService.cs | 10 + .../Services/ICalendarService.cs | 28 ++ Core/Resgrid.Services/AuditService.cs | 11 + Core/Resgrid.Services/AuthorizationService.cs | 163 +++++++ Core/Resgrid.Services/CalendarService.cs | 141 +++++- .../M0057_AddingCalendarItemCheckIns.cs | 63 +++ .../M0057_AddingCalendarItemCheckInsPg.cs | 63 +++ .../CalendarItemCheckInRepository.cs | 192 ++++++++ .../Configs/SqlConfiguration.cs | 8 + .../Modules/ApiDataModule.cs | 3 + .../Modules/DataModule.cs | 3 + .../Modules/NonWebDataModule.cs | 3 + .../Modules/TestingDataModule.cs | 3 + ...ctCalendarItemCheckInByItemAndUserQuery.cs | 33 ++ ...alendarItemCheckInsByDeptDateRangeQuery.cs | 33 ++ ...SelectCalendarItemCheckInsByItemIdQuery.cs | 33 ++ ...alendarItemCheckInsByUserDateRangeQuery.cs | 33 ++ .../PostgreSql/PostgreSqlConfiguration.cs | 26 ++ .../SqlServer/SqlServerConfiguration.cs | 25 + .../Services/CalendarServiceCheckInTests.cs | 442 ++++++++++++++++++ .../Services/CalendarServiceTests.cs | 2 +- .../Controllers/v4/CalendarController.cs | 301 +++++++++++- .../v4/Calendar/CalendarCheckInInput.cs | 33 ++ .../v4/Calendar/CalendarCheckInResultData.cs | 90 ++++ .../v4/Calendar/CalendarCheckInUpdateInput.cs | 35 ++ .../v4/Calendar/CalendarCheckOutInput.cs | 33 ++ .../v4/Calendar/GetAllCalendarItemResult.cs | 5 + .../v4/Calendar/GetCalendarCheckInsResult.cs | 15 + .../v4/Calendar/SetCalendarCheckInResult.cs | 13 + .../Resgrid.Web.Services.xml | 225 +++++++++ .../User/Controllers/CalendarController.cs | 290 +++++++++++- .../User/Controllers/ReportsController.cs | 195 +++++++- .../Models/Calendar/CalendarCheckInView.cs | 14 + .../User/Models/Calendar/CalendarItemView.cs | 5 + .../Calendar/EditCalendarCheckInView.cs | 9 + .../User/Models/Calendar/SignupSheetView.cs | 13 + .../EventAttendanceDetailView.cs | 45 ++ .../EventAttendance/EventAttendanceView.cs | 31 ++ .../Params/EventAttendanceReportParams.cs | 13 + .../Areas/User/Views/Calendar/Edit.cshtml | 39 +- .../User/Views/Calendar/EditCheckIn.cshtml | 84 ++++ .../Areas/User/Views/Calendar/New.cshtml | 39 +- .../User/Views/Calendar/SignupSheet.cshtml | 173 +++++++ .../Areas/User/Views/Calendar/View.cshtml | 210 ++++++++- .../EventAttendanceDetailReport.cshtml | 111 +++++ .../Reports/EventAttendanceReport.cshtml | 94 ++++ .../EventAttendanceReportParams.cshtml | 81 ++++ .../Areas/User/Views/Reports/Index.cshtml | 11 + 71 files changed, 5082 insertions(+), 18 deletions(-) create mode 100644 Core/Resgrid.Model/CalendarItemCheckIn.cs create mode 100644 Core/Resgrid.Model/CalendarItemCheckInTypes.cs create mode 100644 Core/Resgrid.Model/Repositories/ICalendarItemCheckInRepository.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0057_AddingCalendarItemCheckIns.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0057_AddingCalendarItemCheckInsPg.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/CalendarItemCheckInRepository.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/Calendar/SelectCalendarItemCheckInByItemAndUserQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/Calendar/SelectCalendarItemCheckInsByDeptDateRangeQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/Calendar/SelectCalendarItemCheckInsByItemIdQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/Calendar/SelectCalendarItemCheckInsByUserDateRangeQuery.cs create mode 100644 Tests/Resgrid.Tests/Services/CalendarServiceCheckInTests.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/Calendar/CalendarCheckInInput.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/Calendar/CalendarCheckInResultData.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/Calendar/CalendarCheckInUpdateInput.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/Calendar/CalendarCheckOutInput.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/Calendar/GetCalendarCheckInsResult.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/Calendar/SetCalendarCheckInResult.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/Calendar/CalendarCheckInView.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/Calendar/EditCalendarCheckInView.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/Calendar/SignupSheetView.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/Reports/EventAttendance/EventAttendanceDetailView.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/Reports/EventAttendance/EventAttendanceView.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/Reports/Params/EventAttendanceReportParams.cs create mode 100644 Web/Resgrid.Web/Areas/User/Views/Calendar/EditCheckIn.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Calendar/SignupSheet.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Reports/EventAttendanceDetailReport.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Reports/EventAttendanceReport.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Reports/EventAttendanceReportParams.cshtml diff --git a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.ar.resx b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.ar.resx index 9ddf38b6..f568c61e 100644 --- a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.ar.resx @@ -300,4 +300,103 @@ الأنواع + + تسجيل الحضور + + + تسجيل الانصراف + + + وقت الحضور + + + وقت الانصراف + + + المدة + + + تم تسجيل الحضور بنجاح + + + تم تسجيل الانصراف بنجاح + + + تعديل أوقات التسجيل + + + تعديل يدوي + + + تسجيل حضور بواسطة المسؤول + + + تسجيل حضور نيابة عن شخص آخر + + + تقرير حضور الفعاليات + + + إجمالي الفعاليات المحضورة + + + إجمالي الساعات + + + لم يتم العثور على سجل حضور + + + تم تسجيل الحضور مسبقاً + + + لم يتم تسجيل الحضور + + + تسجيل متأخر + + + أوقات مخصصة + + + غير مصرح + + + تم حذف سجل الحضور + + + ملاحظة الحضور + + + ملاحظة الانصراف + + + سُجّل الحضور بواسطة + + + سُجّل الانصراف بواسطة + + + معطّل + + + تسجيل ذاتي للحضور/الانصراف + + + المنشئ والمسؤولون فقط + + + يُفتح تسجيل الحضور قبل 15 دقيقة من بدء الفعالية. + + + الحاضرون + + + ستتم إضافة المجموعات/الأفراد المحددين كحاضرين وإخطارهم مباشرة. + + + سيتم إخطار المجموعات/الأفراد المحددين بهذه الفعالية. + + + طباعة ورقة التسجيل + diff --git a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.de.resx b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.de.resx index f1233824..39008166 100644 --- a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.de.resx @@ -251,4 +251,103 @@ Typen + + Einchecken + + + Auschecken + + + Eincheckzeit + + + Auscheckzeit + + + Dauer + + + Einchecken erfolgreich + + + Auschecken erfolgreich + + + Eincheckzeiten bearbeiten + + + Manuelle Anpassung + + + Einchecken durch Administrator + + + Stellvertretend einchecken + + + Anwesenheitsbericht für Veranstaltungen + + + Gesamtzahl besuchter Veranstaltungen + + + Gesamtstunden + + + Kein Eincheckdatensatz gefunden + + + Bereits eingecheckt + + + Nicht eingecheckt + + + Verspätete Anmeldung + + + Benutzerdefinierte Zeiten + + + Nicht autorisiert + + + Eincheckdatensatz gelöscht + + + Eincheck-Notiz + + + Auscheck-Notiz + + + Eingecheckt von + + + Ausgecheckt von + + + Deaktiviert + + + Selbst Ein-/Auschecken + + + Nur Ersteller & Administratoren + + + Das Einchecken öffnet 15 Minuten vor Veranstaltungsbeginn. + + + Teilnehmer + + + Ausgewählte Gruppen/Mitarbeiter werden als Teilnehmer hinzugefügt und direkt benachrichtigt. + + + Ausgewählte Gruppen/Mitarbeiter werden über diese Veranstaltung benachrichtigt. + + + Anmeldeliste drucken + diff --git a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.en.resx b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.en.resx index 3725c2c0..550fcc00 100644 --- a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.en.resx @@ -300,4 +300,103 @@ Types + + Check In + + + Check Out + + + Check-In Time + + + Check-Out Time + + + Duration + + + Check-in successful + + + Check-out successful + + + Edit Check-In Times + + + Manual Override + + + Admin Check-In + + + Check In on Behalf + + + Event Attendance Report + + + Total Events Attended + + + Total Hours + + + No check-in record found + + + Already checked in + + + Not checked in + + + Late RSVP + + + Custom Times + + + Unauthorized + + + Check-in deleted + + + Check-In Note + + + Check-Out Note + + + Checked In By + + + Checked Out By + + + Disabled + + + Self Check-In/Out + + + Creator & Admins Only + + + Check-in opens 15 minutes before the event starts. + + + Attendees + + + Selected groups/personnel will be added as attendees and notified directly. + + + Selected groups/personnel will be notified about this event. + + + Print Signup Sheet + diff --git a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.es.resx b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.es.resx index 534936fa..447b4045 100644 --- a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.es.resx @@ -297,4 +297,103 @@ Tipos + + Registrar Entrada + + + Registrar Salida + + + Hora de Entrada + + + Hora de Salida + + + Duración + + + Registro de entrada exitoso + + + Registro de salida exitoso + + + Editar Horarios de Registro + + + Ajuste Manual + + + Registro de Entrada por Administrador + + + Registrar Entrada en Nombre de Otro + + + Reporte de Asistencia a Eventos + + + Total de Eventos Asistidos + + + Total de Horas + + + No se encontró registro de entrada + + + Ya se registró la entrada + + + Sin registro de entrada + + + RSVP Tardío + + + Horarios Personalizados + + + No Autorizado + + + Registro de entrada eliminado + + + Nota de Entrada + + + Nota de Salida + + + Entrada Registrada Por + + + Salida Registrada Por + + + Deshabilitado + + + Auto Registro de Entrada/Salida + + + Solo Creador y Administradores + + + El registro de entrada se abre 15 minutos antes del inicio del evento. + + + Asistentes + + + Los grupos/personal seleccionados se agregarán como asistentes y serán notificados directamente. + + + Los grupos/personal seleccionados serán notificados sobre este evento. + + + Imprimir Hoja de Registro + diff --git a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.fr.resx b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.fr.resx index fca632a2..7ed28a6e 100644 --- a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.fr.resx @@ -251,4 +251,103 @@ Types + + Pointer l'arrivée + + + Pointer le départ + + + Heure d'arrivée + + + Heure de départ + + + Durée + + + Pointage d'arrivée réussi + + + Pointage de départ réussi + + + Modifier les horaires de pointage + + + Ajustement manuel + + + Pointage par l'administrateur + + + Pointer pour le compte d'un autre + + + Rapport de présence aux événements + + + Total des événements assistés + + + Total des heures + + + Aucun enregistrement de pointage trouvé + + + Déjà pointé + + + Non pointé + + + Inscription tardive + + + Horaires personnalisés + + + Non autorisé + + + Pointage supprimé + + + Note d'arrivée + + + Note de départ + + + Arrivée enregistrée par + + + Départ enregistré par + + + Désactivé + + + Auto-pointage arrivée/départ + + + Créateur et administrateurs uniquement + + + Le pointage ouvre 15 minutes avant le début de l'événement. + + + Participants + + + Les groupes/personnels sélectionnés seront ajoutés comme participants et notifiés directement. + + + Les groupes/personnels sélectionnés seront notifiés de cet événement. + + + Imprimer la feuille d'émargement + diff --git a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.it.resx b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.it.resx index 3f506649..cc3eb144 100644 --- a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.it.resx @@ -251,4 +251,103 @@ Tipi + + Registra Entrata + + + Registra Uscita + + + Ora di Entrata + + + Ora di Uscita + + + Durata + + + Registrazione entrata riuscita + + + Registrazione uscita riuscita + + + Modifica Orari di Registrazione + + + Modifica Manuale + + + Registrazione Entrata da Amministratore + + + Registra Entrata per Conto di + + + Report Presenze Eventi + + + Totale Eventi Partecipati + + + Ore Totali + + + Nessun record di entrata trovato + + + Entrata già registrata + + + Entrata non registrata + + + RSVP in Ritardo + + + Orari Personalizzati + + + Non Autorizzato + + + Registrazione entrata eliminata + + + Nota di Entrata + + + Nota di Uscita + + + Entrata Registrata Da + + + Uscita Registrata Da + + + Disabilitato + + + Auto Registrazione Entrata/Uscita + + + Solo Creatore e Amministratori + + + La registrazione entrata si apre 15 minuti prima dell'inizio dell'evento. + + + Partecipanti + + + I gruppi/personale selezionati verranno aggiunti come partecipanti e notificati direttamente. + + + I gruppi/personale selezionati verranno notificati di questo evento. + + + Stampa Foglio Presenze + diff --git a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.pl.resx b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.pl.resx index f16062b1..06ea4fe9 100644 --- a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.pl.resx @@ -251,4 +251,103 @@ Typy + + Zamelduj się + + + Wymelduj się + + + Czas zameldowania + + + Czas wymeldowania + + + Czas trwania + + + Zameldowanie zakończone sukcesem + + + Wymeldowanie zakończone sukcesem + + + Edytuj czasy zameldowania + + + Ręczna korekta + + + Zameldowanie przez administratora + + + Zamelduj w imieniu innej osoby + + + Raport obecności na wydarzeniach + + + Łączna liczba wydarzeń + + + Łączna liczba godzin + + + Nie znaleziono rekordu zameldowania + + + Już zameldowany + + + Niezameldowany + + + Późna rejestracja + + + Niestandardowe czasy + + + Brak autoryzacji + + + Rekord zameldowania usunięty + + + Notatka zameldowania + + + Notatka wymeldowania + + + Zameldowany przez + + + Wymeldowany przez + + + Wyłączone + + + Samodzielne zameldowanie/wymeldowanie + + + Tylko twórca i administratorzy + + + Zameldowanie otwiera się 15 minut przed rozpoczęciem wydarzenia. + + + Uczestnicy + + + Wybrane grupy/personel zostaną dodani jako uczestnicy i powiadomieni bezpośrednio. + + + Wybrane grupy/personel zostaną powiadomieni o tym wydarzeniu. + + + Drukuj listę obecności + diff --git a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.sv.resx b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.sv.resx index 71d452ab..9fab8d8c 100644 --- a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.sv.resx @@ -251,4 +251,103 @@ Typer + + Checka in + + + Checka ut + + + Incheckningstid + + + Utcheckningstid + + + Varaktighet + + + Incheckning lyckades + + + Utcheckning lyckades + + + Redigera incheckningstider + + + Manuell justering + + + Administratörsincheckning + + + Checka in åt annan person + + + Närvarorapport för evenemang + + + Totalt antal besökta evenemang + + + Totala timmar + + + Ingen incheckningspost hittades + + + Redan incheckad + + + Inte incheckad + + + Sen anmälan + + + Anpassade tider + + + Ej behörig + + + Incheckningspost raderad + + + Incheckningsanteckning + + + Utcheckningsanteckning + + + Incheckad av + + + Utcheckad av + + + Inaktiverad + + + Själv in-/utcheckning + + + Endast skapare och administratörer + + + Incheckning öppnar 15 minuter före evenemangets start. + + + Deltagare + + + Valda grupper/personal läggs till som deltagare och meddelas direkt. + + + Valda grupper/personal meddelas om detta evenemang. + + + Skriv ut närvarolista + diff --git a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.uk.resx b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.uk.resx index ae25612b..5029ded5 100644 --- a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.uk.resx @@ -251,4 +251,103 @@ Типи + + Реєстрація входу + + + Реєстрація виходу + + + Час входу + + + Час виходу + + + Тривалість + + + Реєстрацію входу виконано + + + Реєстрацію виходу виконано + + + Редагувати час реєстрації + + + Ручне коригування + + + Реєстрація адміністратором + + + Зареєструвати від імені іншого + + + Звіт про відвідуваність подій + + + Загальна кількість відвіданих подій + + + Загальна кількість годин + + + Запис реєстрації не знайдено + + + Вже зареєстровано + + + Не зареєстровано + + + Пізня реєстрація + + + Власний час + + + Не авторизовано + + + Запис реєстрації видалено + + + Примітка до входу + + + Примітка до виходу + + + Вхід зареєстровано + + + Вихід зареєстровано + + + Вимкнено + + + Самостійна реєстрація входу/виходу + + + Тільки автор та адміністратори + + + Реєстрація відкривається за 15 хвилин до початку події. + + + Учасники + + + Обрані групи/персонал будуть додані як учасники та сповіщені безпосередньо. + + + Обрані групи/персонал будуть сповіщені про цю подію. + + + Друк листа реєстрації + diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.ar.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.ar.resx index fc5bd3fb..ca04e7e2 100644 --- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.ar.resx @@ -246,4 +246,67 @@ معرف المرجع لا شيء رد اتصال + + تقرير حضور الفعاليات + + + يعرض ساعات الحضور لكل شخص مع تتبع تسجيل الحضور والانصراف. + + + تقرير حضور الفعاليات + + + ساعات الحضور + + + الفعاليات المحضورة + + + الفعاليات الفائتة + + + إجمالي الساعات + + + التفاصيل + + + تفاصيل الحضور + + + الفعالية + + + وقت الحضور + + + وقت الانصراف + + + المدة + + + ملاحظة الحضور + + + ملاحظة الانصراف + + + سُجّل الحضور بواسطة + + + سُجّل الانصراف بواسطة + + + موقع الحضور + + + موقع الانصراف + + + لم يتم تسجيل الانصراف + + + الوقت الإجمالي + diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.de.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.de.resx index 7bd26a34..3fbef089 100644 --- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.de.resx @@ -246,4 +246,67 @@ Referenz-Id Keine Rückruf + + Anwesenheitsbericht für Veranstaltungen + + + Zeigt die Anwesenheitsstunden pro Person mit Ein-/Auscheckprotokoll. + + + Anwesenheitsbericht für Veranstaltungen + + + Anwesenheitsstunden + + + Besuchte Veranstaltungen + + + Versäumte Veranstaltungen + + + Gesamtstunden + + + Details + + + Anwesenheitsdetails + + + Veranstaltung + + + Eincheckzeit + + + Auscheckzeit + + + Dauer + + + Eincheck-Notiz + + + Auscheck-Notiz + + + Eingecheckt von + + + Ausgecheckt von + + + Eincheck-Standort + + + Auscheck-Standort + + + Nicht ausgecheckt + + + Gesamtzeit + diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.en.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.en.resx index 8613df63..905083d8 100644 --- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.en.resx @@ -675,4 +675,69 @@ Callback + + + + Event Attendance Report + + + Shows volunteer/event attendance hours per person with check-in/check-out tracking. + + + Event Attendance Report + + + Event Attendance Hours + + + Events Attended + + + Events Missed + + + Total Hours + + + Details + + + Event Attendance Detail + + + Event + + + Check-In Time + + + Check-Out Time + + + Duration + + + Check-In Note + + + Check-Out Note + + + Checked In By + + + Checked Out By + + + Check-In Location + + + Check-Out Location + + + Not checked out + + + Total Time + diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.es.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.es.resx index 71683acc..08fef471 100644 --- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.es.resx @@ -246,4 +246,67 @@ Id de Referencia Ninguno Devolución de Llamada + + Reporte de Asistencia a Eventos + + + Muestra las horas de asistencia voluntaria/evento por persona con seguimiento de entrada/salida. + + + Reporte de Asistencia a Eventos + + + Horas de Asistencia a Eventos + + + Eventos Asistidos + + + Eventos Perdidos + + + Total de Horas + + + Detalles + + + Detalle de Asistencia a Eventos + + + Evento + + + Hora de Entrada + + + Hora de Salida + + + Duración + + + Nota de Entrada + + + Nota de Salida + + + Entrada Registrada Por + + + Salida Registrada Por + + + Ubicación de Entrada + + + Ubicación de Salida + + + Sin registro de salida + + + Tiempo Total + diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.fr.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.fr.resx index 13798105..6c91627e 100644 --- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.fr.resx @@ -246,4 +246,67 @@ Id de Référence Aucun Rappel + + Rapport de présence aux événements + + + Affiche les heures de présence par personne avec suivi des pointages. + + + Rapport de présence aux événements + + + Heures de présence + + + Événements assistés + + + Événements manqués + + + Total des heures + + + Détails + + + Détail de présence + + + Événement + + + Heure d'arrivée + + + Heure de départ + + + Durée + + + Note d'arrivée + + + Note de départ + + + Arrivée enregistrée par + + + Départ enregistré par + + + Lieu d'arrivée + + + Lieu de départ + + + Non pointé au départ + + + Temps total + diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.it.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.it.resx index 35199347..45f79b02 100644 --- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.it.resx @@ -246,4 +246,67 @@ Id Riferimento Nessuno Richiamata + + Report Presenze Eventi + + + Mostra le ore di presenza per persona con tracciamento entrata/uscita. + + + Report Presenze Eventi + + + Ore di Presenza + + + Eventi Partecipati + + + Eventi Mancati + + + Ore Totali + + + Dettagli + + + Dettaglio Presenze + + + Evento + + + Ora di Entrata + + + Ora di Uscita + + + Durata + + + Nota di Entrata + + + Nota di Uscita + + + Entrata Registrata Da + + + Uscita Registrata Da + + + Posizione Entrata + + + Posizione Uscita + + + Uscita non registrata + + + Tempo Totale + diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.pl.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.pl.resx index c305cff9..9ea01849 100644 --- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.pl.resx @@ -246,4 +246,67 @@ Id Referencji Brak Oddzwonienie + + Raport obecności na wydarzeniach + + + Pokazuje godziny obecności na osobę ze śledzeniem zameldowań/wymeldowań. + + + Raport obecności na wydarzeniach + + + Godziny obecności + + + Wydarzenia z obecnością + + + Wydarzenia nieobecne + + + Łączna liczba godzin + + + Szczegóły + + + Szczegóły obecności + + + Wydarzenie + + + Czas zameldowania + + + Czas wymeldowania + + + Czas trwania + + + Notatka zameldowania + + + Notatka wymeldowania + + + Zameldowany przez + + + Wymeldowany przez + + + Lokalizacja zameldowania + + + Lokalizacja wymeldowania + + + Nie wymeldowany + + + Łączny czas + diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.sv.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.sv.resx index 774a62ca..bd0b657b 100644 --- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.sv.resx @@ -246,4 +246,67 @@ Referens-Id Ingen Återuppringning + + Närvarorapport för evenemang + + + Visar närvarotimmar per person med in-/utcheckningsspårning. + + + Närvarorapport för evenemang + + + Närvarotimmar + + + Besökta evenemang + + + Missade evenemang + + + Totala timmar + + + Detaljer + + + Närvarodetaljer + + + Evenemang + + + Incheckningstid + + + Utcheckningstid + + + Varaktighet + + + Incheckningsanteckning + + + Utcheckningsanteckning + + + Incheckad av + + + Utcheckad av + + + Incheckningsplats + + + Utcheckningsplats + + + Inte utcheckad + + + Total tid + diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.uk.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.uk.resx index 8ee37b40..41555ced 100644 --- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.uk.resx @@ -246,4 +246,67 @@ Id Посилання Немає Зворотній Дзвінок + + Звіт про відвідуваність подій + + + Показує години присутності на особу з відстеженням входу/виходу. + + + Звіт про відвідуваність подій + + + Години присутності + + + Відвідані події + + + Пропущені події + + + Загальна кількість годин + + + Деталі + + + Деталі присутності + + + Подія + + + Час входу + + + Час виходу + + + Тривалість + + + Примітка до входу + + + Примітка до виходу + + + Вхід зареєстровано + + + Вихід зареєстровано + + + Місце входу + + + Місце виходу + + + Вихід не зареєстровано + + + Загальний час + diff --git a/Core/Resgrid.Model/AuditLogTypes.cs b/Core/Resgrid.Model/AuditLogTypes.cs index 0377f71a..9ed76e36 100644 --- a/Core/Resgrid.Model/AuditLogTypes.cs +++ b/Core/Resgrid.Model/AuditLogTypes.cs @@ -134,6 +134,12 @@ public enum AuditLogTypes CheckInTimerOverrideDeleted, CheckInPerformed, CheckInTimerEnabledOnCall, - CheckInTimerDisabledOnCall + CheckInTimerDisabledOnCall, + // Calendar Check-In Attendance + CalendarCheckInPerformed, + CalendarCheckOutPerformed, + CalendarCheckInUpdated, + CalendarCheckInDeleted, + CalendarAdminCheckInPerformed } } diff --git a/Core/Resgrid.Model/CalendarItem.cs b/Core/Resgrid.Model/CalendarItem.cs index 5e0e39d4..02e7340b 100644 --- a/Core/Resgrid.Model/CalendarItem.cs +++ b/Core/Resgrid.Model/CalendarItem.cs @@ -52,6 +52,8 @@ public class CalendarItem: IEntity public int SignupType { get; set; } + public int CheckInType { get; set; } + public int Reminder { get; set; } public bool LockEditing { get; set; } @@ -171,6 +173,7 @@ public CalendarItem CreateRecurranceItem(DateTime start, DateTime end, string ti item.IsAllDay = IsAllDay; item.ItemType = ItemType; item.SignupType = SignupType; + item.CheckInType = CheckInType; item.Public = Public; item.StartTimezone = timeZone; item.EndTimezone = timeZone; diff --git a/Core/Resgrid.Model/CalendarItemCheckIn.cs b/Core/Resgrid.Model/CalendarItemCheckIn.cs new file mode 100644 index 00000000..39365117 --- /dev/null +++ b/Core/Resgrid.Model/CalendarItemCheckIn.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + public class CalendarItemCheckIn : IEntity + { + public string CalendarItemCheckInId { get; set; } + + public int DepartmentId { get; set; } + + public int CalendarItemId { get; set; } + + public string UserId { get; set; } + + public DateTime CheckInTime { get; set; } + + public DateTime? CheckOutTime { get; set; } + + public string CheckInByUserId { get; set; } + + public string CheckOutByUserId { get; set; } + + public bool IsManualOverride { get; set; } + + public string CheckInNote { get; set; } + + public string CheckOutNote { get; set; } + + public string CheckInLatitude { get; set; } + + public string CheckInLongitude { get; set; } + + public string CheckOutLatitude { get; set; } + + public string CheckOutLongitude { get; set; } + + public DateTime Timestamp { get; set; } + + [NotMapped] + public string TableName => "CalendarItemCheckIns"; + + [NotMapped] + public string IdName => "CalendarItemCheckInId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return CalendarItemCheckInId; } + set { CalendarItemCheckInId = (string)value; } + } + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + + public TimeSpan? GetDuration() + { + if (CheckOutTime.HasValue) + return CheckOutTime.Value - CheckInTime; + + return null; + } + } +} diff --git a/Core/Resgrid.Model/CalendarItemCheckInTypes.cs b/Core/Resgrid.Model/CalendarItemCheckInTypes.cs new file mode 100644 index 00000000..7ea6d8ba --- /dev/null +++ b/Core/Resgrid.Model/CalendarItemCheckInTypes.cs @@ -0,0 +1,20 @@ +namespace Resgrid.Model +{ + public enum CalendarItemCheckInTypes + { + /// + /// Check-in is disabled for this event + /// + Disabled = 0, + + /// + /// Users can self check-in and check-out + /// + SelfCheckIn = 1, + + /// + /// Only the event creator, department admins, or group admins can check in/out users + /// + AdminOnly = 2 + } +} diff --git a/Core/Resgrid.Model/Repositories/ICalendarItemCheckInRepository.cs b/Core/Resgrid.Model/Repositories/ICalendarItemCheckInRepository.cs new file mode 100644 index 00000000..ab232d90 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/ICalendarItemCheckInRepository.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + public interface ICalendarItemCheckInRepository : IRepository + { + Task GetCheckInByCalendarItemAndUserAsync(int calendarItemId, string userId); + Task> GetCheckInsByCalendarItemIdAsync(int calendarItemId); + Task> GetCheckInsByDepartmentAndDateRangeAsync(int departmentId, DateTime start, DateTime end); + Task> GetCheckInsByUserAndDateRangeAsync(string userId, int departmentId, DateTime start, DateTime end); + } +} diff --git a/Core/Resgrid.Model/Services/IAuthorizationService.cs b/Core/Resgrid.Model/Services/IAuthorizationService.cs index 33883133..bc5b5499 100644 --- a/Core/Resgrid.Model/Services/IAuthorizationService.cs +++ b/Core/Resgrid.Model/Services/IAuthorizationService.cs @@ -342,5 +342,15 @@ public interface IAuthorizationService /// The department identifier of the resource being modified. /// true if the user is the managing member or a department admin; otherwise false. Task CanUserModifyDepartmentAsync(string userId, int departmentId); + + Task CanUserCheckInToCalendarEventAsync(string userId, int calendarItemId); + + Task CanUserAdminCheckInCalendarEventAsync(string userId, int calendarItemId, string targetUserId); + + Task CanUserEditCalendarCheckInAsync(string userId, string checkInId); + + Task CanUserDeleteCalendarCheckInAsync(string userId, string checkInId); + + Task CanUserViewCalendarCheckInsAsync(string userId, int calendarItemId); } } diff --git a/Core/Resgrid.Model/Services/ICalendarService.cs b/Core/Resgrid.Model/Services/ICalendarService.cs index 8a5f597e..14037915 100644 --- a/Core/Resgrid.Model/Services/ICalendarService.cs +++ b/Core/Resgrid.Model/Services/ICalendarService.cs @@ -75,6 +75,8 @@ Task MarkAsNotifiedAsync(int calendarItemId, Task NotifyNewCalendarItemAsync(CalendarItem calendarItem); + Task NotifyUsersAboutCalendarItemAsync(CalendarItem calendarItem, List userIds); + // ── External calendar sync ───────────────────────────────────────────────── /// @@ -104,5 +106,31 @@ Task RegenerateCalendarSyncAsync(int departmentId, string userId, /// returns null if the token is invalid, expired, or has been regenerated. /// Task<(int DepartmentId, string UserId)?> ValidateCalendarFeedTokenAsync(string encryptedToken); + + // ── Calendar Check-In Attendance ─────────────────────────────────────────── + + Task CheckInToEventAsync(int calendarItemId, string userId, string note, + string adminUserId = null, string latitude = null, string longitude = null, + CancellationToken cancellationToken = default(CancellationToken)); + + Task CheckOutFromEventAsync(int calendarItemId, string userId, + string note = null, string adminUserId = null, string latitude = null, string longitude = null, + CancellationToken cancellationToken = default(CancellationToken)); + + Task UpdateCheckInTimesAsync(string checkInId, DateTime checkInTime, + DateTime? checkOutTime, string checkInNote, string checkOutNote, + CancellationToken cancellationToken = default(CancellationToken)); + + Task GetCheckInByCalendarItemAndUserAsync(int calendarItemId, string userId); + + Task GetCheckInByIdAsync(string checkInId); + + Task> GetCheckInsByCalendarItemAsync(int calendarItemId); + + Task DeleteCheckInAsync(string checkInId, CancellationToken cancellationToken = default(CancellationToken)); + + Task> GetCheckInsByDepartmentDateRangeAsync(int departmentId, DateTime start, DateTime end); + + Task> GetCheckInsByUserDateRangeAsync(string userId, int departmentId, DateTime start, DateTime end); } } diff --git a/Core/Resgrid.Services/AuditService.cs b/Core/Resgrid.Services/AuditService.cs index f4901e3e..43d1bcb4 100644 --- a/Core/Resgrid.Services/AuditService.cs +++ b/Core/Resgrid.Services/AuditService.cs @@ -150,6 +150,17 @@ public string GetAuditLogTypeString(AuditLogTypes logType) return "UDF Field Removed"; case AuditLogTypes.UdfFieldValueSaved: return "UDF Field Values Saved"; + // Calendar Check-In Attendance + case AuditLogTypes.CalendarCheckInPerformed: + return "Calendar Event Check-In"; + case AuditLogTypes.CalendarCheckOutPerformed: + return "Calendar Event Check-Out"; + case AuditLogTypes.CalendarCheckInUpdated: + return "Calendar Check-In Times Updated"; + case AuditLogTypes.CalendarCheckInDeleted: + return "Calendar Check-In Deleted"; + case AuditLogTypes.CalendarAdminCheckInPerformed: + return "Admin Calendar Check-In"; } return $"Unknown ({logType})"; diff --git a/Core/Resgrid.Services/AuthorizationService.cs b/Core/Resgrid.Services/AuthorizationService.cs index a7fcf11d..e9285857 100644 --- a/Core/Resgrid.Services/AuthorizationService.cs +++ b/Core/Resgrid.Services/AuthorizationService.cs @@ -1526,5 +1526,168 @@ public async Task CanUserModifyDepartmentAsync(string userId, int departme return department.IsUserAnAdmin(userId); } + public async Task CanUserCheckInToCalendarEventAsync(string userId, int calendarItemId) + { + var department = await _departmentsService.GetDepartmentByUserIdAsync(userId); + var item = await _calendarService.GetCalendarItemByIdAsync(calendarItemId); + + if (department == null || item == null) + return false; + + if (item.DepartmentId != department.DepartmentId) + return false; + + // Check-in disabled for this event + if (item.CheckInType == (int)CalendarItemCheckInTypes.Disabled) + return false; + + // Self check-in mode: any department member can check themselves in + if (item.CheckInType == (int)CalendarItemCheckInTypes.SelfCheckIn) + return true; + + // Admin-only mode: only creator, dept admin, or group admin can perform check-ins + if (item.CheckInType == (int)CalendarItemCheckInTypes.AdminOnly) + { + if (department.IsUserAnAdmin(userId)) + return true; + + if (!string.IsNullOrWhiteSpace(item.CreatorUserId) && item.CreatorUserId == userId) + return true; + + var group = await _departmentGroupsService.GetGroupForUserAsync(userId, department.DepartmentId); + if (group != null && group.IsUserGroupAdmin(userId)) + return true; + + return false; + } + + return false; + } + + public async Task CanUserAdminCheckInCalendarEventAsync(string userId, int calendarItemId, string targetUserId) + { + var department = await _departmentsService.GetDepartmentByUserIdAsync(userId); + var item = await _calendarService.GetCalendarItemByIdAsync(calendarItemId); + + if (department == null || item == null) + return false; + + if (item.DepartmentId != department.DepartmentId) + return false; + + // Check-in disabled for this event + if (item.CheckInType == (int)CalendarItemCheckInTypes.Disabled) + return false; + + // Department admins can check in anyone + if (department.IsUserAnAdmin(userId)) + return true; + + // Calendar item creator can check in attendees + if (!string.IsNullOrWhiteSpace(item.CreatorUserId) && item.CreatorUserId == userId) + return true; + + // Group admins can check in users in their group or child groups + var adminGroup = await _departmentGroupsService.GetGroupForUserAsync(userId, department.DepartmentId); + if (adminGroup != null && adminGroup.IsUserGroupAdmin(userId)) + { + // Check if the target user is in the admin's group + if (adminGroup.IsUserInGroup(targetUserId)) + return true; + + // Check child groups + var childGroups = await _departmentGroupsService.GetAllChildDepartmentGroupsAsync(adminGroup.DepartmentGroupId); + if (childGroups != null) + { + foreach (var childGroup in childGroups) + { + if (childGroup.IsUserInGroup(targetUserId)) + return true; + } + } + } + + return false; + } + + public async Task CanUserEditCalendarCheckInAsync(string userId, string checkInId) + { + var department = await _departmentsService.GetDepartmentByUserIdAsync(userId); + var checkIn = await _calendarService.GetCheckInByIdAsync(checkInId); + + if (department == null || checkIn == null) + return false; + + if (checkIn.DepartmentId != department.DepartmentId) + return false; + + if (checkIn.UserId == userId) + return true; + + if (department.IsUserAnAdmin(userId)) + return true; + + // Calendar item creator can edit check-ins + var item = await _calendarService.GetCalendarItemByIdAsync(checkIn.CalendarItemId); + if (item != null && !string.IsNullOrWhiteSpace(item.CreatorUserId) && item.CreatorUserId == userId) + return true; + + // Group admins can edit check-ins for their group members + var adminGroup = await _departmentGroupsService.GetGroupForUserAsync(userId, department.DepartmentId); + if (adminGroup != null && adminGroup.IsUserGroupAdmin(userId)) + { + if (adminGroup.IsUserInGroup(checkIn.UserId)) + return true; + + var childGroups = await _departmentGroupsService.GetAllChildDepartmentGroupsAsync(adminGroup.DepartmentGroupId); + if (childGroups != null) + { + foreach (var childGroup in childGroups) + { + if (childGroup.IsUserInGroup(checkIn.UserId)) + return true; + } + } + } + + return false; + } + + public async Task CanUserDeleteCalendarCheckInAsync(string userId, string checkInId) + { + var department = await _departmentsService.GetDepartmentByUserIdAsync(userId); + var checkIn = await _calendarService.GetCheckInByIdAsync(checkInId); + + if (department == null || checkIn == null) + return false; + + if (checkIn.DepartmentId != department.DepartmentId) + return false; + + if (department.IsUserAnAdmin(userId)) + return true; + + // Calendar item creator can delete check-ins + var item = await _calendarService.GetCalendarItemByIdAsync(checkIn.CalendarItemId); + if (item != null && !string.IsNullOrWhiteSpace(item.CreatorUserId) && item.CreatorUserId == userId) + return true; + + return false; + } + + public async Task CanUserViewCalendarCheckInsAsync(string userId, int calendarItemId) + { + var department = await _departmentsService.GetDepartmentByUserIdAsync(userId); + var item = await _calendarService.GetCalendarItemByIdAsync(calendarItemId); + + if (department == null || item == null) + return false; + + if (item.DepartmentId != department.DepartmentId) + return false; + + return true; + } + } } diff --git a/Core/Resgrid.Services/CalendarService.cs b/Core/Resgrid.Services/CalendarService.cs index 726699ff..22c8d479 100644 --- a/Core/Resgrid.Services/CalendarService.cs +++ b/Core/Resgrid.Services/CalendarService.cs @@ -24,11 +24,12 @@ public class CalendarService : ICalendarService private readonly IDepartmentGroupsService _departmentGroupsService; private readonly IDepartmentSettingsService _departmentSettingsService; private readonly IEncryptionService _encryptionService; + private readonly ICalendarItemCheckInRepository _calendarItemCheckInRepository; public CalendarService(ICalendarItemsRepository calendarItemRepository, ICalendarItemTypeRepository calendarItemTypeRepository, ICalendarItemAttendeeRepository calendarItemAttendeeRepository, IDepartmentsService departmentsService, ICommunicationService communicationService, IUserProfileService userProfileService, IDepartmentGroupsService departmentGroupsService, IDepartmentSettingsService departmentSettingsService, - IEncryptionService encryptionService) + IEncryptionService encryptionService, ICalendarItemCheckInRepository calendarItemCheckInRepository) { _calendarItemRepository = calendarItemRepository; _calendarItemTypeRepository = calendarItemTypeRepository; @@ -39,6 +40,7 @@ public CalendarService(ICalendarItemsRepository calendarItemRepository, ICalenda _departmentGroupsService = departmentGroupsService; _departmentSettingsService = departmentSettingsService; _encryptionService = encryptionService; + _calendarItemCheckInRepository = calendarItemCheckInRepository; } public async Task> GetAllCalendarItemsForDepartmentAsync(int departmentId) @@ -541,6 +543,36 @@ public async Task NotifyNewCalendarItemAsync(CalendarItem calendarItem) return false; } + public async Task NotifyUsersAboutCalendarItemAsync(CalendarItem calendarItem, List userIds) + { + if (calendarItem == null || userIds == null || !userIds.Any()) + return false; + + var profiles = await _userProfileService.GetAllProfilesForDepartmentAsync(calendarItem.DepartmentId); + var departmentNumber = await _departmentSettingsService.GetTextToCallNumberForDepartmentAsync(calendarItem.DepartmentId); + var department = await _departmentsService.GetDepartmentByIdAsync(calendarItem.DepartmentId, false); + + var adjustedDateTime = calendarItem.Start.TimeConverter(department); + var title = $"New: {calendarItem.Title}"; + + var message = String.IsNullOrWhiteSpace(calendarItem.Location) + ? $"on {adjustedDateTime.ToShortDateString()} - {adjustedDateTime.ToShortTimeString()}" + : $"on {adjustedDateTime.ToShortDateString()} - {adjustedDateTime.ToShortTimeString()} at {calendarItem.Location}"; + + if (ConfigHelper.CanTransmit(department.DepartmentId)) + { + foreach (var userId in userIds) + { + if (profiles.ContainsKey(userId)) + await _communicationService.SendCalendarAsync(userId, calendarItem.DepartmentId, message, departmentNumber, title, profiles[userId], department); + else + await _communicationService.SendCalendarAsync(userId, calendarItem.DepartmentId, message, departmentNumber, title, null, department); + } + } + + return true; + } + public async Task> GetCalendarItemsToNotifyAsync(DateTime timestamp) { List itemsToNotify = new List(); @@ -715,5 +747,112 @@ private string BuildEncryptedToken(int departmentId, string userId, string syncG .Replace('/', '_') .TrimEnd('='); } + + #region Calendar Check-In Attendance + + public async Task CheckInToEventAsync(int calendarItemId, string userId, string note, + string adminUserId = null, string latitude = null, string longitude = null, + CancellationToken cancellationToken = default(CancellationToken)) + { + var existing = await _calendarItemCheckInRepository.GetCheckInByCalendarItemAndUserAsync(calendarItemId, userId); + if (existing != null) + return existing; + + var calendarItem = await _calendarItemRepository.GetByIdAsync(calendarItemId); + if (calendarItem == null) + return null; + + var checkIn = new CalendarItemCheckIn + { + CalendarItemCheckInId = Guid.NewGuid().ToString(), + DepartmentId = calendarItem.DepartmentId, + CalendarItemId = calendarItemId, + UserId = userId, + CheckInTime = DateTime.UtcNow, + CheckInByUserId = adminUserId, + IsManualOverride = false, + CheckInNote = note, + CheckInLatitude = latitude, + CheckInLongitude = longitude, + Timestamp = DateTime.UtcNow + }; + + var saved = await _calendarItemCheckInRepository.SaveOrUpdateAsync(checkIn, cancellationToken); + return saved; + } + + public async Task CheckOutFromEventAsync(int calendarItemId, string userId, + string note = null, string adminUserId = null, string latitude = null, string longitude = null, + CancellationToken cancellationToken = default(CancellationToken)) + { + var existing = await _calendarItemCheckInRepository.GetCheckInByCalendarItemAndUserAsync(calendarItemId, userId); + if (existing == null) + return null; + + existing.CheckOutTime = DateTime.UtcNow; + existing.CheckOutByUserId = adminUserId; + existing.CheckOutNote = note; + existing.CheckOutLatitude = latitude; + existing.CheckOutLongitude = longitude; + var saved = await _calendarItemCheckInRepository.SaveOrUpdateAsync(existing, cancellationToken); + return saved; + } + + public async Task UpdateCheckInTimesAsync(string checkInId, DateTime checkInTime, + DateTime? checkOutTime, string checkInNote, string checkOutNote, + CancellationToken cancellationToken = default(CancellationToken)) + { + var existing = await _calendarItemCheckInRepository.GetByIdAsync(checkInId); + if (existing == null) + return null; + + existing.CheckInTime = checkInTime; + existing.CheckOutTime = checkOutTime; + existing.IsManualOverride = true; + existing.CheckInNote = checkInNote; + existing.CheckOutNote = checkOutNote; + + var saved = await _calendarItemCheckInRepository.SaveOrUpdateAsync(existing, cancellationToken); + return saved; + } + + public async Task GetCheckInByCalendarItemAndUserAsync(int calendarItemId, string userId) + { + return await _calendarItemCheckInRepository.GetCheckInByCalendarItemAndUserAsync(calendarItemId, userId); + } + + public async Task GetCheckInByIdAsync(string checkInId) + { + return await _calendarItemCheckInRepository.GetByIdAsync(checkInId); + } + + public async Task> GetCheckInsByCalendarItemAsync(int calendarItemId) + { + var items = await _calendarItemCheckInRepository.GetCheckInsByCalendarItemIdAsync(calendarItemId); + return items?.ToList() ?? new List(); + } + + public async Task DeleteCheckInAsync(string checkInId, CancellationToken cancellationToken = default(CancellationToken)) + { + var checkIn = await _calendarItemCheckInRepository.GetByIdAsync(checkInId); + if (checkIn == null) + return false; + + return await _calendarItemCheckInRepository.DeleteAsync(checkIn, cancellationToken); + } + + public async Task> GetCheckInsByDepartmentDateRangeAsync(int departmentId, DateTime start, DateTime end) + { + var items = await _calendarItemCheckInRepository.GetCheckInsByDepartmentAndDateRangeAsync(departmentId, start, end); + return items?.ToList() ?? new List(); + } + + public async Task> GetCheckInsByUserDateRangeAsync(string userId, int departmentId, DateTime start, DateTime end) + { + var items = await _calendarItemCheckInRepository.GetCheckInsByUserAndDateRangeAsync(userId, departmentId, start, end); + return items?.ToList() ?? new List(); + } + + #endregion Calendar Check-In Attendance } } diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0057_AddingCalendarItemCheckIns.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0057_AddingCalendarItemCheckIns.cs new file mode 100644 index 00000000..a7173798 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0057_AddingCalendarItemCheckIns.cs @@ -0,0 +1,63 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(57)] + public class M0057_AddingCalendarItemCheckIns : Migration + { + public override void Up() + { + Create.Table("CalendarItemCheckIns") + .WithColumn("CalendarItemCheckInId").AsString(128).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("CalendarItemId").AsInt32().NotNullable() + .WithColumn("UserId").AsString(128).NotNullable() + .WithColumn("CheckInTime").AsDateTime2().NotNullable() + .WithColumn("CheckOutTime").AsDateTime2().Nullable() + .WithColumn("CheckInByUserId").AsString(128).Nullable() + .WithColumn("CheckOutByUserId").AsString(128).Nullable() + .WithColumn("IsManualOverride").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("CheckInNote").AsString(1000).Nullable() + .WithColumn("CheckOutNote").AsString(1000).Nullable() + .WithColumn("CheckInLatitude").AsString(50).Nullable() + .WithColumn("CheckInLongitude").AsString(50).Nullable() + .WithColumn("CheckOutLatitude").AsString(50).Nullable() + .WithColumn("CheckOutLongitude").AsString(50).Nullable() + .WithColumn("Timestamp").AsDateTime2().NotNullable(); + + Create.ForeignKey("FK_CalendarItemCheckIns_Departments") + .FromTable("CalendarItemCheckIns").ForeignColumn("DepartmentId") + .ToTable("Departments").PrimaryColumn("DepartmentId"); + + Create.ForeignKey("FK_CalendarItemCheckIns_CalendarItems") + .FromTable("CalendarItemCheckIns").ForeignColumn("CalendarItemId") + .ToTable("CalendarItems").PrimaryColumn("CalendarItemId"); + + Create.Index("IX_CalendarItemCheckIns_CalendarItemId") + .OnTable("CalendarItemCheckIns") + .OnColumn("CalendarItemId"); + + Create.Index("IX_CalendarItemCheckIns_DepartmentId_UserId") + .OnTable("CalendarItemCheckIns") + .OnColumn("DepartmentId").Ascending() + .OnColumn("UserId").Ascending(); + + Create.UniqueConstraint("UQ_CalendarItemCheckIns_CalItem_User") + .OnTable("CalendarItemCheckIns") + .Columns("CalendarItemId", "UserId"); + + // Add CheckInType to CalendarItems table + Alter.Table("CalendarItems") + .AddColumn("CheckInType").AsInt32().NotNullable().WithDefaultValue(0); + } + + public override void Down() + { + Delete.Column("CheckInType").FromTable("CalendarItems"); + + Delete.ForeignKey("FK_CalendarItemCheckIns_CalendarItems").OnTable("CalendarItemCheckIns"); + Delete.ForeignKey("FK_CalendarItemCheckIns_Departments").OnTable("CalendarItemCheckIns"); + Delete.Table("CalendarItemCheckIns"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0057_AddingCalendarItemCheckInsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0057_AddingCalendarItemCheckInsPg.cs new file mode 100644 index 00000000..7189ceab --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0057_AddingCalendarItemCheckInsPg.cs @@ -0,0 +1,63 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(57)] + public class M0057_AddingCalendarItemCheckInsPg : Migration + { + public override void Up() + { + Create.Table("calendaritemcheckins") + .WithColumn("calendaritemcheckinid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("calendaritemid").AsInt32().NotNullable() + .WithColumn("userid").AsCustom("citext").NotNullable() + .WithColumn("checkintime").AsDateTime().NotNullable() + .WithColumn("checkouttime").AsDateTime().Nullable() + .WithColumn("checkinbyuserid").AsCustom("citext").Nullable() + .WithColumn("checkoutbyuserid").AsCustom("citext").Nullable() + .WithColumn("ismanualoverride").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("checkinnote").AsCustom("citext").Nullable() + .WithColumn("checkoutnote").AsCustom("citext").Nullable() + .WithColumn("checkinlatitude").AsCustom("citext").Nullable() + .WithColumn("checkinlongitude").AsCustom("citext").Nullable() + .WithColumn("checkoutlatitude").AsCustom("citext").Nullable() + .WithColumn("checkoutlongitude").AsCustom("citext").Nullable() + .WithColumn("timestamp").AsDateTime().NotNullable(); + + Create.ForeignKey("fk_calendaritemcheckins_departments") + .FromTable("calendaritemcheckins").ForeignColumn("departmentid") + .ToTable("departments").PrimaryColumn("departmentid"); + + Create.ForeignKey("fk_calendaritemcheckins_calendaritems") + .FromTable("calendaritemcheckins").ForeignColumn("calendaritemid") + .ToTable("calendaritems").PrimaryColumn("calendaritemid"); + + Create.Index("ix_calendaritemcheckins_calendaritemid") + .OnTable("calendaritemcheckins") + .OnColumn("calendaritemid"); + + Create.Index("ix_calendaritemcheckins_departmentid_userid") + .OnTable("calendaritemcheckins") + .OnColumn("departmentid").Ascending() + .OnColumn("userid").Ascending(); + + Create.UniqueConstraint("uq_calendaritemcheckins_calitem_user") + .OnTable("calendaritemcheckins") + .Columns("calendaritemid", "userid"); + + // Add checkintype to calendaritems table + Alter.Table("calendaritems") + .AddColumn("checkintype").AsInt32().NotNullable().WithDefaultValue(0); + } + + public override void Down() + { + Delete.Column("checkintype").FromTable("calendaritems"); + + Delete.ForeignKey("fk_calendaritemcheckins_calendaritems").OnTable("calendaritemcheckins"); + Delete.ForeignKey("fk_calendaritemcheckins_departments").OnTable("calendaritemcheckins"); + Delete.Table("calendaritemcheckins"); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/CalendarItemCheckInRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/CalendarItemCheckInRepository.cs new file mode 100644 index 00000000..3d586115 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/CalendarItemCheckInRepository.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Queries.Calendar; + +namespace Resgrid.Repositories.DataRepository +{ + public class CalendarItemCheckInRepository : RepositoryBase, ICalendarItemCheckInRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly SqlConfiguration _sqlConfiguration; + private readonly IQueryFactory _queryFactory; + private readonly IUnitOfWork _unitOfWork; + + public CalendarItemCheckInRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, + IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _sqlConfiguration = sqlConfiguration; + _queryFactory = queryFactory; + _unitOfWork = unitOfWork; + } + + public async Task GetCheckInByCalendarItemAndUserAsync(int calendarItemId, string userId) + { + try + { + var selectFunction = new Func>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("CalendarItemId", calendarItemId); + dynamicParameters.Add("UserId", userId); + + var query = _queryFactory.GetQuery(); + + return await x.QueryFirstOrDefaultAsync(sql: query, + param: dynamicParameters, + transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + + public async Task> GetCheckInsByCalendarItemIdAsync(int calendarItemId) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("CalendarItemId", calendarItemId); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, + param: dynamicParameters, + transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + + public async Task> GetCheckInsByDepartmentAndDateRangeAsync(int departmentId, DateTime start, DateTime end) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("DepartmentId", departmentId); + dynamicParameters.Add("StartDate", start); + dynamicParameters.Add("EndDate", end); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, + param: dynamicParameters, + transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + + public async Task> GetCheckInsByUserAndDateRangeAsync(string userId, int departmentId, DateTime start, DateTime end) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("UserId", userId); + dynamicParameters.Add("DepartmentId", departmentId); + dynamicParameters.Add("StartDate", start); + dynamicParameters.Add("EndDate", end); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, + param: dynamicParameters, + transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs index 70373f99..e086d2ee 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs @@ -523,6 +523,14 @@ protected SqlConfiguration() { } public string SelectCheckInRecordsByDepartmentIdAndDateRangeQuery { get; set; } #endregion CheckIns + #region CalendarItemCheckIns + public string CalendarItemCheckInsTableName { get; set; } + public string SelectCalendarItemCheckInByItemAndUserQuery { get; set; } + public string SelectCalendarItemCheckInsByItemIdQuery { get; set; } + public string SelectCalendarItemCheckInsByDeptDateRangeQuery { get; set; } + public string SelectCalendarItemCheckInsByUserDateRangeQuery { get; set; } + #endregion CalendarItemCheckIns + // Identity #region Table Names diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs index 1488dbe0..220ee03f 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs @@ -190,6 +190,9 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // Calendar Check-In Repositories + builder.RegisterType().As().InstancePerLifetimeScope(); + // SSO Repositories builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs index 5321df74..48a8e786 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -189,6 +189,9 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // Calendar Check-In Repositories + builder.RegisterType().As().InstancePerLifetimeScope(); + // SSO Repositories builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs index 3d97b768..d92d974c 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs @@ -189,6 +189,9 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // Calendar Check-In Repositories + builder.RegisterType().As().InstancePerLifetimeScope(); + // SSO Repositories builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs index 0540c911..90b38dbb 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs @@ -189,6 +189,9 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // Calendar Check-In Repositories + builder.RegisterType().As().InstancePerLifetimeScope(); + // SSO Repositories builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Calendar/SelectCalendarItemCheckInByItemAndUserQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calendar/SelectCalendarItemCheckInByItemAndUserQuery.cs new file mode 100644 index 00000000..227a6699 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calendar/SelectCalendarItemCheckInByItemAndUserQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.Calendar +{ + public class SelectCalendarItemCheckInByItemAndUserQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectCalendarItemCheckInByItemAndUserQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectCalendarItemCheckInByItemAndUserQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.CalendarItemCheckInsTableName, + _sqlConfiguration.ParameterNotation, + new string[] { "%CALITEMID%", "%USERID%" }, + new string[] { "CalendarItemId", "UserId" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Calendar/SelectCalendarItemCheckInsByDeptDateRangeQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calendar/SelectCalendarItemCheckInsByDeptDateRangeQuery.cs new file mode 100644 index 00000000..67851b83 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calendar/SelectCalendarItemCheckInsByDeptDateRangeQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.Calendar +{ + public class SelectCalendarItemCheckInsByDeptDateRangeQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectCalendarItemCheckInsByDeptDateRangeQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectCalendarItemCheckInsByDeptDateRangeQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.CalendarItemCheckInsTableName, + _sqlConfiguration.ParameterNotation, + new string[] { "%DID%", "%STARTDATE%", "%ENDDATE%" }, + new string[] { "DepartmentId", "StartDate", "EndDate" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Calendar/SelectCalendarItemCheckInsByItemIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calendar/SelectCalendarItemCheckInsByItemIdQuery.cs new file mode 100644 index 00000000..1593dcea --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calendar/SelectCalendarItemCheckInsByItemIdQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.Calendar +{ + public class SelectCalendarItemCheckInsByItemIdQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectCalendarItemCheckInsByItemIdQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectCalendarItemCheckInsByItemIdQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.CalendarItemCheckInsTableName, + _sqlConfiguration.ParameterNotation, + new string[] { "%CALITEMID%" }, + new string[] { "CalendarItemId" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Calendar/SelectCalendarItemCheckInsByUserDateRangeQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calendar/SelectCalendarItemCheckInsByUserDateRangeQuery.cs new file mode 100644 index 00000000..da0d1253 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calendar/SelectCalendarItemCheckInsByUserDateRangeQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.Calendar +{ + public class SelectCalendarItemCheckInsByUserDateRangeQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectCalendarItemCheckInsByUserDateRangeQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectCalendarItemCheckInsByUserDateRangeQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.CalendarItemCheckInsTableName, + _sqlConfiguration.ParameterNotation, + new string[] { "%USERID%", "%DID%", "%STARTDATE%", "%ENDDATE%" }, + new string[] { "UserId", "DepartmentId", "StartDate", "EndDate" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs index 1a90cdeb..924646e2 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs @@ -1615,6 +1615,32 @@ ORDER BY Timestamp DESC ORDER BY Timestamp DESC"; #endregion CheckIns + #region CalendarItemCheckIns + CalendarItemCheckInsTableName = "CalendarItemCheckIns"; + SelectCalendarItemCheckInByItemAndUserQuery = @" + SELECT * + FROM %SCHEMA%.%TABLENAME% + WHERE CalendarItemId = %CALITEMID% AND UserId = %USERID% + LIMIT 1"; + SelectCalendarItemCheckInsByItemIdQuery = @" + SELECT * + FROM %SCHEMA%.%TABLENAME% + WHERE CalendarItemId = %CALITEMID% + ORDER BY CheckInTime DESC"; + SelectCalendarItemCheckInsByDeptDateRangeQuery = @" + SELECT * + FROM %SCHEMA%.%TABLENAME% + WHERE DepartmentId = %DID% + AND CheckInTime >= %STARTDATE% AND CheckInTime <= %ENDDATE% + ORDER BY CheckInTime DESC"; + SelectCalendarItemCheckInsByUserDateRangeQuery = @" + SELECT * + FROM %SCHEMA%.%TABLENAME% + WHERE UserId = %USERID% AND DepartmentId = %DID% + AND CheckInTime >= %STARTDATE% AND CheckInTime <= %ENDDATE% + ORDER BY CheckInTime DESC"; + #endregion CalendarItemCheckIns + #region User Defined Fields UdfDefinitionsTableName = "UdfDefinitions"; UdfFieldsTableName = "UdfFields"; diff --git a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs index 0fde83fd..649c4878 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs @@ -1577,6 +1577,31 @@ SELECT TOP 1 * ORDER BY [Timestamp] DESC"; #endregion CheckIns + #region CalendarItemCheckIns + CalendarItemCheckInsTableName = "CalendarItemCheckIns"; + SelectCalendarItemCheckInByItemAndUserQuery = @" + SELECT TOP 1 * + FROM %SCHEMA%.%TABLENAME% + WHERE [CalendarItemId] = %CALITEMID% AND [UserId] = %USERID%"; + SelectCalendarItemCheckInsByItemIdQuery = @" + SELECT * + FROM %SCHEMA%.%TABLENAME% + WHERE [CalendarItemId] = %CALITEMID% + ORDER BY [CheckInTime] DESC"; + SelectCalendarItemCheckInsByDeptDateRangeQuery = @" + SELECT * + FROM %SCHEMA%.%TABLENAME% + WHERE [DepartmentId] = %DID% + AND [CheckInTime] >= %STARTDATE% AND [CheckInTime] <= %ENDDATE% + ORDER BY [CheckInTime] DESC"; + SelectCalendarItemCheckInsByUserDateRangeQuery = @" + SELECT * + FROM %SCHEMA%.%TABLENAME% + WHERE [UserId] = %USERID% AND [DepartmentId] = %DID% + AND [CheckInTime] >= %STARTDATE% AND [CheckInTime] <= %ENDDATE% + ORDER BY [CheckInTime] DESC"; + #endregion CalendarItemCheckIns + #region User Defined Fields UdfDefinitionsTableName = "UdfDefinitions"; UdfFieldsTableName = "UdfFields"; diff --git a/Tests/Resgrid.Tests/Services/CalendarServiceCheckInTests.cs b/Tests/Resgrid.Tests/Services/CalendarServiceCheckInTests.cs new file mode 100644 index 00000000..354713e1 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/CalendarServiceCheckInTests.cs @@ -0,0 +1,442 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class CalendarServiceCheckInTests + { + private Mock _calendarItemRepo; + private Mock _calendarItemTypeRepo; + private Mock _attendeeRepo; + private Mock _departmentsService; + private Mock _communicationService; + private Mock _userProfileService; + private Mock _departmentGroupsService; + private Mock _departmentSettingsService; + private Mock _encryptionService; + private Mock _checkInRepo; + private CalendarService _service; + + [SetUp] + public void SetUp() + { + _calendarItemRepo = new Mock(); + _calendarItemTypeRepo = new Mock(); + _attendeeRepo = new Mock(); + _departmentsService = new Mock(); + _communicationService = new Mock(); + _userProfileService = new Mock(); + _departmentGroupsService = new Mock(); + _departmentSettingsService = new Mock(); + _encryptionService = new Mock(); + _checkInRepo = new Mock(); + + _service = new CalendarService( + _calendarItemRepo.Object, + _calendarItemTypeRepo.Object, + _attendeeRepo.Object, + _departmentsService.Object, + _communicationService.Object, + _userProfileService.Object, + _departmentGroupsService.Object, + _departmentSettingsService.Object, + _encryptionService.Object, + _checkInRepo.Object); + } + + #region Service Logic Tests + + [Test] + public async Task CheckInToEvent_creates_new_record_when_none_exists() + { + _checkInRepo.Setup(x => x.GetCheckInByCalendarItemAndUserAsync(1, "user1")) + .ReturnsAsync((CalendarItemCheckIn)null); + _calendarItemRepo.Setup(x => x.GetByIdAsync(It.IsAny())) + .ReturnsAsync(new CalendarItem { CalendarItemId = 1, DepartmentId = 10, CheckInType = (int)CalendarItemCheckInTypes.SelfCheckIn }); + _checkInRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((CalendarItemCheckIn c, CancellationToken ct, bool f) => c); + + var result = await _service.CheckInToEventAsync(1, "user1", "test note"); + + result.Should().NotBeNull(); + result.CalendarItemId.Should().Be(1); + result.UserId.Should().Be("user1"); + result.CheckInNote.Should().Be("test note"); + result.DepartmentId.Should().Be(10); + result.CalendarItemCheckInId.Should().NotBeNullOrEmpty(); + } + + [Test] + public async Task CheckInToEvent_returns_existing_when_already_checked_in() + { + var existing = new CalendarItemCheckIn + { + CalendarItemCheckInId = "existing-id", + CalendarItemId = 1, + UserId = "user1", + CheckInTime = DateTime.UtcNow.AddHours(-1) + }; + _checkInRepo.Setup(x => x.GetCheckInByCalendarItemAndUserAsync(1, "user1")) + .ReturnsAsync(existing); + + var result = await _service.CheckInToEventAsync(1, "user1", "test note"); + + result.Should().BeSameAs(existing); + _checkInRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task CheckOutFromEvent_sets_checkout_time_and_note() + { + var existing = new CalendarItemCheckIn + { + CalendarItemCheckInId = "id1", + CalendarItemId = 1, + UserId = "user1", + CheckInTime = DateTime.UtcNow.AddHours(-2) + }; + _checkInRepo.Setup(x => x.GetCheckInByCalendarItemAndUserAsync(1, "user1")) + .ReturnsAsync(existing); + _checkInRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((CalendarItemCheckIn c, CancellationToken ct, bool f) => c); + + var result = await _service.CheckOutFromEventAsync(1, "user1", "checkout note"); + + result.Should().NotBeNull(); + result.CheckOutTime.Should().NotBeNull(); + result.CheckOutTime.Value.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + result.CheckOutNote.Should().Be("checkout note"); + } + + [Test] + public async Task CheckOutFromEvent_returns_null_when_no_checkin() + { + _checkInRepo.Setup(x => x.GetCheckInByCalendarItemAndUserAsync(1, "user1")) + .ReturnsAsync((CalendarItemCheckIn)null); + + var result = await _service.CheckOutFromEventAsync(1, "user1"); + + result.Should().BeNull(); + } + + [Test] + public async Task UpdateCheckInTimes_sets_manual_override_flag_and_both_notes() + { + var existing = new CalendarItemCheckIn + { + CalendarItemCheckInId = "id1", + CalendarItemId = 1, + UserId = "user1", + CheckInTime = DateTime.UtcNow.AddHours(-2), + IsManualOverride = false + }; + _checkInRepo.Setup(x => x.GetByIdAsync(It.IsAny())) + .ReturnsAsync(existing); + _checkInRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((CalendarItemCheckIn c, CancellationToken ct, bool f) => c); + + var newCheckIn = DateTime.UtcNow.AddHours(-3); + var newCheckOut = DateTime.UtcNow; + var result = await _service.UpdateCheckInTimesAsync("id1", newCheckIn, newCheckOut, "in note", "out note"); + + result.Should().NotBeNull(); + result.IsManualOverride.Should().BeTrue(); + result.CheckInTime.Should().Be(newCheckIn); + result.CheckOutTime.Should().Be(newCheckOut); + result.CheckInNote.Should().Be("in note"); + result.CheckOutNote.Should().Be("out note"); + } + + [Test] + public async Task AdminCheckIn_sets_CheckInByUserId() + { + _checkInRepo.Setup(x => x.GetCheckInByCalendarItemAndUserAsync(1, "user1")) + .ReturnsAsync((CalendarItemCheckIn)null); + _calendarItemRepo.Setup(x => x.GetByIdAsync(It.IsAny())) + .ReturnsAsync(new CalendarItem { CalendarItemId = 1, DepartmentId = 10, CheckInType = (int)CalendarItemCheckInTypes.SelfCheckIn }); + _checkInRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((CalendarItemCheckIn c, CancellationToken ct, bool f) => c); + + var result = await _service.CheckInToEventAsync(1, "user1", "admin note", "admin1"); + + result.Should().NotBeNull(); + result.CheckInByUserId.Should().Be("admin1"); + result.UserId.Should().Be("user1"); + } + + [Test] + public async Task CheckOutFromEvent_sets_CheckOutByUserId_when_admin() + { + var existing = new CalendarItemCheckIn + { + CalendarItemCheckInId = "id1", + CalendarItemId = 1, + UserId = "user1", + CheckInTime = DateTime.UtcNow.AddHours(-2) + }; + _checkInRepo.Setup(x => x.GetCheckInByCalendarItemAndUserAsync(1, "user1")) + .ReturnsAsync(existing); + _checkInRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((CalendarItemCheckIn c, CancellationToken ct, bool f) => c); + + var result = await _service.CheckOutFromEventAsync(1, "user1", "note", "admin1"); + + result.Should().NotBeNull(); + result.CheckOutByUserId.Should().Be("admin1"); + } + + [Test] + public async Task CheckInToEvent_stores_coordinates() + { + _checkInRepo.Setup(x => x.GetCheckInByCalendarItemAndUserAsync(1, "user1")) + .ReturnsAsync((CalendarItemCheckIn)null); + _calendarItemRepo.Setup(x => x.GetByIdAsync(It.IsAny())) + .ReturnsAsync(new CalendarItem { CalendarItemId = 1, DepartmentId = 10, CheckInType = (int)CalendarItemCheckInTypes.SelfCheckIn }); + _checkInRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((CalendarItemCheckIn c, CancellationToken ct, bool f) => c); + + var result = await _service.CheckInToEventAsync(1, "user1", "note", null, "33.4484", "-112.0740"); + + result.Should().NotBeNull(); + result.CheckInLatitude.Should().Be("33.4484"); + result.CheckInLongitude.Should().Be("-112.0740"); + } + + [Test] + public void GetDuration_returns_correct_timespan() + { + var checkIn = new CalendarItemCheckIn + { + CheckInTime = new DateTime(2024, 1, 1, 10, 0, 0), + CheckOutTime = new DateTime(2024, 1, 1, 12, 30, 0) + }; + + var duration = checkIn.GetDuration(); + + duration.Should().NotBeNull(); + duration.Value.TotalHours.Should().Be(2.5); + } + + [Test] + public void GetDuration_returns_null_when_not_checked_out() + { + var checkIn = new CalendarItemCheckIn + { + CheckInTime = new DateTime(2024, 1, 1, 10, 0, 0), + CheckOutTime = null + }; + + var duration = checkIn.GetDuration(); + + duration.Should().BeNull(); + } + + [Test] + public async Task DeleteCheckIn_removes_record() + { + var existing = new CalendarItemCheckIn + { + CalendarItemCheckInId = "id1", + CalendarItemId = 1, + UserId = "user1" + }; + _checkInRepo.Setup(x => x.GetByIdAsync(It.IsAny())) + .ReturnsAsync(existing); + _checkInRepo.Setup(x => x.DeleteAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + var result = await _service.DeleteCheckInAsync("id1"); + + result.Should().BeTrue(); + _checkInRepo.Verify(x => x.DeleteAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + #endregion Service Logic Tests + + #region Authorization Tests + + private Mock _authDeptService; + private Mock _authCalService; + private Mock _authGroupService; + private AuthorizationService _authService; + + private void SetupAuthService() + { + _authDeptService = new Mock(); + _authCalService = new Mock(); + _authGroupService = new Mock(); + + _authService = new AuthorizationService( + _authDeptService.Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + _authGroupService.Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + _authCalService.Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object); + } + + [Test] + public async Task CanUserCheckIn_returns_true_when_same_department() + { + SetupAuthService(); + var dept = new Department { DepartmentId = 10, ManagingUserId = "admin1" }; + _authDeptService.Setup(x => x.GetDepartmentByUserIdAsync("user1", It.IsAny())).ReturnsAsync(dept); + _authCalService.Setup(x => x.GetCalendarItemByIdAsync(1)) + .ReturnsAsync(new CalendarItem { CalendarItemId = 1, DepartmentId = 10, CheckInType = (int)CalendarItemCheckInTypes.SelfCheckIn }); + + var result = await _authService.CanUserCheckInToCalendarEventAsync("user1", 1); + + result.Should().BeTrue(); + } + + [Test] + public async Task CanUserCheckIn_returns_false_when_different_department() + { + SetupAuthService(); + var dept = new Department { DepartmentId = 20, ManagingUserId = "admin2" }; + _authDeptService.Setup(x => x.GetDepartmentByUserIdAsync("user1", It.IsAny())).ReturnsAsync(dept); + _authCalService.Setup(x => x.GetCalendarItemByIdAsync(1)) + .ReturnsAsync(new CalendarItem { CalendarItemId = 1, DepartmentId = 10, CheckInType = (int)CalendarItemCheckInTypes.SelfCheckIn }); + + // User is in dept 20, event is in dept 10 → should fail + var result = await _authService.CanUserCheckInToCalendarEventAsync("user1", 1); + + result.Should().BeFalse(); + } + + [Test] + public async Task CanUserAdminCheckIn_returns_true_when_department_admin() + { + SetupAuthService(); + var dept = new Department { DepartmentId = 10, ManagingUserId = "admin1" }; + _authDeptService.Setup(x => x.GetDepartmentByUserIdAsync("admin1", It.IsAny())).ReturnsAsync(dept); + _authCalService.Setup(x => x.GetCalendarItemByIdAsync(1)) + .ReturnsAsync(new CalendarItem { CalendarItemId = 1, DepartmentId = 10, CheckInType = (int)CalendarItemCheckInTypes.SelfCheckIn }); + + var result = await _authService.CanUserAdminCheckInCalendarEventAsync("admin1", 1, "user1"); + + result.Should().BeTrue(); + } + + [Test] + public async Task CanUserAdminCheckIn_returns_true_when_event_creator() + { + SetupAuthService(); + var dept = new Department { DepartmentId = 10, ManagingUserId = "someoneelse" }; + _authDeptService.Setup(x => x.GetDepartmentByUserIdAsync("creator1", It.IsAny())).ReturnsAsync(dept); + _authCalService.Setup(x => x.GetCalendarItemByIdAsync(1)) + .ReturnsAsync(new CalendarItem { CalendarItemId = 1, DepartmentId = 10, CheckInType = (int)CalendarItemCheckInTypes.AdminOnly, CreatorUserId = "creator1" }); + _authGroupService.Setup(x => x.GetGroupForUserAsync("creator1", 10)) + .ReturnsAsync((DepartmentGroup)null); + + var result = await _authService.CanUserAdminCheckInCalendarEventAsync("creator1", 1, "user1"); + + result.Should().BeTrue(); + } + + [Test] + public async Task CanUserAdminCheckIn_returns_false_when_not_admin_nor_creator() + { + SetupAuthService(); + var dept = new Department { DepartmentId = 10, ManagingUserId = "admin1" }; + _authDeptService.Setup(x => x.GetDepartmentByUserIdAsync("user1", It.IsAny())).ReturnsAsync(dept); + _authCalService.Setup(x => x.GetCalendarItemByIdAsync(1)) + .ReturnsAsync(new CalendarItem { CalendarItemId = 1, DepartmentId = 10, CheckInType = (int)CalendarItemCheckInTypes.AdminOnly, CreatorUserId = "someoneelse" }); + _authGroupService.Setup(x => x.GetGroupForUserAsync("user1", 10)) + .ReturnsAsync((DepartmentGroup)null); + + var result = await _authService.CanUserAdminCheckInCalendarEventAsync("user1", 1, "user2"); + + result.Should().BeFalse(); + } + + [Test] + public async Task CanUserEditCheckIn_returns_true_for_own_checkin() + { + SetupAuthService(); + var dept = new Department { DepartmentId = 10, ManagingUserId = "admin1" }; + _authDeptService.Setup(x => x.GetDepartmentByUserIdAsync("user1", It.IsAny())).ReturnsAsync(dept); + _authCalService.Setup(x => x.GetCheckInByIdAsync("checkin1")) + .ReturnsAsync(new CalendarItemCheckIn { CalendarItemCheckInId = "checkin1", DepartmentId = 10, UserId = "user1" }); + + var result = await _authService.CanUserEditCalendarCheckInAsync("user1", "checkin1"); + + result.Should().BeTrue(); + } + + [Test] + public async Task CanUserEditCheckIn_returns_true_for_department_admin() + { + SetupAuthService(); + var dept = new Department { DepartmentId = 10, ManagingUserId = "admin1" }; + _authDeptService.Setup(x => x.GetDepartmentByUserIdAsync("admin1", It.IsAny())).ReturnsAsync(dept); + _authCalService.Setup(x => x.GetCheckInByIdAsync("checkin1")) + .ReturnsAsync(new CalendarItemCheckIn { CalendarItemCheckInId = "checkin1", DepartmentId = 10, UserId = "user1" }); + + var result = await _authService.CanUserEditCalendarCheckInAsync("admin1", "checkin1"); + + result.Should().BeTrue(); + } + + [Test] + public async Task CanUserEditCheckIn_returns_false_for_other_users_checkin() + { + SetupAuthService(); + var dept = new Department { DepartmentId = 10, ManagingUserId = "admin1" }; + _authDeptService.Setup(x => x.GetDepartmentByUserIdAsync("user2", It.IsAny())).ReturnsAsync(dept); + _authCalService.Setup(x => x.GetCheckInByIdAsync("checkin1")) + .ReturnsAsync(new CalendarItemCheckIn { CalendarItemCheckInId = "checkin1", DepartmentId = 10, UserId = "user1", CalendarItemId = 1 }); + _authCalService.Setup(x => x.GetCalendarItemByIdAsync(1)) + .ReturnsAsync(new CalendarItem { CalendarItemId = 1, DepartmentId = 10, CheckInType = (int)CalendarItemCheckInTypes.AdminOnly, CreatorUserId = "someoneelse" }); + _authGroupService.Setup(x => x.GetGroupForUserAsync("user2", 10)) + .ReturnsAsync((DepartmentGroup)null); + + var result = await _authService.CanUserEditCalendarCheckInAsync("user2", "checkin1"); + + result.Should().BeFalse(); + } + + [Test] + public async Task CanUserDeleteCheckIn_returns_false_when_not_admin() + { + SetupAuthService(); + var dept = new Department { DepartmentId = 10, ManagingUserId = "admin1" }; + _authDeptService.Setup(x => x.GetDepartmentByUserIdAsync("user1", It.IsAny())).ReturnsAsync(dept); + _authCalService.Setup(x => x.GetCheckInByIdAsync("checkin1")) + .ReturnsAsync(new CalendarItemCheckIn { CalendarItemCheckInId = "checkin1", DepartmentId = 10, UserId = "user1", CalendarItemId = 1 }); + _authCalService.Setup(x => x.GetCalendarItemByIdAsync(1)) + .ReturnsAsync(new CalendarItem { CalendarItemId = 1, DepartmentId = 10, CheckInType = (int)CalendarItemCheckInTypes.AdminOnly, CreatorUserId = "someoneelse" }); + + var result = await _authService.CanUserDeleteCalendarCheckInAsync("user1", "checkin1"); + + result.Should().BeFalse(); + } + + #endregion Authorization Tests + } +} diff --git a/Tests/Resgrid.Tests/Services/CalendarServiceTests.cs b/Tests/Resgrid.Tests/Services/CalendarServiceTests.cs index ce5e3aab..94ee9639 100644 --- a/Tests/Resgrid.Tests/Services/CalendarServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CalendarServiceTests.cs @@ -70,7 +70,7 @@ protected with_the_calendar_service() _calendarService = new CalendarService(_calendarItemRepositoryMock.Object, _calendarItemTypeRepositoryMock.Object, _calendarItemAttendeeRepositoryMock.Object, _departmentsServiceMock.Object, _communicationServiceMock.Object, _userProfileServiceMock.Object, _departmentGroupsServiceMock.Object, _departmentSettingsServiceMock.Object, - _encryptionServiceMock.Object); + _encryptionServiceMock.Object, new Mock().Object); } } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CalendarController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CalendarController.cs index ca9166b8..662b62a9 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CalendarController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CalendarController.cs @@ -6,17 +6,22 @@ using System.Collections.Generic; using System.Linq; using System.Net.Mime; +using System.Threading; using Microsoft.AspNetCore.Authorization; using Resgrid.Framework; using Resgrid.Model; +using Resgrid.Model.Events; using Resgrid.Model.Helpers; +using Resgrid.Model.Providers; using Resgrid.Providers.Claims; using Resgrid.Web.Services.Controllers.Version3.Models.Calendar; using Resgrid.Web.Services.Helpers; using Resgrid.Web.Services.Models.v4.Calendar; +using Resgrid.Web.ServicesCore.Helpers; using CalendarItem = Resgrid.Model.CalendarItem; using CalendarItemAttendee = Resgrid.Model.CalendarItemAttendee; using CalendarItemType = Resgrid.Model.CalendarItemType; +using IAuthorizationService = Resgrid.Model.Services.IAuthorizationService; namespace Resgrid.Web.Services.Controllers.v4 { @@ -31,11 +36,19 @@ public class CalendarController : V4AuthenticatedApiControllerbase #region Members and Constructors private readonly ICalendarService _calendarService; private readonly IDepartmentsService _departmentsService; + private readonly IAuthorizationService _authorizationService; + private readonly IEventAggregator _eventAggregator; + private readonly IUserProfileService _userProfileService; - public CalendarController(ICalendarService calendarService, IDepartmentsService departmentsService) + public CalendarController(ICalendarService calendarService, IDepartmentsService departmentsService, + IAuthorizationService authorizationService, IEventAggregator eventAggregator, + IUserProfileService userProfileService) { _calendarService = calendarService; _departmentsService = departmentsService; + _authorizationService = authorizationService; + _eventAggregator = eventAggregator; + _userProfileService = userProfileService; } #endregion Members and Constructors @@ -257,6 +270,291 @@ public async Task> SetCalendarAttending return result; } + /// + /// Checks in to a calendar event + /// + [HttpPost("SetCalendarCheckIn")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [Authorize(Policy = ResgridResources.Schedule_View)] + public async Task> SetCalendarCheckIn([FromBody] CalendarCheckInInput input, CancellationToken cancellationToken) + { + var result = new SetCalendarCheckInResult(); + + if (input == null || input.CalendarEventId <= 0) + return BadRequest(); + + var targetUserId = !string.IsNullOrWhiteSpace(input.UserId) && input.UserId != UserId ? input.UserId : UserId; + var isAdminCheckIn = targetUserId != UserId; + + if (isAdminCheckIn) + { + if (!await _authorizationService.CanUserAdminCheckInCalendarEventAsync(UserId, input.CalendarEventId, targetUserId)) + return Unauthorized(); + } + else + { + if (!await _authorizationService.CanUserCheckInToCalendarEventAsync(UserId, input.CalendarEventId)) + return Unauthorized(); + } + + var checkIn = await _calendarService.CheckInToEventAsync(input.CalendarEventId, targetUserId, input.Note, + isAdminCheckIn ? UserId : null, input.Latitude, input.Longitude, cancellationToken); + + if (checkIn == null) + { + result.Id = ""; + result.PageSize = 0; + result.Status = ResponseHelper.Failure; + } + else + { + result.Id = checkIn.CalendarItemCheckInId; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + + var auditEvent = new AuditEvent(); + auditEvent.DepartmentId = DepartmentId; + auditEvent.UserId = UserId; + auditEvent.Type = isAdminCheckIn ? AuditLogTypes.CalendarAdminCheckInPerformed : AuditLogTypes.CalendarCheckInPerformed; + auditEvent.After = checkIn.CloneJsonToString(); + auditEvent.Successful = true; + auditEvent.IpAddress = IpAddressHelper.GetRequestIP(Request, true); + auditEvent.ServerName = Environment.MachineName; + auditEvent.UserAgent = $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}"; + _eventAggregator.SendMessage(auditEvent); + } + + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + /// + /// Checks out from a calendar event + /// + [HttpPost("SetCalendarCheckOut")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [Authorize(Policy = ResgridResources.Schedule_View)] + public async Task> SetCalendarCheckOut([FromBody] CalendarCheckOutInput input, CancellationToken cancellationToken) + { + var result = new SetCalendarCheckInResult(); + + if (input == null || input.CalendarEventId <= 0) + return BadRequest(); + + var targetUserId = !string.IsNullOrWhiteSpace(input.UserId) && input.UserId != UserId ? input.UserId : UserId; + var isAdminCheckOut = targetUserId != UserId; + + if (isAdminCheckOut) + { + if (!await _authorizationService.CanUserAdminCheckInCalendarEventAsync(UserId, input.CalendarEventId, targetUserId)) + return Unauthorized(); + } + else + { + if (!await _authorizationService.CanUserCheckInToCalendarEventAsync(UserId, input.CalendarEventId)) + return Unauthorized(); + } + + var checkIn = await _calendarService.CheckOutFromEventAsync(input.CalendarEventId, targetUserId, + input.Note, isAdminCheckOut ? UserId : null, input.Latitude, input.Longitude, cancellationToken); + + if (checkIn == null) + { + result.Id = ""; + result.PageSize = 0; + result.Status = ResponseHelper.NotFound; + } + else + { + result.Id = checkIn.CalendarItemCheckInId; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + + var auditEvent = new AuditEvent(); + auditEvent.DepartmentId = DepartmentId; + auditEvent.UserId = UserId; + auditEvent.Type = AuditLogTypes.CalendarCheckOutPerformed; + auditEvent.After = checkIn.CloneJsonToString(); + auditEvent.Successful = true; + auditEvent.IpAddress = IpAddressHelper.GetRequestIP(Request, true); + auditEvent.ServerName = Environment.MachineName; + auditEvent.UserAgent = $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}"; + _eventAggregator.SendMessage(auditEvent); + } + + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + /// + /// Updates check-in times for a calendar event + /// + [HttpPost("UpdateCalendarCheckIn")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [Authorize(Policy = ResgridResources.Schedule_Update)] + public async Task> UpdateCalendarCheckIn([FromBody] CalendarCheckInUpdateInput input, CancellationToken cancellationToken) + { + var result = new SetCalendarCheckInResult(); + + if (input == null || string.IsNullOrWhiteSpace(input.CheckInId)) + return BadRequest(); + + if (!await _authorizationService.CanUserEditCalendarCheckInAsync(UserId, input.CheckInId)) + return Unauthorized(); + + var existing = await _calendarService.GetCheckInByIdAsync(input.CheckInId); + var beforeJson = existing?.CloneJsonToString(); + + var checkIn = await _calendarService.UpdateCheckInTimesAsync(input.CheckInId, input.CheckInTime, + input.CheckOutTime, input.CheckInNote, input.CheckOutNote, cancellationToken); + + if (checkIn == null) + { + result.Id = ""; + result.PageSize = 0; + result.Status = ResponseHelper.NotFound; + } + else + { + result.Id = checkIn.CalendarItemCheckInId; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + + var auditEvent = new AuditEvent(); + auditEvent.DepartmentId = DepartmentId; + auditEvent.UserId = UserId; + auditEvent.Type = AuditLogTypes.CalendarCheckInUpdated; + auditEvent.Before = beforeJson; + auditEvent.After = checkIn.CloneJsonToString(); + auditEvent.Successful = true; + auditEvent.IpAddress = IpAddressHelper.GetRequestIP(Request, true); + auditEvent.ServerName = Environment.MachineName; + auditEvent.UserAgent = $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}"; + _eventAggregator.SendMessage(auditEvent); + } + + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + /// + /// Gets all check-ins for a calendar item + /// + [HttpGet("GetCalendarItemCheckIns")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [Authorize(Policy = ResgridResources.Schedule_View)] + public async Task> GetCalendarItemCheckIns(int calendarItemId) + { + var result = new GetCalendarCheckInsResult(); + result.Data = new List(); + + if (!await _authorizationService.CanUserViewCalendarCheckInsAsync(UserId, calendarItemId)) + return Unauthorized(); + + var checkIns = await _calendarService.GetCheckInsByCalendarItemAsync(calendarItemId); + var personnelNames = await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(DepartmentId); + + foreach (var checkIn in checkIns) + { + var data = new CalendarCheckInResultData + { + CheckInId = checkIn.CalendarItemCheckInId, + CalendarItemId = checkIn.CalendarItemId, + UserId = checkIn.UserId, + CheckInTime = checkIn.CheckInTime, + CheckOutTime = checkIn.CheckOutTime, + DurationSeconds = checkIn.GetDuration()?.TotalSeconds, + IsManualOverride = checkIn.IsManualOverride, + CheckInNote = checkIn.CheckInNote, + CheckOutNote = checkIn.CheckOutNote, + CheckInLatitude = checkIn.CheckInLatitude, + CheckInLongitude = checkIn.CheckInLongitude, + CheckOutLatitude = checkIn.CheckOutLatitude, + CheckOutLongitude = checkIn.CheckOutLongitude + }; + + var name = personnelNames?.FirstOrDefault(x => x.UserId == checkIn.UserId); + if (name != null) + data.Name = name.Name; + + if (!string.IsNullOrWhiteSpace(checkIn.CheckInByUserId)) + { + var adminName = personnelNames?.FirstOrDefault(x => x.UserId == checkIn.CheckInByUserId); + if (adminName != null) + data.CheckInByName = adminName.Name; + } + + if (!string.IsNullOrWhiteSpace(checkIn.CheckOutByUserId)) + { + var outName = personnelNames?.FirstOrDefault(x => x.UserId == checkIn.CheckOutByUserId); + if (outName != null) + data.CheckOutByName = outName.Name; + } + + result.Data.Add(data); + } + + result.PageSize = result.Data.Count; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); + } + + /// + /// Deletes a calendar check-in record + /// + [HttpDelete("DeleteCalendarCheckIn")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [Authorize(Policy = ResgridResources.Schedule_Delete)] + public async Task> DeleteCalendarCheckIn(string checkInId, CancellationToken cancellationToken) + { + var result = new SetCalendarCheckInResult(); + + if (string.IsNullOrWhiteSpace(checkInId)) + return BadRequest(); + + if (!await _authorizationService.CanUserDeleteCalendarCheckInAsync(UserId, checkInId)) + return Unauthorized(); + + var existing = await _calendarService.GetCheckInByIdAsync(checkInId); + var beforeJson = existing?.CloneJsonToString(); + + var deleted = await _calendarService.DeleteCheckInAsync(checkInId, cancellationToken); + + result.Id = checkInId; + result.PageSize = deleted ? 1 : 0; + result.Status = deleted ? ResponseHelper.Success : ResponseHelper.NotFound; + + if (deleted) + { + var auditEvent = new AuditEvent(); + auditEvent.DepartmentId = DepartmentId; + auditEvent.UserId = UserId; + auditEvent.Type = AuditLogTypes.CalendarCheckInDeleted; + auditEvent.Before = beforeJson; + auditEvent.Successful = true; + auditEvent.IpAddress = IpAddressHelper.GetRequestIP(Request, true); + auditEvent.ServerName = Environment.MachineName; + auditEvent.UserAgent = $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}"; + _eventAggregator.SendMessage(auditEvent); + } + + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + public static GetAllCalendarItemResultData ConvertCalendarItemData(CalendarItem item, Department department, string currentUserId, CalendarItemType type, List personnelNames) { var calendarItem = new GetAllCalendarItemResultData(); @@ -297,6 +595,7 @@ public static GetAllCalendarItemResultData ConvertCalendarItemData(CalendarItem calendarItem.ItemType = item.ItemType; calendarItem.Location = item.Location; calendarItem.SignupType = item.SignupType; + calendarItem.CheckInType = item.CheckInType; calendarItem.Reminder = item.Reminder; calendarItem.LockEditing = item.LockEditing; calendarItem.Entities = item.Entities; diff --git a/Web/Resgrid.Web.Services/Models/v4/Calendar/CalendarCheckInInput.cs b/Web/Resgrid.Web.Services/Models/v4/Calendar/CalendarCheckInInput.cs new file mode 100644 index 00000000..e17336f1 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Calendar/CalendarCheckInInput.cs @@ -0,0 +1,33 @@ +namespace Resgrid.Web.Services.Models.v4.Calendar +{ + /// + /// Input for checking in to a calendar event + /// + public class CalendarCheckInInput + { + /// + /// Calendar event item id to check in to + /// + public int CalendarEventId { get; set; } + + /// + /// Optional note for the check-in + /// + public string Note { get; set; } + + /// + /// Optional user id for admin/group admin check-in on behalf of another user + /// + public string UserId { get; set; } + + /// + /// GPS latitude at check-in (from Responder app) + /// + public string Latitude { get; set; } + + /// + /// GPS longitude at check-in (from Responder app) + /// + public string Longitude { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/Calendar/CalendarCheckInResultData.cs b/Web/Resgrid.Web.Services/Models/v4/Calendar/CalendarCheckInResultData.cs new file mode 100644 index 00000000..02d948b4 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Calendar/CalendarCheckInResultData.cs @@ -0,0 +1,90 @@ +using System; + +namespace Resgrid.Web.Services.Models.v4.Calendar +{ + /// + /// Data representing a single calendar check-in record + /// + public class CalendarCheckInResultData + { + /// + /// The check-in record id + /// + public string CheckInId { get; set; } + + /// + /// The calendar item id + /// + public int CalendarItemId { get; set; } + + /// + /// The user id who checked in + /// + public string UserId { get; set; } + + /// + /// The display name of the user + /// + public string Name { get; set; } + + /// + /// Check-in time in UTC + /// + public DateTime CheckInTime { get; set; } + + /// + /// Check-out time in UTC (nullable) + /// + public DateTime? CheckOutTime { get; set; } + + /// + /// Duration in seconds (null if not checked out) + /// + public double? DurationSeconds { get; set; } + + /// + /// Whether the times were manually overridden + /// + public bool IsManualOverride { get; set; } + + /// + /// Name of the person who performed the check-in (if on behalf) + /// + public string CheckInByName { get; set; } + + /// + /// Name of the person who performed the check-out (if on behalf) + /// + public string CheckOutByName { get; set; } + + /// + /// Check-in note + /// + public string CheckInNote { get; set; } + + /// + /// Check-out note + /// + public string CheckOutNote { get; set; } + + /// + /// GPS latitude at check-in + /// + public string CheckInLatitude { get; set; } + + /// + /// GPS longitude at check-in + /// + public string CheckInLongitude { get; set; } + + /// + /// GPS latitude at check-out + /// + public string CheckOutLatitude { get; set; } + + /// + /// GPS longitude at check-out + /// + public string CheckOutLongitude { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/Calendar/CalendarCheckInUpdateInput.cs b/Web/Resgrid.Web.Services/Models/v4/Calendar/CalendarCheckInUpdateInput.cs new file mode 100644 index 00000000..b6fb2409 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Calendar/CalendarCheckInUpdateInput.cs @@ -0,0 +1,35 @@ +using System; + +namespace Resgrid.Web.Services.Models.v4.Calendar +{ + /// + /// Input for updating check-in times + /// + public class CalendarCheckInUpdateInput + { + /// + /// The check-in record id to update + /// + public string CheckInId { get; set; } + + /// + /// The updated check-in time in UTC + /// + public DateTime CheckInTime { get; set; } + + /// + /// The updated check-out time in UTC (nullable) + /// + public DateTime? CheckOutTime { get; set; } + + /// + /// Check-in note + /// + public string CheckInNote { get; set; } + + /// + /// Check-out note + /// + public string CheckOutNote { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/Calendar/CalendarCheckOutInput.cs b/Web/Resgrid.Web.Services/Models/v4/Calendar/CalendarCheckOutInput.cs new file mode 100644 index 00000000..ad804a03 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Calendar/CalendarCheckOutInput.cs @@ -0,0 +1,33 @@ +namespace Resgrid.Web.Services.Models.v4.Calendar +{ + /// + /// Input for checking out from a calendar event + /// + public class CalendarCheckOutInput + { + /// + /// Calendar event item id to check out from + /// + public int CalendarEventId { get; set; } + + /// + /// Optional user id for admin/group admin check-out on behalf of another user + /// + public string UserId { get; set; } + + /// + /// Optional note for the check-out + /// + public string Note { get; set; } + + /// + /// GPS latitude at check-out (from Responder app) + /// + public string Latitude { get; set; } + + /// + /// GPS longitude at check-out (from Responder app) + /// + public string Longitude { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/Calendar/GetAllCalendarItemResult.cs b/Web/Resgrid.Web.Services/Models/v4/Calendar/GetAllCalendarItemResult.cs index 50a96f5d..a7cd0cdd 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Calendar/GetAllCalendarItemResult.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Calendar/GetAllCalendarItemResult.cs @@ -104,6 +104,11 @@ public class GetAllCalendarItemResultData /// public int SignupType { get; set; } + /// + /// Check-in type: 0=Disabled, 1=SelfCheckIn, 2=AdminOnly + /// + public int CheckInType { get; set; } + /// /// Reminder type /// diff --git a/Web/Resgrid.Web.Services/Models/v4/Calendar/GetCalendarCheckInsResult.cs b/Web/Resgrid.Web.Services/Models/v4/Calendar/GetCalendarCheckInsResult.cs new file mode 100644 index 00000000..17801864 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Calendar/GetCalendarCheckInsResult.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace Resgrid.Web.Services.Models.v4.Calendar +{ + /// + /// Result containing calendar check-in records + /// + public class GetCalendarCheckInsResult : StandardApiResponseV4Base + { + /// + /// Response Data + /// + public List Data { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/Calendar/SetCalendarCheckInResult.cs b/Web/Resgrid.Web.Services/Models/v4/Calendar/SetCalendarCheckInResult.cs new file mode 100644 index 00000000..7aa43c6d --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Calendar/SetCalendarCheckInResult.cs @@ -0,0 +1,13 @@ +namespace Resgrid.Web.Services.Models.v4.Calendar +{ + /// + /// The result of a calendar check-in operation + /// + public class SetCalendarCheckInResult : StandardApiResponseV4Base + { + /// + /// Identifier of the check-in record + /// + public string Id { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index eb1a1422..d6bb449b 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -81,6 +81,31 @@ + + + Checks in to a calendar event + + + + + Checks out from a calendar event + + + + + Updates check-in times for a calendar event + + + + + Gets all check-ins for a calendar item + + + + + Deletes a calendar check-in record + + iCal export and external calendar sync endpoints. @@ -4032,6 +4057,181 @@ Added on as UTC + + + Input for checking in to a calendar event + + + + + Calendar event item id to check in to + + + + + Optional note for the check-in + + + + + Optional user id for admin/group admin check-in on behalf of another user + + + + + GPS latitude at check-in (from Responder app) + + + + + GPS longitude at check-in (from Responder app) + + + + + Data representing a single calendar check-in record + + + + + The check-in record id + + + + + The calendar item id + + + + + The user id who checked in + + + + + The display name of the user + + + + + Check-in time in UTC + + + + + Check-out time in UTC (nullable) + + + + + Duration in seconds (null if not checked out) + + + + + Whether the times were manually overridden + + + + + Name of the person who performed the check-in (if on behalf) + + + + + Name of the person who performed the check-out (if on behalf) + + + + + Check-in note + + + + + Check-out note + + + + + GPS latitude at check-in + + + + + GPS longitude at check-in + + + + + GPS latitude at check-out + + + + + GPS longitude at check-out + + + + + Input for updating check-in times + + + + + The check-in record id to update + + + + + The updated check-in time in UTC + + + + + The updated check-out time in UTC (nullable) + + + + + Check-in note + + + + + Check-out note + + + + + Input for checking out from a calendar event + + + + + Calendar event item id to check out from + + + + + Optional user id for admin/group admin check-out on behalf of another user + + + + + Optional note for the check-out + + + + + GPS latitude at check-out (from Responder app) + + + + + GPS longitude at check-out (from Responder app) + + Result containing all calendar items @@ -4132,6 +4332,11 @@ Signup type + + + Check-in type: 0=Disabled, 1=SelfCheckIn, 2=AdminOnly + + Reminder type @@ -4257,6 +4462,16 @@ Color for this type + + + Result containing calendar check-in records + + + + + Response Data + + Result containing a single calendar items @@ -4298,6 +4513,16 @@ Identifier of the new attending calendar entry + + + The result of a calendar check-in operation + + + + + Identifier of the check-in record + + A Call file result diff --git a/Web/Resgrid.Web/Areas/User/Controllers/CalendarController.cs b/Web/Resgrid.Web/Areas/User/Controllers/CalendarController.cs index 81cde1c8..754f7e01 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/CalendarController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/CalendarController.cs @@ -144,12 +144,20 @@ public async Task New(NewCalendarEntry model, CancellationToken c if (calendarItem != null) { - try + if (model.Item.SignupType == (int)CalendarItemSignupTypes.None && !string.IsNullOrWhiteSpace(model.entities)) { - await _calendarService.NotifyNewCalendarItemAsync(calendarItem); + // None signup: add selected entities as direct attendees and notify only them + var newUserIds = await AddEntitiesAsAttendeesAsync(calendarItem, model.entities, new HashSet(), cancellationToken); + if (newUserIds.Any()) + { + try { await _calendarService.NotifyUsersAboutCalendarItemAsync(calendarItem, newUserIds); } catch { } + } + } + else + { + // RSVP mode: notify entities (groups/department) via existing mechanism + try { await _calendarService.NotifyNewCalendarItemAsync(calendarItem); } catch { } } - catch - { } } return RedirectToAction("Index"); @@ -225,6 +233,15 @@ public async Task Edit(EditCalendarEntry model, CancellationToken { var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + // Snapshot existing attendees before update so we can diff for notifications + var existingItem = await _calendarService.GetCalendarItemByIdAsync(model.Item.CalendarItemId); + var existingAttendeeIds = new HashSet(); + if (existingItem?.Attendees != null) + { + foreach (var a in existingItem.Attendees) + existingAttendeeIds.Add(a.UserId); + } + model.Item.Start = model.StartTime; model.Item.End = model.EndTime; model.Item.RecurrenceEnd = model.RecurrenceEndLocal; @@ -234,6 +251,18 @@ public async Task Edit(EditCalendarEntry model, CancellationToken await _calendarService.UpdateCalendarItemAsync(model.Item, department.TimeZone, cancellationToken); + // Add new attendees from entities and notify only the newly added ones + // Skip notifications for past events (bookkeeping after the fact) + if (!string.IsNullOrWhiteSpace(model.entities)) + { + var updatedItem = await _calendarService.GetCalendarItemByIdAsync(model.Item.CalendarItemId); + var newUserIds = await AddEntitiesAsAttendeesAsync(updatedItem, model.entities, existingAttendeeIds, cancellationToken); + if (newUserIds.Any() && updatedItem.End > DateTime.UtcNow) + { + try { await _calendarService.NotifyUsersAboutCalendarItemAsync(updatedItem, newUserIds); } catch { } + } + } + return RedirectToAction("Index"); } @@ -590,11 +619,27 @@ public async Task View(int calendarItemId) model.CanEdit = await _authorizationService.CanUserModifyCalendarEntryAsync(UserId, calendarItemId); - if (model.CalendarItem.DepartmentId != DepartmentId) - return Unauthorized(); - model.ExportIcsUrl = $"{SystemBehaviorConfig.ResgridApiBaseUrl}/api/v4/CalendarExport/ExportICalFile?calendarItemId={calendarItemId}"; + // Check-in attendance data + model.UserCheckIn = await _calendarService.GetCheckInByCalendarItemAndUserAsync(calendarItemId, UserId); + model.CheckIns = await _calendarService.GetCheckInsByCalendarItemAsync(calendarItemId); + model.PersonnelNames = await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(DepartmentId); + model.IsAdmin = model.Department.IsUserAnAdmin(UserId); + + // Check if user is event creator or group admin (for admin check-in buttons) + if (!model.IsAdmin) + { + if (!string.IsNullOrWhiteSpace(model.CalendarItem.CreatorUserId) && model.CalendarItem.CreatorUserId == UserId) + model.IsAdmin = true; + else + { + var group = await _departmentGroupsService.GetGroupForUserAsync(UserId, DepartmentId); + if (group != null && group.IsUserGroupAdmin(UserId)) + model.IsAdmin = true; + } + } + return View(model); } @@ -607,6 +652,27 @@ public async Task Signup(CalendarItemView model, CancellationToke return RedirectToAction("View", new { calendarItemId = model.CalendarItem.CalendarItemId }); } + [HttpGet] + [Authorize(Policy = ResgridResources.Schedule_View)] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + public async Task SignupSheet(int calendarItemId, int rows = 0) + { + var calendarItem = await _calendarService.GetCalendarItemByIdAsync(calendarItemId); + if (calendarItem == null || calendarItem.DepartmentId != DepartmentId) + return Unauthorized(); + + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); + var personnelNames = await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(DepartmentId); + + var model = new SignupSheetView(); + model.CalendarItem = calendarItem; + model.Department = department; + model.PersonnelNames = personnelNames; + model.TotalRows = rows > 0 ? rows : 25; + + return View(model); + } + [HttpGet] [Authorize(Policy = ResgridResources.Schedule_View)] @@ -831,6 +897,216 @@ public async Task RegenerateCalendarSync(CancellationToken cancel return RedirectToAction("Index"); } + // -- Check-In Attendance ------------------------------------------------------- + + [HttpPost] + [Authorize(Policy = ResgridResources.Schedule_View)] + [ValidateAntiForgeryToken] + public async Task CheckIn(int calendarItemId, string checkInNote, CancellationToken cancellationToken) + { + if (!await _authorizationService.CanUserCheckInToCalendarEventAsync(UserId, calendarItemId)) + return Unauthorized(); + + var checkIn = await _calendarService.CheckInToEventAsync(calendarItemId, UserId, checkInNote, cancellationToken: cancellationToken); + + if (checkIn != null) + { + var auditEvent = new AuditEvent(); + auditEvent.DepartmentId = DepartmentId; + auditEvent.UserId = UserId; + auditEvent.Type = AuditLogTypes.CalendarCheckInPerformed; + auditEvent.After = checkIn.CloneJsonToString(); + auditEvent.Successful = true; + _eventAggregator.SendMessage(auditEvent); + } + + return RedirectToAction("View", new { calendarItemId = calendarItemId }); + } + + [HttpPost] + [Authorize(Policy = ResgridResources.Schedule_View)] + [ValidateAntiForgeryToken] + public async Task CheckOut(int calendarItemId, string checkOutNote, CancellationToken cancellationToken) + { + if (!await _authorizationService.CanUserCheckInToCalendarEventAsync(UserId, calendarItemId)) + return Unauthorized(); + + var checkIn = await _calendarService.CheckOutFromEventAsync(calendarItemId, UserId, checkOutNote, cancellationToken: cancellationToken); + + if (checkIn != null) + { + var auditEvent = new AuditEvent(); + auditEvent.DepartmentId = DepartmentId; + auditEvent.UserId = UserId; + auditEvent.Type = AuditLogTypes.CalendarCheckOutPerformed; + auditEvent.After = checkIn.CloneJsonToString(); + auditEvent.Successful = true; + _eventAggregator.SendMessage(auditEvent); + } + + return RedirectToAction("View", new { calendarItemId = calendarItemId }); + } + + [HttpPost] + [Authorize(Policy = ResgridResources.Schedule_View)] + [ValidateAntiForgeryToken] + public async Task AdminCheckIn(int calendarItemId, string userId, CancellationToken cancellationToken) + { + if (!await _authorizationService.CanUserAdminCheckInCalendarEventAsync(UserId, calendarItemId, userId)) + return Unauthorized(); + + var checkIn = await _calendarService.CheckInToEventAsync(calendarItemId, userId, null, UserId, cancellationToken: cancellationToken); + + if (checkIn != null) + { + var auditEvent = new AuditEvent(); + auditEvent.DepartmentId = DepartmentId; + auditEvent.UserId = UserId; + auditEvent.Type = AuditLogTypes.CalendarAdminCheckInPerformed; + auditEvent.After = checkIn.CloneJsonToString(); + auditEvent.Successful = true; + _eventAggregator.SendMessage(auditEvent); + } + + return RedirectToAction("View", new { calendarItemId = calendarItemId }); + } + + [HttpPost] + [Authorize(Policy = ResgridResources.Schedule_View)] + [ValidateAntiForgeryToken] + public async Task AdminCheckOut(int calendarItemId, string userId, CancellationToken cancellationToken) + { + if (!await _authorizationService.CanUserAdminCheckInCalendarEventAsync(UserId, calendarItemId, userId)) + return Unauthorized(); + + var checkIn = await _calendarService.CheckOutFromEventAsync(calendarItemId, userId, null, UserId, cancellationToken: cancellationToken); + + if (checkIn != null) + { + var auditEvent = new AuditEvent(); + auditEvent.DepartmentId = DepartmentId; + auditEvent.UserId = UserId; + auditEvent.Type = AuditLogTypes.CalendarCheckOutPerformed; + auditEvent.After = checkIn.CloneJsonToString(); + auditEvent.Successful = true; + _eventAggregator.SendMessage(auditEvent); + } + + return RedirectToAction("View", new { calendarItemId = calendarItemId }); + } + + [HttpGet] + [Authorize(Policy = ResgridResources.Schedule_Update)] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + public async Task EditCheckIn(string checkInId) + { + if (!await _authorizationService.CanUserEditCalendarCheckInAsync(UserId, checkInId)) + return Unauthorized(); + + var checkIn = await _calendarService.GetCheckInByIdAsync(checkInId); + if (checkIn == null) + return NotFound(); + + var model = new EditCalendarCheckInView(); + model.CheckIn = checkIn; + + return View(model); + } + + [HttpPost] + [Authorize(Policy = ResgridResources.Schedule_Update)] + [ValidateAntiForgeryToken] + public async Task EditCheckIn(EditCalendarCheckInView model, CancellationToken cancellationToken) + { + if (!await _authorizationService.CanUserEditCalendarCheckInAsync(UserId, model.CheckIn.CalendarItemCheckInId)) + return Unauthorized(); + + var existing = await _calendarService.GetCheckInByIdAsync(model.CheckIn.CalendarItemCheckInId); + var beforeJson = existing?.CloneJsonToString(); + + var updated = await _calendarService.UpdateCheckInTimesAsync( + model.CheckIn.CalendarItemCheckInId, + model.CheckIn.CheckInTime, + model.CheckIn.CheckOutTime, + model.CheckIn.CheckInNote, + model.CheckIn.CheckOutNote, + cancellationToken); + + if (updated != null) + { + var auditEvent = new AuditEvent(); + auditEvent.DepartmentId = DepartmentId; + auditEvent.UserId = UserId; + auditEvent.Type = AuditLogTypes.CalendarCheckInUpdated; + auditEvent.Before = beforeJson; + auditEvent.After = updated.CloneJsonToString(); + auditEvent.Successful = true; + _eventAggregator.SendMessage(auditEvent); + + return RedirectToAction("View", new { calendarItemId = updated.CalendarItemId }); + } + + return NotFound(); + } + // -- Helpers ------------------------------------------------------------------ + + /// + /// Resolves entity strings (D: for department, G:123 for groups) into individual users, + /// creates attendee records for any user not already attending, and returns the list of + /// newly added user IDs (so only they can be notified). + /// + private async Task> AddEntitiesAsAttendeesAsync(CalendarItem calendarItem, string entities, + HashSet existingAttendeeUserIds, CancellationToken cancellationToken) + { + var newlyAdded = new List(); + if (string.IsNullOrWhiteSpace(entities)) + return newlyAdded; + + var items = entities.Split(','); + var processedUserIds = new HashSet(); + + foreach (var val in items) + { + if (val.StartsWith("D:")) + { + var personnelNames = await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(calendarItem.DepartmentId); + if (personnelNames != null) + { + foreach (var person in personnelNames) + { + if (processedUserIds.Add(person.UserId) && !existingAttendeeUserIds.Contains(person.UserId)) + { + await _calendarService.SignupForEvent(calendarItem.CalendarItemId, person.UserId, null, + (int)CalendarItemAttendeeTypes.Required, cancellationToken); + newlyAdded.Add(person.UserId); + } + } + } + } + else if (val.StartsWith("G:")) + { + int groupId; + if (int.TryParse(val.Replace("G:", ""), out groupId)) + { + var group = await _departmentGroupsService.GetGroupByIdAsync(groupId); + if (group?.Members != null) + { + foreach (var member in group.Members) + { + if (processedUserIds.Add(member.UserId) && !existingAttendeeUserIds.Contains(member.UserId)) + { + await _calendarService.SignupForEvent(calendarItem.CalendarItemId, member.UserId, null, + (int)CalendarItemAttendeeTypes.Required, cancellationToken); + newlyAdded.Add(member.UserId); + } + } + } + } + } + } + + return newlyAdded; + } } } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs index c3f6bff8..145ed038 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs @@ -22,6 +22,7 @@ using Resgrid.Web.Areas.User.Models.Reports.Params; using Resgrid.Web.Areas.User.Models.Reports.Personnel; using Resgrid.Web.Areas.User.Models.Reports.Shifts; +using Resgrid.Web.Areas.User.Models.Reports.EventAttendance; using Resgrid.Web.Areas.User.Models.Reports.Units; using Resgrid.Web.Helpers; using IAuthorizationService = Resgrid.Model.Services.IAuthorizationService; @@ -52,6 +53,7 @@ public class ReportsController : SecureBaseController private readonly IAuthorizationService _authorizationService; private readonly IUnitsService _unitsService; private readonly IUnitStatesService _unitStatesService; + private readonly ICalendarService _calendarService; public ReportsController(IDepartmentsService departmentsService, IUsersService usersService, IActionLogsService actionLogsService, @@ -62,7 +64,8 @@ public ReportsController(IDepartmentsService departmentsService, IUsersService u ICertificationService certificationService, IWorkLogsService logService, IShiftsService shiftsService, ICallsService callsService, IWorkLogsService workLogsService, ICustomStateService customStateService, IAuthorizationService authorizationService, - IUnitsService unitsService, IUnitStatesService unitStatesService) + IUnitsService unitsService, IUnitStatesService unitStatesService, + ICalendarService calendarService) { _departmentsService = departmentsService; _usersService = usersService; @@ -82,6 +85,7 @@ public ReportsController(IDepartmentsService departmentsService, IUsersService u _authorizationService = authorizationService; _unitsService = unitsService; _unitStatesService = unitStatesService; + _calendarService = calendarService; } #endregion Private Members and Constructors @@ -2044,5 +2048,194 @@ private async Task FlaggedCallNotesReportModel(int d return model; } + + #region Event Attendance Report + + [HttpGet] + [Authorize(Policy = ResgridResources.Reports_View)] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + public async Task EventAttendanceReportParams() + { + var model = new EventAttendanceReportParams(); + model.Start = new DateTime(DateTime.UtcNow.Year, 1, 1); + model.End = new DateTime(DateTime.UtcNow.Year, 12, 31, 23, 59, 59); + + return View(model); + } + + [HttpPost] + [Authorize(Policy = ResgridResources.Reports_View)] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + public async Task EventAttendanceReportParams(EventAttendanceReportParams model) + { + return RedirectToAction("EventAttendanceReport", new { start = model.Start.ToString("o"), end = model.End.ToString("o") }); + } + + [HttpGet] + [Authorize(Policy = ResgridResources.Reports_View)] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + public async Task EventAttendanceReport(DateTime start, DateTime end) + { + var model = await BuildEventAttendanceReportModel(start, end); + return View(model); + } + + [HttpGet] + [Authorize(Policy = ResgridResources.Reports_View)] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + public async Task EventAttendanceDetailReport(string userId, DateTime start, DateTime end) + { + var model = await BuildEventAttendanceDetailReportModel(userId, start, end); + return View(model); + } + + private async Task BuildEventAttendanceReportModel(DateTime start, DateTime end) + { + var model = new EventAttendanceView(); + model.RunOn = DateTime.UtcNow; + model.Start = start; + model.End = end; + model.Name = "Event Attendance Report"; + model.EventHours = new List(); + + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + model.Department = department; + + var checkIns = await _calendarService.GetCheckInsByDepartmentDateRangeAsync(DepartmentId, start, end); + var calendarItems = await _calendarService.GetAllCalendarItemsForDepartmentInRangeAsync(DepartmentId, start, end); + var groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(DepartmentId); + var personnelNames = await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(DepartmentId); + + // Build a lookup: userId → set of calendarItemIds they were listed as attendee for + var attendeeEventsByUser = new Dictionary>(); + foreach (var item in calendarItems) + { + if (item.Attendees != null) + { + foreach (var attendee in item.Attendees) + { + if (!attendeeEventsByUser.ContainsKey(attendee.UserId)) + attendeeEventsByUser[attendee.UserId] = new HashSet(); + attendeeEventsByUser[attendee.UserId].Add(item.CalendarItemId); + } + } + } + + // Build a lookup: userId → set of calendarItemIds they actually checked in for + var checkedInEventsByUser = new Dictionary>(); + foreach (var ci in checkIns) + { + if (!checkedInEventsByUser.ContainsKey(ci.UserId)) + checkedInEventsByUser[ci.UserId] = new HashSet(); + checkedInEventsByUser[ci.UserId].Add(ci.CalendarItemId); + } + + // Collect all users who are either attendees or have check-ins + var allUserIds = new HashSet(attendeeEventsByUser.Keys); + foreach (var ci in checkIns) + allUserIds.Add(ci.UserId); + + foreach (var userId in allUserIds) + { + var personHours = new PersonnelEventHours(); + personHours.ID = userId; + + // Count events checked in for + checkedInEventsByUser.TryGetValue(userId, out var checkedInEvents); + personHours.TotalEvents = checkedInEvents?.Count ?? 0; + + // Count events listed as attendee but did NOT check in for + attendeeEventsByUser.TryGetValue(userId, out var attendeeEvents); + if (attendeeEvents != null) + { + personHours.MissedEvents = checkedInEvents != null + ? attendeeEvents.Count(e => !checkedInEvents.Contains(e)) + : attendeeEvents.Count; + } + + // Total hours from check-ins with checkouts + personHours.TotalSeconds = checkIns + .Where(c => c.UserId == userId && c.CheckOutTime.HasValue) + .Sum(c => (c.CheckOutTime.Value - c.CheckInTime).TotalSeconds); + + var name = personnelNames?.FirstOrDefault(x => x.UserId == userId); + personHours.Name = name?.Name ?? userId; + + if (groups != null) + { + var userGroup = groups.FirstOrDefault(g => g.Members != null && g.Members.Any(m => m.UserId == userId)); + personHours.Group = userGroup?.Name ?? ""; + } + + model.EventHours.Add(personHours); + } + + model.EventHours = model.EventHours.OrderBy(x => x.Name).ToList(); + + return model; + } + + private async Task BuildEventAttendanceDetailReportModel(string userId, DateTime start, DateTime end) + { + var model = new EventAttendanceDetailView(); + model.RunOn = DateTime.UtcNow; + model.Start = start; + model.End = end; + model.Name = "Event Attendance Detail Report"; + model.Details = new List(); + + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + model.Department = department; + + var profile = await _userProfileService.GetProfileByUserIdAsync(userId); + model.PersonnelName = profile?.FullName?.AsFirstNameLastName ?? userId; + + var checkIns = await _calendarService.GetCheckInsByUserDateRangeAsync(userId, DepartmentId, start, end); + var personnelNames = await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(DepartmentId); + + double totalSeconds = 0; + foreach (var checkIn in checkIns) + { + var calItem = await _calendarService.GetCalendarItemByIdAsync(checkIn.CalendarItemId); + + var detail = new EventAttendanceDetail(); + detail.EventTitle = calItem?.Title ?? "Unknown Event"; + detail.CheckInTime = checkIn.CheckInTime; + detail.CheckOutTime = checkIn.CheckOutTime; + detail.IsManualOverride = checkIn.IsManualOverride; + detail.CheckInNote = checkIn.CheckInNote; + detail.CheckOutNote = checkIn.CheckOutNote; + detail.CheckInLatitude = checkIn.CheckInLatitude; + detail.CheckInLongitude = checkIn.CheckInLongitude; + detail.CheckOutLatitude = checkIn.CheckOutLatitude; + detail.CheckOutLongitude = checkIn.CheckOutLongitude; + + if (!string.IsNullOrWhiteSpace(checkIn.CheckInByUserId)) + { + var byName = personnelNames?.FirstOrDefault(x => x.UserId == checkIn.CheckInByUserId); + detail.CheckInByName = byName?.Name; + } + + if (!string.IsNullOrWhiteSpace(checkIn.CheckOutByUserId)) + { + var byName = personnelNames?.FirstOrDefault(x => x.UserId == checkIn.CheckOutByUserId); + detail.CheckOutByName = byName?.Name; + } + + if (checkIn.CheckOutTime.HasValue) + { + detail.DurationSeconds = (checkIn.CheckOutTime.Value - checkIn.CheckInTime).TotalSeconds; + totalSeconds += detail.DurationSeconds; + } + + model.Details.Add(detail); + } + + model.TotalSeconds = totalSeconds; + + return model; + } + + #endregion Event Attendance Report } } diff --git a/Web/Resgrid.Web/Areas/User/Models/Calendar/CalendarCheckInView.cs b/Web/Resgrid.Web/Areas/User/Models/Calendar/CalendarCheckInView.cs new file mode 100644 index 00000000..497d04fd --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/Calendar/CalendarCheckInView.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using Resgrid.Model; + +namespace Resgrid.Web.Areas.User.Models.Calendar +{ + public class CalendarCheckInView + { + public CalendarItem CalendarItem { get; set; } + public CalendarItemCheckIn UserCheckIn { get; set; } + public List CheckIns { get; set; } + public bool IsAdmin { get; set; } + public List PersonnelNames { get; set; } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Models/Calendar/CalendarItemView.cs b/Web/Resgrid.Web/Areas/User/Models/Calendar/CalendarItemView.cs index 15cc9d27..86aec7d0 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Calendar/CalendarItemView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Calendar/CalendarItemView.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using Resgrid.Model; namespace Resgrid.Web.Areas.User.Models.Calendar @@ -13,5 +14,9 @@ public class CalendarItemView public bool CanEdit { get; set; } /// URL to download a single-event .ics file for this calendar item via the v4 API. public string ExportIcsUrl { get; set; } + public CalendarItemCheckIn UserCheckIn { get; set; } + public List CheckIns { get; set; } + public bool IsAdmin { get; set; } + public List PersonnelNames { get; set; } } } diff --git a/Web/Resgrid.Web/Areas/User/Models/Calendar/EditCalendarCheckInView.cs b/Web/Resgrid.Web/Areas/User/Models/Calendar/EditCalendarCheckInView.cs new file mode 100644 index 00000000..34f95843 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/Calendar/EditCalendarCheckInView.cs @@ -0,0 +1,9 @@ +using Resgrid.Model; + +namespace Resgrid.Web.Areas.User.Models.Calendar +{ + public class EditCalendarCheckInView + { + public CalendarItemCheckIn CheckIn { get; set; } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Models/Calendar/SignupSheetView.cs b/Web/Resgrid.Web/Areas/User/Models/Calendar/SignupSheetView.cs new file mode 100644 index 00000000..1b0e3804 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/Calendar/SignupSheetView.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using Resgrid.Model; + +namespace Resgrid.Web.Areas.User.Models.Calendar +{ + public class SignupSheetView + { + public CalendarItem CalendarItem { get; set; } + public Department Department { get; set; } + public List PersonnelNames { get; set; } + public int TotalRows { get; set; } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Models/Reports/EventAttendance/EventAttendanceDetailView.cs b/Web/Resgrid.Web/Areas/User/Models/Reports/EventAttendance/EventAttendanceDetailView.cs new file mode 100644 index 00000000..1fab2b7d --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/Reports/EventAttendance/EventAttendanceDetailView.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using Resgrid.Model; + +namespace Resgrid.Web.Areas.User.Models.Reports.EventAttendance +{ + public class EventAttendanceDetailView + { + public DateTime RunOn { get; set; } + public Department Department { get; set; } + public DateTime Start { get; set; } + public DateTime End { get; set; } + public string Name { get; set; } + public string PersonnelName { get; set; } + public List Details { get; set; } + public double TotalSeconds { get; set; } + + public TimeSpan GetTotalTimeSpan() + { + return TimeSpan.FromSeconds(TotalSeconds); + } + } + + public class EventAttendanceDetail + { + public string EventTitle { get; set; } + public DateTime CheckInTime { get; set; } + public DateTime? CheckOutTime { get; set; } + public double DurationSeconds { get; set; } + public bool IsManualOverride { get; set; } + public string CheckInNote { get; set; } + public string CheckOutNote { get; set; } + public string CheckInByName { get; set; } + public string CheckOutByName { get; set; } + public string CheckInLatitude { get; set; } + public string CheckInLongitude { get; set; } + public string CheckOutLatitude { get; set; } + public string CheckOutLongitude { get; set; } + + public TimeSpan GetTimeSpan() + { + return TimeSpan.FromSeconds(DurationSeconds); + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Models/Reports/EventAttendance/EventAttendanceView.cs b/Web/Resgrid.Web/Areas/User/Models/Reports/EventAttendance/EventAttendanceView.cs new file mode 100644 index 00000000..5b311cbb --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/Reports/EventAttendance/EventAttendanceView.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using Resgrid.Model; + +namespace Resgrid.Web.Areas.User.Models.Reports.EventAttendance +{ + public class EventAttendanceView + { + public DateTime RunOn { get; set; } + public Department Department { get; set; } + public DateTime Start { get; set; } + public DateTime End { get; set; } + public string Name { get; set; } + public List EventHours { get; set; } + } + + public class PersonnelEventHours + { + public string ID { get; set; } + public string Name { get; set; } + public string Group { get; set; } + public int TotalEvents { get; set; } + public int MissedEvents { get; set; } + public double TotalSeconds { get; set; } + + public TimeSpan GetTimeSpan() + { + return TimeSpan.FromSeconds(TotalSeconds); + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Models/Reports/Params/EventAttendanceReportParams.cs b/Web/Resgrid.Web/Areas/User/Models/Reports/Params/EventAttendanceReportParams.cs new file mode 100644 index 00000000..360cba8e --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/Reports/Params/EventAttendanceReportParams.cs @@ -0,0 +1,13 @@ +using System; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace Resgrid.Web.Areas.User.Models.Reports.Params +{ + public class EventAttendanceReportParams + { + public string UserId { get; set; } + public SelectList Users { get; set; } + public DateTime Start { get; set; } + public DateTime End { get; set; } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Calendar/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Calendar/Edit.cshtml index f1b80059..48518d5c 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Calendar/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Calendar/Edit.cshtml @@ -330,6 +330,16 @@ +
+ +
+ +
+
@@ -362,11 +372,14 @@
-
- +
+
+
+

+
@@ -390,4 +403,26 @@ + } diff --git a/Web/Resgrid.Web/Areas/User/Views/Calendar/EditCheckIn.cshtml b/Web/Resgrid.Web/Areas/User/Views/Calendar/EditCheckIn.cshtml new file mode 100644 index 00000000..bf5ef60b --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Calendar/EditCheckIn.cshtml @@ -0,0 +1,84 @@ +@using Resgrid.Framework +@model Resgrid.Web.Areas.User.Models.Calendar.EditCalendarCheckInView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + @localizer["EditCheckInTimes"]; +} + +
+
+

@localizer["EditCheckInTimes"]

+ +
+
+ +
+
+
+
+
+
+ @Html.AntiForgeryToken() + + +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+ +
+
+ @commonLocalizer["Cancel"] + +
+
+
+
+
+
+
+
+ +@section Scripts +{ + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Calendar/New.cshtml b/Web/Resgrid.Web/Areas/User/Views/Calendar/New.cshtml index ec6f0462..bf5af023 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Calendar/New.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Calendar/New.cshtml @@ -287,6 +287,16 @@
+
+ +
+ +
+
@@ -319,11 +329,14 @@
-
- +
+
+
+

+
@@ -342,4 +355,26 @@ @section Scripts { + } diff --git a/Web/Resgrid.Web/Areas/User/Views/Calendar/SignupSheet.cshtml b/Web/Resgrid.Web/Areas/User/Views/Calendar/SignupSheet.cshtml new file mode 100644 index 00000000..2d88ed03 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Calendar/SignupSheet.cshtml @@ -0,0 +1,173 @@ +@using Resgrid.Model.Helpers +@using Resgrid.Framework +@model Resgrid.Web.Areas.User.Models.Calendar.SignupSheetView +@{ + Layout = null; + var attendeeList = Model.CalendarItem.Attendees?.ToList() ?? new List(); + var blankRows = Model.TotalRows - attendeeList.Count; + if (blankRows < 0) blankRows = 0; +} + + + + + Signup Sheet — @Model.CalendarItem.Title + + + + +
+

@Model.CalendarItem.Title

+
+ Date: + @if (Model.CalendarItem.IsAllDay) + { + @Model.CalendarItem.Start.TimeConverter(Model.Department).ToString("dddd, MMMM d, yyyy") + } + else + { + @Model.CalendarItem.Start.TimeConverter(Model.Department).ToString("dddd, MMMM d, yyyy") + } + + Time: + @if (Model.CalendarItem.IsAllDay) + { + @:All Day + } + else + { + @Model.CalendarItem.Start.TimeConverter(Model.Department).ToString("h:mm tt") + @: – @Model.CalendarItem.End.TimeConverter(Model.Department).ToString("h:mm tt") + } + + @if (!string.IsNullOrWhiteSpace(Model.CalendarItem.Location)) + { + Location: @Model.CalendarItem.Location + } +
+ @if (!string.IsNullOrWhiteSpace(Model.CalendarItem.Description)) + { +
@Html.Raw(StringHelpers.SanitizeHtmlInString(Model.CalendarItem.Description))
+ } +
+ + + + + + + + + + + + + + @{ + int rowNum = 1; + } + @foreach (var attendee in attendeeList) + { + var personName = Model.PersonnelNames?.FirstOrDefault(x => x.UserId == attendee.UserId); + + + + + + + + + rowNum++; + } + @for (int i = 0; i < blankRows; i++) + { + + + + + + + + + rowNum++; + } + +
#Name (Print)SignatureTime InTime OutNotes
@rowNum@(personName?.Name ?? attendee.UserId)
@rowNum
+ + + + diff --git a/Web/Resgrid.Web/Areas/User/Views/Calendar/View.cshtml b/Web/Resgrid.Web/Areas/User/Views/Calendar/View.cshtml index 757b54db..f91516b3 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Calendar/View.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Calendar/View.cshtml @@ -122,6 +122,13 @@ @localizer["DownloadIcs"] } + @if (Model.CanEdit) + { + + @localizer["PrintSignupSheet"] + + }
@@ -330,8 +337,8 @@ - @* Signup form *@ - @if (Model.CalendarItem.SignupType != 0 && !Model.CalendarItem.IsUserAttending(Model.UserId) && Model.CalendarItem.Start >= DateTime.UtcNow) + @* Signup form — admins/creators can sign up people even after the event has passed *@ + @if (Model.CalendarItem.SignupType != 0 && !Model.CalendarItem.IsUserAttending(Model.UserId) && (Model.CalendarItem.Start >= DateTime.UtcNow || Model.IsAdmin)) {
@@ -362,6 +369,205 @@
} + @* Self Check-In / Check-Out *@ + @if (Model.CalendarItem.CheckInType != (int)Resgrid.Model.CalendarItemCheckInTypes.Disabled) + { + @* Show self check-in panel if SelfCheckIn mode, or if AdminOnly and user is admin/creator/group-admin *@ + var showSelfPanel = Model.CalendarItem.CheckInType == (int)Resgrid.Model.CalendarItemCheckInTypes.SelfCheckIn || Model.IsAdmin; + var checkInWindowOpen = DateTime.UtcNow >= Model.CalendarItem.Start.AddMinutes(-15); + var eventIsPast = DateTime.UtcNow > Model.CalendarItem.End; + @* Admins/creators can always check in/out (even after event has passed) for bookkeeping *@ + var adminBypassWindow = Model.IsAdmin; + if (showSelfPanel) + { +
+
+
+
+
@localizer["CheckIn"] / @localizer["CheckOut"]
+
+
+ @if (Model.UserCheckIn == null && (checkInWindowOpen || adminBypassWindow)) + { + @using (Html.BeginForm("CheckIn", "Calendar", FormMethod.Post, new { area = "User" })) + { + @Html.AntiForgeryToken() + +
+ + +
+ + } + } + else if (Model.UserCheckIn == null && !checkInWindowOpen && !adminBypassWindow) + { +

@localizer["CheckInNotYetAvailable"]

+ } + else if (Model.UserCheckIn != null && !Model.UserCheckIn.CheckOutTime.HasValue) + { +

@localizer["CheckInTime"]: @Model.UserCheckIn.CheckInTime.TimeConverterToString(Model.Department)

+ @using (Html.BeginForm("CheckOut", "Calendar", FormMethod.Post, new { area = "User" })) + { + @Html.AntiForgeryToken() + +
+ + +
+ + } + } + else if (Model.UserCheckIn != null && Model.UserCheckIn.CheckOutTime.HasValue) + { +

@localizer["CheckInTime"]: @Model.UserCheckIn.CheckInTime.TimeConverterToString(Model.Department)

+

@localizer["CheckOutTime"]: @Model.UserCheckIn.CheckOutTime.Value.TimeConverterToString(Model.Department)

+

@localizer["Duration"]: @Model.UserCheckIn.GetDuration()?.ToString(@"h\h\ m\m")

+ } +
+
+
+
+ } + @* Attendance Check-Ins Table *@ + @if (Model.CheckIns != null && Model.CheckIns.Any()) + { +
+
+
+
+
@localizer["EventAttendanceReport"]
+
+ @Model.CheckIns.Count +
+
+
+
+ + + + + + + + + + @if (Model.IsAdmin) + { + + } + + + + @foreach (var ci in Model.CheckIns) + { + var personName = Model.PersonnelNames?.FirstOrDefault(x => x.UserId == ci.UserId); + + + + + + + + @if (Model.IsAdmin) + { + + } + + } + +
@commonLocalizer["Name"]@localizer["CheckInTime"]@localizer["CheckOutTime"]@localizer["Duration"]@localizer["CheckInNote"]@localizer["CheckOutNote"]
+ @(personName?.Name ?? ci.UserId) + @if (!string.IsNullOrWhiteSpace(ci.CheckInByUserId)) + { + var byName = Model.PersonnelNames?.FirstOrDefault(x => x.UserId == ci.CheckInByUserId); +
@localizer["CheckIn"] by @(byName?.Name ?? ci.CheckInByUserId) + } + @if (!string.IsNullOrWhiteSpace(ci.CheckOutByUserId)) + { + var byName = Model.PersonnelNames?.FirstOrDefault(x => x.UserId == ci.CheckOutByUserId); +
@localizer["CheckOut"] by @(byName?.Name ?? ci.CheckOutByUserId) + } +
@ci.CheckInTime.TimeConverterToString(Model.Department) + @if (ci.CheckOutTime.HasValue) + { + @ci.CheckOutTime.Value.TimeConverterToString(Model.Department) + } + else + { + @localizer["NotCheckedIn"] + } + + @if (ci.GetDuration() != null) + { + @ci.GetDuration()?.ToString(@"h\h\ m\m") + } + @ci.CheckInNote@ci.CheckOutNote + @if (!ci.CheckOutTime.HasValue) + { + @using (Html.BeginForm("AdminCheckOut", "Calendar", FormMethod.Post, new { area = "User", style = "display:inline;" })) + { + @Html.AntiForgeryToken() + + + + } + } + + + +
+
+
+
+
+
+ } + @* Admin Check-In on Behalf — admins can always check in users, even after the event *@ + @if (Model.IsAdmin && Model.CalendarItem.Attendees != null && Model.CalendarItem.Attendees.Any()) + { + var uncheckedAttendees = Model.CalendarItem.Attendees + .Where(a => Model.CheckIns == null || !Model.CheckIns.Any(c => c.UserId == a.UserId)) + .ToList(); + if (uncheckedAttendees.Any()) + { +
+
+
+
+
@localizer["AdminCheckIn"]
+
+
+

@localizer["CheckInOnBehalf"]

+
+ @foreach (var attendee in uncheckedAttendees) + { + var aName = Model.PersonnelNames?.FirstOrDefault(x => x.UserId == attendee.UserId); + @using (Html.BeginForm("AdminCheckIn", "Calendar", FormMethod.Post, new { area = "User", style = "display:inline;" })) + { + @Html.AntiForgeryToken() + + + + } + } +
+
+
+
+
+ } + } + } @* end CheckInType != Disabled *@ @section Scripts { diff --git a/Web/Resgrid.Web/Areas/User/Views/Reports/EventAttendanceDetailReport.cshtml b/Web/Resgrid.Web/Areas/User/Views/Reports/EventAttendanceDetailReport.cshtml new file mode 100644 index 00000000..e7eff850 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Reports/EventAttendanceDetailReport.cshtml @@ -0,0 +1,111 @@ +@using Resgrid.Model.Helpers +@using Resgrid.Web +@using Resgrid.Web.Helpers +@using Microsoft.Extensions.Localization +@model Resgrid.Web.Areas.User.Models.Reports.EventAttendance.EventAttendanceDetailView +@inject IStringLocalizer localizer +@{ + Layout = null; +} + + + + + + @localizer["EventAttendanceDetailTitle"] + + + + + + + + + + + +
+
+
+ +
+
+

@localizer["EventAttendanceDetailTitle"]

+

@Model.PersonnelName

+ @Model.Start.FormatForDepartment(Model.Department) - @Model.End.FormatForDepartment(Model.Department) +
+
+
+
+ + + + + + + + + + + + + + + + + @foreach (var detail in Model.Details) + { + + + + + + + + + + + + + } + +
@localizer["EventHeader"]@localizer["CheckInTimeHeader"]@localizer["CheckOutTimeHeader"]@localizer["DurationHeader"]@localizer["CheckInNoteHeader"]@localizer["CheckOutNoteHeader"]@localizer["CheckedInByHeader"]@localizer["CheckedOutByHeader"]@localizer["CheckInLocationHeader"]@localizer["CheckOutLocationHeader"]
@detail.EventTitle@detail.CheckInTime.FormatForDepartment(Model.Department)@(detail.CheckOutTime.HasValue ? detail.CheckOutTime.Value.FormatForDepartment(Model.Department) : localizer["NotCheckedOut"].Value)@detail.GetTimeSpan().ToString(@"d\d\:h\h\:m\m\:s\s", System.Globalization.CultureInfo.InvariantCulture)@detail.CheckInNote@detail.CheckOutNote@detail.CheckInByName@detail.CheckOutByName + @if (!string.IsNullOrWhiteSpace(detail.CheckInLatitude)) + { + @detail.CheckInLatitude, @detail.CheckInLongitude + } + + @if (!string.IsNullOrWhiteSpace(detail.CheckOutLatitude)) + { + @detail.CheckOutLatitude, @detail.CheckOutLongitude + } +
+
+
+
+
+ @localizer["TotalTimeLabel"]: @Model.GetTotalTimeSpan().ToString(@"d\d\:h\h\:m\m\:s\s", System.Globalization.CultureInfo.InvariantCulture) +
+
+
+
+ @Model.RunOn.FormatForDepartment(Model.Department) +
+
+
+ + + + + diff --git a/Web/Resgrid.Web/Areas/User/Views/Reports/EventAttendanceReport.cshtml b/Web/Resgrid.Web/Areas/User/Views/Reports/EventAttendanceReport.cshtml new file mode 100644 index 00000000..de295859 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Reports/EventAttendanceReport.cshtml @@ -0,0 +1,94 @@ +@using Resgrid.Model.Helpers +@using Resgrid.Web +@using Resgrid.Web.Helpers +@using Microsoft.Extensions.Localization +@model Resgrid.Web.Areas.User.Models.Reports.EventAttendance.EventAttendanceView +@inject IStringLocalizer localizer +@{ + Layout = null; +} + + + + + + @localizer["EventAttendanceReportTitle"] + + + + + + + + + + + +
+
+
+ +
+
+

@localizer["EventAttendanceReportTitle"]

+ @Model.Start.FormatForDepartment(Model.Department) - @Model.End.FormatForDepartment(Model.Department) +
+
+
+
+

@localizer["EventAttendanceHours"]

+
+
+
+
+ + + + + + + + + + + + + @foreach (var person in Model.EventHours) + { + + + + + + + + + } + +
@localizer["NameHeader"]@localizer["GroupHeader"]@localizer["EventsAttendedHeader"]@localizer["EventsMissedHeader"]@localizer["TotalHoursHeader"]
@person.Name@person.Group@person.TotalEvents@(person.MissedEvents > 0 ? person.MissedEvents.ToString() : "0")@person.GetTimeSpan().ToString(@"d\d\:h\h\:m\m\:s\s", System.Globalization.CultureInfo.InvariantCulture) + @localizer["DetailsButton"] +
+
+
+
+
+ @Model.RunOn.FormatForDepartment(Model.Department) +
+
+
+ + + + + diff --git a/Web/Resgrid.Web/Areas/User/Views/Reports/EventAttendanceReportParams.cshtml b/Web/Resgrid.Web/Areas/User/Views/Reports/EventAttendanceReportParams.cshtml new file mode 100644 index 00000000..42d3a65f --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Reports/EventAttendanceReportParams.cshtml @@ -0,0 +1,81 @@ +@using Resgrid.Framework +@using Resgrid.Model +@using Resgrid.Web.Helpers +@using Microsoft.Extensions.Localization +@model Resgrid.Web.Areas.User.Models.Reports.Params.EventAttendanceReportParams +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["EventAttendanceReportName"]; +} + +
+
+

@localizer["EventAttendanceReportName"]

+ +
+
+ +
+
+
+
+
+
+ +
+
+ @Html.AntiForgeryToken() +
+
+
+ +
+ +
+ @Html.TextBoxFor(m => m.Start, new { style = "width:250px;", onkeydown = "javascript:return false;" }) +
+
+
+ +
+ @Html.TextBoxFor(m => m.End, new { style = "width:250px;", onkeydown = "javascript:return false;" }) +
+
+ +
+
+ @commonLocalizer["Cancel"] + +
+
+
+
+
+
+
+
+ + +@section Scripts +{ + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Reports/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Reports/Index.cshtml index c789b160..b811b2de 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Reports/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Reports/Index.cshtml @@ -176,6 +176,17 @@ } + + + @localizer["EventAttendanceReportName"] + + + @localizer["EventAttendanceReportDescription"] + + + @localizer["ViewReport"] + + From baef9594f28764bacdf93d10aeb98e66c5c118ec Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 28 Mar 2026 15:00:31 -0700 Subject: [PATCH 3/7] RE1-T107 RE1-T108 PR#312 fixes --- .../Areas/User/Calendar/Calendar.ar.resx | 3 + .../Areas/User/Calendar/Calendar.de.resx | 3 + .../Areas/User/Calendar/Calendar.en.resx | 3 + .../Areas/User/Calendar/Calendar.es.resx | 3 + .../Areas/User/Calendar/Calendar.fr.resx | 3 + .../Areas/User/Calendar/Calendar.it.resx | 3 + .../Areas/User/Calendar/Calendar.pl.resx | 3 + .../Areas/User/Calendar/Calendar.sv.resx | 3 + .../Areas/User/Calendar/Calendar.uk.resx | 3 + Core/Resgrid.Model/CheckInTimerConfig.cs | 2 + Core/Resgrid.Model/CheckInTimerOverride.cs | 2 + Core/Resgrid.Model/ResolvedCheckInTimer.cs | 2 + .../Services/ICheckInTimerService.cs | 4 +- Core/Resgrid.Services/AuthorizationService.cs | 20 +- Core/Resgrid.Services/CalendarService.cs | 6 + Core/Resgrid.Services/CheckInTimerService.cs | 174 +++++++++++++++-- Core/Resgrid.Services/CustomStateService.cs | 7 + Core/Resgrid.Services/ServicesModule.cs | 2 +- ...ixCheckInTimerNullableUniqueConstraints.cs | 83 ++++++++ ...M0059_AddActiveForStatesToCheckInTimers.cs | 23 +++ ...CheckInTimerNullableUniqueConstraintsPg.cs | 84 +++++++++ ...059_AddActiveForStatesToCheckInTimersPg.cs | 23 +++ ...ctCalendarItemCheckInByItemAndUserQuery.cs | 2 +- ...alendarItemCheckInsByDeptDateRangeQuery.cs | 2 +- ...SelectCalendarItemCheckInsByItemIdQuery.cs | 2 +- ...alendarItemCheckInsByUserDateRangeQuery.cs | 2 +- .../SelectCheckInRecordsByCallIdQuery.cs | 2 +- ...nRecordsByDepartmentIdAndDateRangeQuery.cs | 2 +- ...InTimerConfigByDepartmentAndTargetQuery.cs | 2 +- ...tCheckInTimerConfigsByDepartmentIdQuery.cs | 2 +- ...heckInTimerOverridesByDepartmentIdQuery.cs | 2 +- .../SelectLastCheckInForUnitOnCallQuery.cs | 2 +- .../SelectLastCheckInForUserOnCallQuery.cs | 2 +- ...electMatchingCheckInTimerOverridesQuery.cs | 2 +- .../Services/CheckInTimerServiceTests.cs | 177 +++++++++++++++++- .../Controllers/v4/CalendarController.cs | 10 +- .../Controllers/v4/CheckInTimersController.cs | 11 +- Web/Resgrid.Web.Services/Hubs/EventingHub.cs | 29 ++- .../v4/CheckInTimers/CheckInTimerModels.cs | 15 ++ .../User/Controllers/CalendarController.cs | 4 +- .../User/Controllers/DepartmentController.cs | 60 +++++- .../User/Controllers/DispatchController.cs | 1 + .../User/Controllers/ReportsController.cs | 12 +- .../User/Models/Calendar/SignupSheetView.cs | 2 +- .../Areas/User/Views/Calendar/Edit.cshtml | 15 +- .../User/Views/Calendar/EditCheckIn.cshtml | 8 +- .../Areas/User/Views/Calendar/New.cshtml | 15 +- .../User/Views/Calendar/SignupSheet.cshtml | 2 +- .../Areas/User/Views/Calendar/View.cshtml | 10 +- .../Views/Department/DispatchSettings.cshtml | 122 +++++++++++- .../User/Views/Dispatch/CallExport.cshtml | 1 - .../Areas/User/Views/Dispatch/NewCall.cshtml | 2 +- .../Areas/User/Views/Dispatch/ViewCall.cshtml | 1 + .../Reports/EventAttendanceReport.cshtml | 2 +- .../Areas/User/Views/Templates/Edit.cshtml | 2 +- .../resgrid.dispatch.checkin-timers.js | 1 + .../dispatch/resgrid.dispatch.newcall.js | 6 +- 57 files changed, 893 insertions(+), 98 deletions(-) create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0058_FixCheckInTimerNullableUniqueConstraints.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0059_AddActiveForStatesToCheckInTimers.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0058_FixCheckInTimerNullableUniqueConstraintsPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0059_AddActiveForStatesToCheckInTimersPg.cs diff --git a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.ar.resx b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.ar.resx index f568c61e..842540f4 100644 --- a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.ar.resx @@ -351,6 +351,9 @@ لم يتم تسجيل الحضور + + Not checked out + تسجيل متأخر diff --git a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.de.resx b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.de.resx index 39008166..2e483357 100644 --- a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.de.resx @@ -302,6 +302,9 @@ Nicht eingecheckt + + Not checked out + Verspätete Anmeldung diff --git a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.en.resx b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.en.resx index 550fcc00..551209e8 100644 --- a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.en.resx @@ -351,6 +351,9 @@ Not checked in + + Not checked out + Late RSVP diff --git a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.es.resx b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.es.resx index 447b4045..536ad93d 100644 --- a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.es.resx @@ -348,6 +348,9 @@ Sin registro de entrada + + Not checked out + RSVP Tardío diff --git a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.fr.resx b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.fr.resx index 7ed28a6e..57ba01fc 100644 --- a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.fr.resx @@ -302,6 +302,9 @@ Non pointé + + Not checked out + Inscription tardive diff --git a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.it.resx b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.it.resx index cc3eb144..556a7b5b 100644 --- a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.it.resx @@ -302,6 +302,9 @@ Entrata non registrata + + Not checked out + RSVP in Ritardo diff --git a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.pl.resx b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.pl.resx index 06ea4fe9..06914a15 100644 --- a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.pl.resx @@ -302,6 +302,9 @@ Niezameldowany + + Not checked out + Późna rejestracja diff --git a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.sv.resx b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.sv.resx index 9fab8d8c..dd55cb74 100644 --- a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.sv.resx @@ -302,6 +302,9 @@ Inte incheckad + + Not checked out + Sen anmälan diff --git a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.uk.resx b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.uk.resx index 5029ded5..32666144 100644 --- a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.uk.resx @@ -302,6 +302,9 @@ Не зареєстровано + + Not checked out + Пізня реєстрація diff --git a/Core/Resgrid.Model/CheckInTimerConfig.cs b/Core/Resgrid.Model/CheckInTimerConfig.cs index ce4bf7e2..846e5c8e 100644 --- a/Core/Resgrid.Model/CheckInTimerConfig.cs +++ b/Core/Resgrid.Model/CheckInTimerConfig.cs @@ -27,6 +27,8 @@ public class CheckInTimerConfig : IEntity public DateTime? UpdatedOn { get; set; } + public string ActiveForStates { get; set; } + [NotMapped] public string TableName => "CheckInTimerConfigs"; diff --git a/Core/Resgrid.Model/CheckInTimerOverride.cs b/Core/Resgrid.Model/CheckInTimerOverride.cs index 416cf582..7d6dbc8d 100644 --- a/Core/Resgrid.Model/CheckInTimerOverride.cs +++ b/Core/Resgrid.Model/CheckInTimerOverride.cs @@ -31,6 +31,8 @@ public class CheckInTimerOverride : IEntity public DateTime? UpdatedOn { get; set; } + public string ActiveForStates { get; set; } + [NotMapped] public string TableName => "CheckInTimerOverrides"; diff --git a/Core/Resgrid.Model/ResolvedCheckInTimer.cs b/Core/Resgrid.Model/ResolvedCheckInTimer.cs index 19186403..13e9abdb 100644 --- a/Core/Resgrid.Model/ResolvedCheckInTimer.cs +++ b/Core/Resgrid.Model/ResolvedCheckInTimer.cs @@ -15,5 +15,7 @@ public class ResolvedCheckInTimer public int WarningThresholdMinutes { get; set; } public bool IsFromOverride { get; set; } + + public string ActiveForStates { get; set; } } } diff --git a/Core/Resgrid.Model/Services/ICheckInTimerService.cs b/Core/Resgrid.Model/Services/ICheckInTimerService.cs index 09a5bde2..af58eb92 100644 --- a/Core/Resgrid.Model/Services/ICheckInTimerService.cs +++ b/Core/Resgrid.Model/Services/ICheckInTimerService.cs @@ -9,12 +9,12 @@ public interface ICheckInTimerService // Configuration CRUD Task> GetTimerConfigsForDepartmentAsync(int departmentId); Task SaveTimerConfigAsync(CheckInTimerConfig config, CancellationToken cancellationToken = default); - Task DeleteTimerConfigAsync(string configId, CancellationToken cancellationToken = default); + Task DeleteTimerConfigAsync(string configId, int departmentId, CancellationToken cancellationToken = default); // Override CRUD Task> GetTimerOverridesForDepartmentAsync(int departmentId); Task SaveTimerOverrideAsync(CheckInTimerOverride ovr, CancellationToken cancellationToken = default); - Task DeleteTimerOverrideAsync(string overrideId, CancellationToken cancellationToken = default); + Task DeleteTimerOverrideAsync(string overrideId, int departmentId, CancellationToken cancellationToken = default); // Timer Resolution Task> ResolveAllTimersForCallAsync(Call call); diff --git a/Core/Resgrid.Services/AuthorizationService.cs b/Core/Resgrid.Services/AuthorizationService.cs index e9285857..9452ac69 100644 --- a/Core/Resgrid.Services/AuthorizationService.cs +++ b/Core/Resgrid.Services/AuthorizationService.cs @@ -1579,13 +1579,25 @@ public async Task CanUserAdminCheckInCalendarEventAsync(string userId, int if (item.CheckInType == (int)CalendarItemCheckInTypes.Disabled) return false; - // Department admins can check in anyone + // Department admins can check in users in their department if (department.IsUserAnAdmin(userId)) - return true; + { + var targetDepartment = await _departmentsService.GetDepartmentByUserIdAsync(targetUserId); + if (targetDepartment != null && targetDepartment.DepartmentId == department.DepartmentId) + return true; + + return false; + } - // Calendar item creator can check in attendees + // Calendar item creator can check in users in their department if (!string.IsNullOrWhiteSpace(item.CreatorUserId) && item.CreatorUserId == userId) - return true; + { + var targetDepartment = await _departmentsService.GetDepartmentByUserIdAsync(targetUserId); + if (targetDepartment != null && targetDepartment.DepartmentId == department.DepartmentId) + return true; + + return false; + } // Group admins can check in users in their group or child groups var adminGroup = await _departmentGroupsService.GetGroupForUserAsync(userId, department.DepartmentId); diff --git a/Core/Resgrid.Services/CalendarService.cs b/Core/Resgrid.Services/CalendarService.cs index 22c8d479..46b0c326 100644 --- a/Core/Resgrid.Services/CalendarService.cs +++ b/Core/Resgrid.Services/CalendarService.cs @@ -789,6 +789,9 @@ public async Task CheckOutFromEventAsync(int calendarItemId if (existing == null) return null; + if (existing.CheckOutTime.HasValue) + return existing; + existing.CheckOutTime = DateTime.UtcNow; existing.CheckOutByUserId = adminUserId; existing.CheckOutNote = note; @@ -806,6 +809,9 @@ public async Task UpdateCheckInTimesAsync(string checkInId, if (existing == null) return null; + if (checkOutTime.HasValue && checkOutTime.Value < checkInTime) + throw new ArgumentException("Check-out time cannot be earlier than check-in time."); + existing.CheckInTime = checkInTime; existing.CheckOutTime = checkOutTime; existing.IsManualOverride = true; diff --git a/Core/Resgrid.Services/CheckInTimerService.cs b/Core/Resgrid.Services/CheckInTimerService.cs index e1670008..c4d4fed8 100644 --- a/Core/Resgrid.Services/CheckInTimerService.cs +++ b/Core/Resgrid.Services/CheckInTimerService.cs @@ -14,15 +14,24 @@ public class CheckInTimerService : ICheckInTimerService private readonly ICheckInTimerConfigRepository _configRepository; private readonly ICheckInTimerOverrideRepository _overrideRepository; private readonly ICheckInRecordRepository _recordRepository; + private readonly IActionLogsService _actionLogsService; + private readonly IUnitsService _unitsService; + private readonly ICallsService _callsService; public CheckInTimerService( ICheckInTimerConfigRepository configRepository, ICheckInTimerOverrideRepository overrideRepository, - ICheckInRecordRepository recordRepository) + ICheckInRecordRepository recordRepository, + IActionLogsService actionLogsService, + IUnitsService unitsService, + ICallsService callsService) { _configRepository = configRepository; _overrideRepository = overrideRepository; _recordRepository = recordRepository; + _actionLogsService = actionLogsService; + _unitsService = unitsService; + _callsService = callsService; } #region Configuration CRUD @@ -36,19 +45,30 @@ public async Task> GetTimerConfigsForDepartmentAsync(in public async Task SaveTimerConfigAsync(CheckInTimerConfig config, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(config.CheckInTimerConfigId)) + { config.CreatedOn = DateTime.UtcNow; + } else + { + var existing = await _configRepository.GetByIdAsync(config.CheckInTimerConfigId); + if (existing != null && existing.DepartmentId != config.DepartmentId) + throw new UnauthorizedAccessException("Cannot modify a timer config belonging to another department."); + config.UpdatedOn = DateTime.UtcNow; + } return await _configRepository.SaveOrUpdateAsync(config, cancellationToken); } - public async Task DeleteTimerConfigAsync(string configId, CancellationToken cancellationToken = default) + public async Task DeleteTimerConfigAsync(string configId, int departmentId, CancellationToken cancellationToken = default) { var config = await _configRepository.GetByIdAsync(configId); if (config == null) return false; + if (config.DepartmentId != departmentId) + throw new UnauthorizedAccessException("Cannot delete a timer config belonging to another department."); + return await _configRepository.DeleteAsync(config, cancellationToken); } @@ -65,19 +85,30 @@ public async Task> GetTimerOverridesForDepartmentAsyn public async Task SaveTimerOverrideAsync(CheckInTimerOverride ovr, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(ovr.CheckInTimerOverrideId)) + { ovr.CreatedOn = DateTime.UtcNow; + } else + { + var existing = await _overrideRepository.GetByIdAsync(ovr.CheckInTimerOverrideId); + if (existing != null && existing.DepartmentId != ovr.DepartmentId) + throw new UnauthorizedAccessException("Cannot modify a timer override belonging to another department."); + ovr.UpdatedOn = DateTime.UtcNow; + } return await _overrideRepository.SaveOrUpdateAsync(ovr, cancellationToken); } - public async Task DeleteTimerOverrideAsync(string overrideId, CancellationToken cancellationToken = default) + public async Task DeleteTimerOverrideAsync(string overrideId, int departmentId, CancellationToken cancellationToken = default) { var ovr = await _overrideRepository.GetByIdAsync(overrideId); if (ovr == null) return false; + if (ovr.DepartmentId != departmentId) + throw new UnauthorizedAccessException("Cannot delete a timer override belonging to another department."); + return await _overrideRepository.DeleteAsync(ovr, cancellationToken); } @@ -106,14 +137,17 @@ public async Task> ResolveAllTimersForCallAsync(Call // First, populate from defaults foreach (var def in defaultList) { - var key = $"{def.TimerTargetType}_{def.UnitTypeId}"; + var targetId = def.UnitTypeId?.ToString(); + var key = $"{def.TimerTargetType}_{def.UnitTypeId}_{targetId}"; resolved[key] = new ResolvedCheckInTimer { TargetType = def.TimerTargetType, UnitTypeId = def.UnitTypeId, + TargetEntityId = targetId, DurationMinutes = def.DurationMinutes, WarningThresholdMinutes = def.WarningThresholdMinutes, - IsFromOverride = false + IsFromOverride = false, + ActiveForStates = def.ActiveForStates }; } @@ -133,7 +167,11 @@ public async Task> ResolveAllTimersForCallAsync(Call foreach (var scored in scoredOverrides) { var o = scored.Override; - var key = $"{o.TimerTargetType}_{o.UnitTypeId}"; + if (!o.IsEnabled) + continue; + + var targetId = o.UnitTypeId?.ToString(); + var key = $"{o.TimerTargetType}_{o.UnitTypeId}_{targetId}"; // Only apply if this is the best scoring override for this key if (!resolved.ContainsKey(key) || !resolved[key].IsFromOverride) @@ -142,9 +180,11 @@ public async Task> ResolveAllTimersForCallAsync(Call { TargetType = o.TimerTargetType, UnitTypeId = o.UnitTypeId, + TargetEntityId = targetId, DurationMinutes = o.DurationMinutes, WarningThresholdMinutes = o.WarningThresholdMinutes, - IsFromOverride = true + IsFromOverride = true, + ActiveForStates = o.ActiveForStates }; } } @@ -189,6 +229,11 @@ public async Task> GetActiveTimerStatusesForCallAsync(C if (!resolvedTimers.Any()) return new List(); + // Filter timers by ActiveForStates against current dispatched entity states + resolvedTimers = await FilterTimersByActiveStatesAsync(resolvedTimers, call); + if (!resolvedTimers.Any()) + return new List(); + var checkIns = await _recordRepository.GetByCallIdAsync(call.CallId); var checkInList = checkIns?.ToList() ?? new List(); @@ -197,13 +242,18 @@ public async Task> GetActiveTimerStatusesForCallAsync(C foreach (var timer in resolvedTimers) { - // Find the latest check-in matching this timer target - var relevantCheckIns = checkInList - .Where(c => c.CheckInType == timer.TargetType) + // Find the latest check-in matching this timer's type and concrete target + var matchingCheckIns = checkInList + .Where(c => c.CheckInType == timer.TargetType); + + if (timer.UnitTypeId.HasValue) + matchingCheckIns = matchingCheckIns.Where(c => c.UnitId == timer.UnitTypeId); + + var latestCheckIn = matchingCheckIns .OrderByDescending(c => c.Timestamp) .FirstOrDefault(); - var baseTime = relevantCheckIns?.Timestamp ?? call.LoggedOn; + var baseTime = latestCheckIn?.Timestamp ?? call.LoggedOn; var elapsed = (now - baseTime).TotalMinutes; string status; @@ -219,8 +269,8 @@ public async Task> GetActiveTimerStatusesForCallAsync(C TargetType = timer.TargetType, TargetEntityId = timer.TargetEntityId, TargetName = timer.TargetName ?? ((CheckInTimerTargetType)timer.TargetType).ToString(), - UnitId = timer.UnitTypeId.HasValue ? timer.UnitTypeId : null, - LastCheckIn = relevantCheckIns?.Timestamp, + UnitId = latestCheckIn?.UnitId, + LastCheckIn = latestCheckIn?.Timestamp, DurationMinutes = timer.DurationMinutes, WarningThresholdMinutes = timer.WarningThresholdMinutes, ElapsedMinutes = Math.Round(elapsed, 1), @@ -232,5 +282,103 @@ public async Task> GetActiveTimerStatusesForCallAsync(C } #endregion Timer Status Computation + + #region State Filtering + + private async Task> FilterTimersByActiveStatesAsync(List timers, Call call) + { + var timersWithStateFilter = timers.Where(t => !string.IsNullOrWhiteSpace(t.ActiveForStates)).ToList(); + if (!timersWithStateFilter.Any()) + return timers; // No filtering needed + + // Populate call dispatches if not already loaded + if (call.Dispatches == null || call.UnitDispatches == null) + call = await _callsService.PopulateCallData(call, true, false, false, false, true, false, false, false, false); + + // Build a set of current personnel action type IDs + var personnelStates = new Dictionary(); + if (call.Dispatches != null) + { + foreach (var dispatch in call.Dispatches) + { + var lastAction = await _actionLogsService.GetLastActionLogForUserAsync(dispatch.UserId); + personnelStates[dispatch.UserId] = lastAction?.ActionTypeId ?? (int)ActionTypes.StandingBy; + } + } + + // Build a set of current unit state IDs (keyed by UnitId) + var unitStates = new Dictionary(); + if (call.UnitDispatches != null) + { + foreach (var unitDispatch in call.UnitDispatches) + { + var lastState = await _unitsService.GetLastUnitStateByUnitIdAsync(unitDispatch.UnitId); + unitStates[unitDispatch.UnitId] = lastState?.State ?? (int)UnitStateTypes.Available; + } + } + + var result = new List(); + foreach (var timer in timers) + { + if (string.IsNullOrWhiteSpace(timer.ActiveForStates)) + { + result.Add(timer); // No filter = always active + continue; + } + + var allowedStates = ParseActiveForStates(timer.ActiveForStates); + + bool anyEntityMatches = false; + + if (timer.TargetType == (int)CheckInTimerTargetType.UnitType) + { + // Check unit states + foreach (var kvp in unitStates) + { + // If timer is for a specific UnitType, we'd need to check the unit's type + // For now, check all dispatched units + if (allowedStates.Contains(kvp.Value)) + { + anyEntityMatches = true; + break; + } + } + } + else + { + // Personnel-based timers (Personnel, IC, PAR, Hazmat, SectorRotation, Rehab) + foreach (var kvp in personnelStates) + { + if (allowedStates.Contains(kvp.Value)) + { + anyEntityMatches = true; + break; + } + } + } + + if (anyEntityMatches) + result.Add(timer); + } + + return result; + } + + private static HashSet ParseActiveForStates(string activeForStates) + { + var states = new HashSet(); + if (string.IsNullOrWhiteSpace(activeForStates)) + return states; + + foreach (var part in activeForStates.Split(',', StringSplitOptions.RemoveEmptyEntries)) + { + if (int.TryParse(part.Trim(), out int stateId)) + states.Add(stateId); + } + + return states; + } + + #endregion State Filtering } } diff --git a/Core/Resgrid.Services/CustomStateService.cs b/Core/Resgrid.Services/CustomStateService.cs index 52f9bd44..11f6d9e7 100644 --- a/Core/Resgrid.Services/CustomStateService.cs +++ b/Core/Resgrid.Services/CustomStateService.cs @@ -230,7 +230,14 @@ public async Task GetCustomDetailByIdAsync(int detailId) if (existingDetail != null) { + existingDetail.ButtonText = detail.ButtonText; + existingDetail.ButtonColor = detail.ButtonColor; + existingDetail.TextColor = detail.TextColor; + existingDetail.GpsRequired = detail.GpsRequired; + existingDetail.NoteType = detail.NoteType; + existingDetail.DetailType = detail.DetailType; existingDetail.Order = detail.Order; + existingDetail.BaseType = detail.BaseType; } } } diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index 59b19aa8..daad563b 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -86,7 +86,7 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().SingleInstance(); - builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().InstancePerLifetimeScope(); // UDF Services builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0058_FixCheckInTimerNullableUniqueConstraints.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0058_FixCheckInTimerNullableUniqueConstraints.cs new file mode 100644 index 00000000..0a88f5be --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0058_FixCheckInTimerNullableUniqueConstraints.cs @@ -0,0 +1,83 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(58)] + public class M0058_FixCheckInTimerNullableUniqueConstraints : Migration + { + public override void Up() + { + // Drop the existing unique constraints that prevent multiple NULLs + Delete.UniqueConstraint("UQ_CheckInTimerConfigs_Dept_Target_Unit") + .FromTable("CheckInTimerConfigs"); + + Delete.UniqueConstraint("UQ_CheckInTimerOverrides_Dept_Call_Target_Unit") + .FromTable("CheckInTimerOverrides"); + + // Replace with filtered unique indexes that only enforce uniqueness + // when nullable columns are NOT NULL, allowing multiple NULL rows. + Execute.Sql(@" + CREATE UNIQUE NONCLUSTERED INDEX UQ_CheckInTimerConfigs_Dept_Target_Unit + ON CheckInTimerConfigs (DepartmentId, TimerTargetType, UnitTypeId) + WHERE UnitTypeId IS NOT NULL; + "); + + Execute.Sql(@" + CREATE UNIQUE NONCLUSTERED INDEX UQ_CheckInTimerOverrides_Dept_Call_Target_Unit + ON CheckInTimerOverrides (DepartmentId, CallTypeId, CallPriority, TimerTargetType, UnitTypeId) + WHERE CallTypeId IS NOT NULL AND CallPriority IS NOT NULL AND UnitTypeId IS NOT NULL; + "); + + // Add compound indexes for "latest check-in" lookups by user and unit + Create.Index("IX_CheckInRecords_CallId_UserId_Timestamp") + .OnTable("CheckInRecords") + .OnColumn("CallId").Ascending() + .OnColumn("UserId").Ascending() + .OnColumn("Timestamp").Descending(); + + Create.Index("IX_CheckInRecords_CallId_UnitId_Timestamp") + .OnTable("CheckInRecords") + .OnColumn("CallId").Ascending() + .OnColumn("UnitId").Ascending() + .OnColumn("Timestamp").Descending(); + + // Add composite indexes for CalendarItemCheckIns query patterns + Create.Index("IX_CalendarItemCheckIns_CalendarItemId_CheckInTime") + .OnTable("CalendarItemCheckIns") + .OnColumn("CalendarItemId").Ascending() + .OnColumn("CheckInTime").Descending(); + + Create.Index("IX_CalendarItemCheckIns_DepartmentId_CheckInTime") + .OnTable("CalendarItemCheckIns") + .OnColumn("DepartmentId").Ascending() + .OnColumn("CheckInTime").Descending(); + + Create.Index("IX_CalendarItemCheckIns_DepartmentId_UserId_CheckInTime") + .OnTable("CalendarItemCheckIns") + .OnColumn("DepartmentId").Ascending() + .OnColumn("UserId").Ascending() + .OnColumn("CheckInTime").Descending(); + } + + public override void Down() + { + Delete.Index("IX_CalendarItemCheckIns_DepartmentId_UserId_CheckInTime").OnTable("CalendarItemCheckIns"); + Delete.Index("IX_CalendarItemCheckIns_DepartmentId_CheckInTime").OnTable("CalendarItemCheckIns"); + Delete.Index("IX_CalendarItemCheckIns_CalendarItemId_CheckInTime").OnTable("CalendarItemCheckIns"); + + Delete.Index("IX_CheckInRecords_CallId_UnitId_Timestamp").OnTable("CheckInRecords"); + Delete.Index("IX_CheckInRecords_CallId_UserId_Timestamp").OnTable("CheckInRecords"); + + Execute.Sql("DROP INDEX IF EXISTS UQ_CheckInTimerConfigs_Dept_Target_Unit ON CheckInTimerConfigs;"); + Execute.Sql("DROP INDEX IF EXISTS UQ_CheckInTimerOverrides_Dept_Call_Target_Unit ON CheckInTimerOverrides;"); + + Create.UniqueConstraint("UQ_CheckInTimerConfigs_Dept_Target_Unit") + .OnTable("CheckInTimerConfigs") + .Columns("DepartmentId", "TimerTargetType", "UnitTypeId"); + + Create.UniqueConstraint("UQ_CheckInTimerOverrides_Dept_Call_Target_Unit") + .OnTable("CheckInTimerOverrides") + .Columns("DepartmentId", "CallTypeId", "CallPriority", "TimerTargetType", "UnitTypeId"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0059_AddActiveForStatesToCheckInTimers.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0059_AddActiveForStatesToCheckInTimers.cs new file mode 100644 index 00000000..0ad95193 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0059_AddActiveForStatesToCheckInTimers.cs @@ -0,0 +1,23 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(59)] + public class M0059_AddActiveForStatesToCheckInTimers : Migration + { + public override void Up() + { + Alter.Table("CheckInTimerConfigs") + .AddColumn("ActiveForStates").AsString(200).Nullable(); + + Alter.Table("CheckInTimerOverrides") + .AddColumn("ActiveForStates").AsString(200).Nullable(); + } + + public override void Down() + { + Delete.Column("ActiveForStates").FromTable("CheckInTimerConfigs"); + Delete.Column("ActiveForStates").FromTable("CheckInTimerOverrides"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0058_FixCheckInTimerNullableUniqueConstraintsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0058_FixCheckInTimerNullableUniqueConstraintsPg.cs new file mode 100644 index 00000000..7e1f6367 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0058_FixCheckInTimerNullableUniqueConstraintsPg.cs @@ -0,0 +1,84 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(58)] + public class M0058_FixCheckInTimerNullableUniqueConstraintsPg : Migration + { + public override void Up() + { + // Drop the existing unique constraints that allow duplicate NULL rows + Delete.UniqueConstraint("uq_checkintimerconfigs_dept_target_unit") + .FromTable("checkintimerconfigs"); + + Delete.UniqueConstraint("uq_checkintimeroverrides_dept_call_target_unit") + .FromTable("checkintimeroverrides"); + + // Replace with NULLS NOT DISTINCT unique indexes (PostgreSQL 15+) + // so that NULL values are treated as equal for uniqueness checks, + // preventing duplicate "Any/None" rules. + Execute.Sql(@" + CREATE UNIQUE INDEX uq_checkintimerconfigs_dept_target_unit + ON checkintimerconfigs (departmentid, timertargettype, unittypeid) + NULLS NOT DISTINCT; + "); + + Execute.Sql(@" + CREATE UNIQUE INDEX uq_checkintimeroverrides_dept_call_target_unit + ON checkintimeroverrides (departmentid, calltypeid, callpriority, timertargettype, unittypeid) + NULLS NOT DISTINCT; + "); + + // Add compound indexes for "latest check-in" lookups by user and unit + Create.Index("ix_checkinrecords_callid_userid_timestamp") + .OnTable("checkinrecords") + .OnColumn("callid").Ascending() + .OnColumn("userid").Ascending() + .OnColumn("timestamp").Descending(); + + Create.Index("ix_checkinrecords_callid_unitid_timestamp") + .OnTable("checkinrecords") + .OnColumn("callid").Ascending() + .OnColumn("unitid").Ascending() + .OnColumn("timestamp").Descending(); + + // Add composite indexes for CalendarItemCheckIns query patterns + Create.Index("ix_calendaritemcheckins_calendaritemid_checkintime") + .OnTable("calendaritemcheckins") + .OnColumn("calendaritemid").Ascending() + .OnColumn("checkintime").Descending(); + + Create.Index("ix_calendaritemcheckins_departmentid_checkintime") + .OnTable("calendaritemcheckins") + .OnColumn("departmentid").Ascending() + .OnColumn("checkintime").Descending(); + + Create.Index("ix_calendaritemcheckins_departmentid_userid_checkintime") + .OnTable("calendaritemcheckins") + .OnColumn("departmentid").Ascending() + .OnColumn("userid").Ascending() + .OnColumn("checkintime").Descending(); + } + + public override void Down() + { + Delete.Index("ix_calendaritemcheckins_departmentid_userid_checkintime").OnTable("calendaritemcheckins"); + Delete.Index("ix_calendaritemcheckins_departmentid_checkintime").OnTable("calendaritemcheckins"); + Delete.Index("ix_calendaritemcheckins_calendaritemid_checkintime").OnTable("calendaritemcheckins"); + + Delete.Index("ix_checkinrecords_callid_unitid_timestamp").OnTable("checkinrecords"); + Delete.Index("ix_checkinrecords_callid_userid_timestamp").OnTable("checkinrecords"); + + Execute.Sql("DROP INDEX IF EXISTS uq_checkintimerconfigs_dept_target_unit;"); + Execute.Sql("DROP INDEX IF EXISTS uq_checkintimeroverrides_dept_call_target_unit;"); + + Create.UniqueConstraint("uq_checkintimerconfigs_dept_target_unit") + .OnTable("checkintimerconfigs") + .Columns("departmentid", "timertargettype", "unittypeid"); + + Create.UniqueConstraint("uq_checkintimeroverrides_dept_call_target_unit") + .OnTable("checkintimeroverrides") + .Columns("departmentid", "calltypeid", "callpriority", "timertargettype", "unittypeid"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0059_AddActiveForStatesToCheckInTimersPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0059_AddActiveForStatesToCheckInTimersPg.cs new file mode 100644 index 00000000..fc637821 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0059_AddActiveForStatesToCheckInTimersPg.cs @@ -0,0 +1,23 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(59)] + public class M0059_AddActiveForStatesToCheckInTimersPg : Migration + { + public override void Up() + { + Alter.Table("checkintimerconfigs") + .AddColumn("activeforstates").AsCustom("citext").Nullable(); + + Alter.Table("checkintimeroverrides") + .AddColumn("activeforstates").AsCustom("citext").Nullable(); + } + + public override void Down() + { + Delete.Column("activeforstates").FromTable("checkintimerconfigs"); + Delete.Column("activeforstates").FromTable("checkintimeroverrides"); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Calendar/SelectCalendarItemCheckInByItemAndUserQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calendar/SelectCalendarItemCheckInByItemAndUserQuery.cs index 227a6699..031066a4 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Queries/Calendar/SelectCalendarItemCheckInByItemAndUserQuery.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calendar/SelectCalendarItemCheckInByItemAndUserQuery.cs @@ -27,7 +27,7 @@ public string GetQuery() public string GetQuery() where TEntity : class, IEntity { - throw new System.NotImplementedException(); + return GetQuery(); } } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Calendar/SelectCalendarItemCheckInsByDeptDateRangeQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calendar/SelectCalendarItemCheckInsByDeptDateRangeQuery.cs index 67851b83..4f6206a2 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Queries/Calendar/SelectCalendarItemCheckInsByDeptDateRangeQuery.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calendar/SelectCalendarItemCheckInsByDeptDateRangeQuery.cs @@ -27,7 +27,7 @@ public string GetQuery() public string GetQuery() where TEntity : class, IEntity { - throw new System.NotImplementedException(); + return GetQuery(); } } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Calendar/SelectCalendarItemCheckInsByItemIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calendar/SelectCalendarItemCheckInsByItemIdQuery.cs index 1593dcea..d59ea7a8 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Queries/Calendar/SelectCalendarItemCheckInsByItemIdQuery.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calendar/SelectCalendarItemCheckInsByItemIdQuery.cs @@ -27,7 +27,7 @@ public string GetQuery() public string GetQuery() where TEntity : class, IEntity { - throw new System.NotImplementedException(); + return GetQuery(); } } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Calendar/SelectCalendarItemCheckInsByUserDateRangeQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calendar/SelectCalendarItemCheckInsByUserDateRangeQuery.cs index da0d1253..2b81c88b 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Queries/Calendar/SelectCalendarItemCheckInsByUserDateRangeQuery.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calendar/SelectCalendarItemCheckInsByUserDateRangeQuery.cs @@ -27,7 +27,7 @@ public string GetQuery() public string GetQuery() where TEntity : class, IEntity { - throw new System.NotImplementedException(); + return GetQuery(); } } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInRecordsByCallIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInRecordsByCallIdQuery.cs index cf66d71a..c394c088 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInRecordsByCallIdQuery.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInRecordsByCallIdQuery.cs @@ -27,7 +27,7 @@ public string GetQuery() public string GetQuery() where TEntity : class, IEntity { - throw new System.NotImplementedException(); + return GetQuery(); } } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInRecordsByDepartmentIdAndDateRangeQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInRecordsByDepartmentIdAndDateRangeQuery.cs index 1ab630f8..97007fdc 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInRecordsByDepartmentIdAndDateRangeQuery.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInRecordsByDepartmentIdAndDateRangeQuery.cs @@ -27,7 +27,7 @@ public string GetQuery() public string GetQuery() where TEntity : class, IEntity { - throw new System.NotImplementedException(); + return GetQuery(); } } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInTimerConfigByDepartmentAndTargetQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInTimerConfigByDepartmentAndTargetQuery.cs index 097b5801..09ec1768 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInTimerConfigByDepartmentAndTargetQuery.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInTimerConfigByDepartmentAndTargetQuery.cs @@ -27,7 +27,7 @@ public string GetQuery() public string GetQuery() where TEntity : class, IEntity { - throw new System.NotImplementedException(); + return GetQuery(); } } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInTimerConfigsByDepartmentIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInTimerConfigsByDepartmentIdQuery.cs index 3b1b05d1..5ff92f43 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInTimerConfigsByDepartmentIdQuery.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInTimerConfigsByDepartmentIdQuery.cs @@ -27,7 +27,7 @@ public string GetQuery() public string GetQuery() where TEntity : class, IEntity { - throw new System.NotImplementedException(); + return GetQuery(); } } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInTimerOverridesByDepartmentIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInTimerOverridesByDepartmentIdQuery.cs index 69d04bbd..3edd54c1 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInTimerOverridesByDepartmentIdQuery.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectCheckInTimerOverridesByDepartmentIdQuery.cs @@ -27,7 +27,7 @@ public string GetQuery() public string GetQuery() where TEntity : class, IEntity { - throw new System.NotImplementedException(); + return GetQuery(); } } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectLastCheckInForUnitOnCallQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectLastCheckInForUnitOnCallQuery.cs index 861feee4..cc7241dd 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectLastCheckInForUnitOnCallQuery.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectLastCheckInForUnitOnCallQuery.cs @@ -27,7 +27,7 @@ public string GetQuery() public string GetQuery() where TEntity : class, IEntity { - throw new System.NotImplementedException(); + return GetQuery(); } } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectLastCheckInForUserOnCallQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectLastCheckInForUserOnCallQuery.cs index 8bddf111..5dab1692 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectLastCheckInForUserOnCallQuery.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectLastCheckInForUserOnCallQuery.cs @@ -27,7 +27,7 @@ public string GetQuery() public string GetQuery() where TEntity : class, IEntity { - throw new System.NotImplementedException(); + return GetQuery(); } } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectMatchingCheckInTimerOverridesQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectMatchingCheckInTimerOverridesQuery.cs index 8f980f05..e7639454 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectMatchingCheckInTimerOverridesQuery.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/CheckIns/SelectMatchingCheckInTimerOverridesQuery.cs @@ -27,7 +27,7 @@ public string GetQuery() public string GetQuery() where TEntity : class, IEntity { - throw new System.NotImplementedException(); + return GetQuery(); } } } diff --git a/Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs b/Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs index cc48cd30..6f8d641e 100644 --- a/Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs @@ -8,6 +8,7 @@ using NUnit.Framework; using Resgrid.Model; using Resgrid.Model.Repositories; +using Resgrid.Model.Services; using Resgrid.Services; namespace Resgrid.Tests.Services @@ -18,6 +19,9 @@ public class CheckInTimerServiceTests private Mock _configRepo; private Mock _overrideRepo; private Mock _recordRepo; + private Mock _actionLogsService; + private Mock _unitsService; + private Mock _callsService; private CheckInTimerService _service; [SetUp] @@ -26,7 +30,11 @@ public void SetUp() _configRepo = new Mock(); _overrideRepo = new Mock(); _recordRepo = new Mock(); - _service = new CheckInTimerService(_configRepo.Object, _overrideRepo.Object, _recordRepo.Object); + _actionLogsService = new Mock(); + _unitsService = new Mock(); + _callsService = new Mock(); + _service = new CheckInTimerService(_configRepo.Object, _overrideRepo.Object, _recordRepo.Object, + _actionLogsService.Object, _unitsService.Object, _callsService.Object); } #region Timer Resolution @@ -261,11 +269,176 @@ public async Task DeleteTimerConfigAsync_ReturnsFalse_WhenConfigNotFound() { _configRepo.Setup(x => x.GetByIdAsync("non-existent")).ReturnsAsync((CheckInTimerConfig)null); - var result = await _service.DeleteTimerConfigAsync("non-existent"); + var result = await _service.DeleteTimerConfigAsync("non-existent", 1); result.Should().BeFalse(); } #endregion CRUD + + #region ActiveForStates Propagation + + [Test] + public async Task ResolveAllTimersForCallAsync_PropagatesActiveForStates_FromConfig() + { + var call = new Call { CallId = 1, DepartmentId = 10, Priority = 0, CheckInTimersEnabled = true }; + var configs = new List + { + new CheckInTimerConfig { TimerTargetType = 0, DurationMinutes = 30, WarningThresholdMinutes = 5, IsEnabled = true, ActiveForStates = "3,6" } + }; + _configRepo.Setup(x => x.GetByDepartmentIdAsync(10)).ReturnsAsync(configs); + _overrideRepo.Setup(x => x.GetMatchingOverridesAsync(10, null, 0)).ReturnsAsync(new List()); + + var result = await _service.ResolveAllTimersForCallAsync(call); + + result.Should().HaveCount(1); + result[0].ActiveForStates.Should().Be("3,6"); + } + + [Test] + public async Task ResolveAllTimersForCallAsync_PropagatesActiveForStates_FromOverride() + { + var call = new Call { CallId = 1, DepartmentId = 10, Type = "1", Priority = 3, CheckInTimersEnabled = true }; + var configs = new List + { + new CheckInTimerConfig { TimerTargetType = 0, DurationMinutes = 30, WarningThresholdMinutes = 5, IsEnabled = true, ActiveForStates = "3" } + }; + var overrides = new List + { + new CheckInTimerOverride { TimerTargetType = 0, CallTypeId = 1, CallPriority = 3, DurationMinutes = 10, WarningThresholdMinutes = 2, IsEnabled = true, ActiveForStates = "3,6" } + }; + _configRepo.Setup(x => x.GetByDepartmentIdAsync(10)).ReturnsAsync(configs); + _overrideRepo.Setup(x => x.GetMatchingOverridesAsync(10, 1, 3)).ReturnsAsync(overrides); + + var result = await _service.ResolveAllTimersForCallAsync(call); + + result.Should().HaveCount(1); + result[0].ActiveForStates.Should().Be("3,6"); + result[0].IsFromOverride.Should().BeTrue(); + } + + [Test] + public async Task ResolveAllTimersForCallAsync_NullActiveForStates_IsPreserved() + { + var call = new Call { CallId = 1, DepartmentId = 10, Priority = 0, CheckInTimersEnabled = true }; + var configs = new List + { + new CheckInTimerConfig { TimerTargetType = 0, DurationMinutes = 30, WarningThresholdMinutes = 5, IsEnabled = true, ActiveForStates = null } + }; + _configRepo.Setup(x => x.GetByDepartmentIdAsync(10)).ReturnsAsync(configs); + _overrideRepo.Setup(x => x.GetMatchingOverridesAsync(10, null, 0)).ReturnsAsync(new List()); + + var result = await _service.ResolveAllTimersForCallAsync(call); + + result.Should().HaveCount(1); + result[0].ActiveForStates.Should().BeNull(); + } + + #endregion ActiveForStates Propagation + + #region State Filtering + + [Test] + public async Task GetActiveTimerStatusesForCallAsync_FiltersOut_WhenPersonnelStateDoesNotMatch() + { + 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" } }, + UnitDispatches = new List() + }; + var configs = new List + { + new CheckInTimerConfig { TimerTargetType = 0, DurationMinutes = 30, WarningThresholdMinutes = 5, IsEnabled = true, ActiveForStates = "3" } // Only On Scene + }; + _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()); + // User is Responding (2), not On Scene (3) + _actionLogsService.Setup(x => x.GetLastActionLogForUserAsync("user1", null)) + .ReturnsAsync(new ActionLog { ActionTypeId = (int)ActionTypes.Responding }); + + var result = await _service.GetActiveTimerStatusesForCallAsync(call); + + result.Should().BeEmpty(); + } + + [Test] + public async Task GetActiveTimerStatusesForCallAsync_IncludesTimer_WhenPersonnelStateMatches() + { + 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" } }, + UnitDispatches = new List() + }; + var configs = new List + { + new CheckInTimerConfig { TimerTargetType = 0, DurationMinutes = 30, WarningThresholdMinutes = 5, IsEnabled = true, ActiveForStates = "3" } // Only On Scene + }; + _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()); + // User is On Scene (3) - matches + _actionLogsService.Setup(x => x.GetLastActionLogForUserAsync("user1", null)) + .ReturnsAsync(new ActionLog { ActionTypeId = (int)ActionTypes.OnScene }); + + var result = await _service.GetActiveTimerStatusesForCallAsync(call); + + result.Should().HaveCount(1); + } + + [Test] + public async Task GetActiveTimerStatusesForCallAsync_NullActiveForStates_IncludesTimer() + { + 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" } }, + UnitDispatches = new List() + }; + var configs = new List + { + new CheckInTimerConfig { TimerTargetType = 0, DurationMinutes = 30, WarningThresholdMinutes = 5, IsEnabled = true, ActiveForStates = null } + }; + _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.GetActiveTimerStatusesForCallAsync(call); + + result.Should().HaveCount(1); + } + + [Test] + public async Task GetActiveTimerStatusesForCallAsync_FiltersOut_WhenUnitStateDoesNotMatch() + { + var call = new Call + { + CallId = 1, DepartmentId = 10, State = (int)CallStates.Active, CheckInTimersEnabled = true, + LoggedOn = DateTime.UtcNow.AddMinutes(-5), + Dispatches = new List(), + UnitDispatches = new List { new CallDispatchUnit { UnitId = 5 } } + }; + var configs = new List + { + new CheckInTimerConfig { TimerTargetType = (int)CheckInTimerTargetType.UnitType, DurationMinutes = 30, WarningThresholdMinutes = 5, IsEnabled = true, ActiveForStates = "6" } // Only On Scene + }; + _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()); + // Unit is Responding (5), not On Scene (6) + _unitsService.Setup(x => x.GetLastUnitStateByUnitIdAsync(5)) + .ReturnsAsync(new UnitState { State = (int)UnitStateTypes.Responding }); + + var result = await _service.GetActiveTimerStatusesForCallAsync(call); + + result.Should().BeEmpty(); + } + + #endregion State Filtering } } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CalendarController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CalendarController.cs index 662b62a9..50267425 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CalendarController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CalendarController.cs @@ -466,6 +466,8 @@ public async Task> GetCalendarItemCheckI foreach (var checkIn in checkIns) { + var canViewLocation = await _authorizationService.CanUserViewPersonLocationAsync(UserId, checkIn.UserId, DepartmentId); + var data = new CalendarCheckInResultData { CheckInId = checkIn.CalendarItemCheckInId, @@ -477,10 +479,10 @@ public async Task> GetCalendarItemCheckI IsManualOverride = checkIn.IsManualOverride, CheckInNote = checkIn.CheckInNote, CheckOutNote = checkIn.CheckOutNote, - CheckInLatitude = checkIn.CheckInLatitude, - CheckInLongitude = checkIn.CheckInLongitude, - CheckOutLatitude = checkIn.CheckOutLatitude, - CheckOutLongitude = checkIn.CheckOutLongitude + CheckInLatitude = canViewLocation ? checkIn.CheckInLatitude : null, + CheckInLongitude = canViewLocation ? checkIn.CheckInLongitude : null, + CheckOutLatitude = canViewLocation ? checkIn.CheckOutLatitude : null, + CheckOutLongitude = canViewLocation ? checkIn.CheckOutLongitude : null }; var name = personnelNames?.FirstOrDefault(x => x.UserId == checkIn.UserId); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CheckInTimersController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CheckInTimersController.cs index 516b9bcd..110a403a 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CheckInTimersController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CheckInTimersController.cs @@ -58,6 +58,7 @@ public async Task> GetTimerConfigs() DurationMinutes = c.DurationMinutes, WarningThresholdMinutes = c.WarningThresholdMinutes, IsEnabled = c.IsEnabled, + ActiveForStates = c.ActiveForStates, CreatedOn = c.CreatedOn, UpdatedOn = c.UpdatedOn }).ToList(); @@ -90,6 +91,7 @@ public async Task> SaveTimerConfig([F DurationMinutes = input.DurationMinutes, WarningThresholdMinutes = input.WarningThresholdMinutes, IsEnabled = input.IsEnabled, + ActiveForStates = input.ActiveForStates, CreatedByUserId = UserId }; @@ -114,7 +116,7 @@ public async Task> DeleteTimerConfig( { var result = new SaveCheckInTimerConfigResult(); - var deleted = await _checkInTimerService.DeleteTimerConfigAsync(configId, cancellationToken); + var deleted = await _checkInTimerService.DeleteTimerConfigAsync(configId, DepartmentId, cancellationToken); if (!deleted) return NotFound(); @@ -153,6 +155,7 @@ public async Task> GetTimerOverrides() DurationMinutes = o.DurationMinutes, WarningThresholdMinutes = o.WarningThresholdMinutes, IsEnabled = o.IsEnabled, + ActiveForStates = o.ActiveForStates, CreatedOn = o.CreatedOn, UpdatedOn = o.UpdatedOn }).ToList(); @@ -187,6 +190,7 @@ public async Task> SaveTimerOverrid DurationMinutes = input.DurationMinutes, WarningThresholdMinutes = input.WarningThresholdMinutes, IsEnabled = input.IsEnabled, + ActiveForStates = input.ActiveForStates, CreatedByUserId = UserId }; @@ -211,7 +215,7 @@ public async Task> DeleteTimerOverr { var result = new SaveCheckInTimerOverrideResult(); - var deleted = await _checkInTimerService.DeleteTimerOverrideAsync(overrideId, cancellationToken); + var deleted = await _checkInTimerService.DeleteTimerOverrideAsync(overrideId, DepartmentId, cancellationToken); if (!deleted) return NotFound(); @@ -253,7 +257,8 @@ public async Task> GetTimersForCall(int TargetName = t.TargetName ?? ((CheckInTimerTargetType)t.TargetType).ToString(), DurationMinutes = t.DurationMinutes, WarningThresholdMinutes = t.WarningThresholdMinutes, - IsFromOverride = t.IsFromOverride + IsFromOverride = t.IsFromOverride, + ActiveForStates = t.ActiveForStates }).ToList(); result.PageSize = result.Data.Count; diff --git a/Web/Resgrid.Web.Services/Hubs/EventingHub.cs b/Web/Resgrid.Web.Services/Hubs/EventingHub.cs index c8c3deb3..ffe1e9d5 100644 --- a/Web/Resgrid.Web.Services/Hubs/EventingHub.cs +++ b/Web/Resgrid.Web.Services/Hubs/EventingHub.cs @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System.Security.Claims; +using System.Threading.Tasks; using CommonServiceLocator; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.SignalR; @@ -23,6 +24,9 @@ public interface IEventingHub Task CallsUpdated(int departmentId, int id); Task DepartmentUpdated(int departmentId); + + // CheckInPerformed and CheckInTimersUpdated are server-only broadcasts + // invoked via IHubContext.SendAsync() — no client-facing methods needed. } [AllowAnonymous] @@ -35,8 +39,13 @@ public EventingHub() _departmentLinksService = ServiceLocator.Current.GetInstance(); } + [Authorize] public async Task Connect(int departmentId) { + var claim = Context.User?.FindFirst(ClaimTypes.PrimaryGroupSid); + if (claim == null || !int.TryParse(claim.Value, out int userDepartmentId) || userDepartmentId != departmentId) + throw new HubException("Unauthorized: department mismatch."); + await Groups.AddToGroupAsync(Context.ConnectionId, departmentId.ToString()); await Clients.Caller.SendAsync("onConnected", Context.ConnectionId); @@ -98,20 +107,8 @@ public async Task DepartmentUpdated(int departmentId) await group.SendAsync("departmentUpdated"); } - public async Task CheckInPerformed(int departmentId, int callId, string checkInRecordId) - { - var group = Clients.Group(departmentId.ToString()); - - if (group != null) - await group.SendAsync("checkInPerformed", callId, checkInRecordId); - } - - public async Task CheckInTimersUpdated(int departmentId, int callId) - { - var group = Clients.Group(departmentId.ToString()); - - if (group != null) - await group.SendAsync("checkInTimersUpdated", callId); - } + // CheckInPerformed and CheckInTimersUpdated are server-only broadcasts. + // They are invoked via IHubContext.Clients.Group().SendAsync() + // and must not be exposed as client-callable hub methods. } } diff --git a/Web/Resgrid.Web.Services/Models/v4/CheckInTimers/CheckInTimerModels.cs b/Web/Resgrid.Web.Services/Models/v4/CheckInTimers/CheckInTimerModels.cs index 07ca2b15..8ada3e71 100644 --- a/Web/Resgrid.Web.Services/Models/v4/CheckInTimers/CheckInTimerModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/CheckInTimers/CheckInTimerModels.cs @@ -21,6 +21,7 @@ public class CheckInTimerConfigResultData public int DurationMinutes { get; set; } public int WarningThresholdMinutes { get; set; } public bool IsEnabled { get; set; } + public string ActiveForStates { get; set; } public DateTime CreatedOn { get; set; } public DateTime? UpdatedOn { get; set; } } @@ -30,17 +31,22 @@ public class CheckInTimerConfigInput public string CheckInTimerConfigId { get; set; } [Required] + [Range(0, int.MaxValue)] public int TimerTargetType { get; set; } public int? UnitTypeId { get; set; } [Required] + [Range(1, int.MaxValue)] public int DurationMinutes { get; set; } [Required] + [Range(1, int.MaxValue)] public int WarningThresholdMinutes { get; set; } public bool IsEnabled { get; set; } = true; + + public string ActiveForStates { get; set; } } public class SaveCheckInTimerConfigResult : StandardApiResponseV4Base @@ -67,6 +73,7 @@ public class CheckInTimerOverrideResultData public int DurationMinutes { get; set; } public int WarningThresholdMinutes { get; set; } public bool IsEnabled { get; set; } + public string ActiveForStates { get; set; } public DateTime CreatedOn { get; set; } public DateTime? UpdatedOn { get; set; } } @@ -78,17 +85,22 @@ public class CheckInTimerOverrideInput public int? CallPriority { get; set; } [Required] + [Range(0, int.MaxValue)] public int TimerTargetType { get; set; } public int? UnitTypeId { get; set; } [Required] + [Range(1, int.MaxValue)] public int DurationMinutes { get; set; } [Required] + [Range(1, int.MaxValue)] public int WarningThresholdMinutes { get; set; } public bool IsEnabled { get; set; } = true; + + public string ActiveForStates { get; set; } } public class SaveCheckInTimerOverrideResult : StandardApiResponseV4Base @@ -122,9 +134,11 @@ public class CheckInTimerStatusResultData public class PerformCheckInInput { [Required] + [Range(1, int.MaxValue)] public int CallId { get; set; } [Required] + [Range(0, int.MaxValue)] public int CheckInType { get; set; } public string Latitude { get; set; } @@ -174,6 +188,7 @@ public class ResolvedCheckInTimerResultData public int DurationMinutes { get; set; } public int WarningThresholdMinutes { get; set; } public bool IsFromOverride { get; set; } + public string ActiveForStates { get; set; } } // ── Toggle ────────────────────────────────────────────────── diff --git a/Web/Resgrid.Web/Areas/User/Controllers/CalendarController.cs b/Web/Resgrid.Web/Areas/User/Controllers/CalendarController.cs index 754f7e01..9f8c4f74 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/CalendarController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/CalendarController.cs @@ -253,7 +253,7 @@ public async Task Edit(EditCalendarEntry model, CancellationToken // Add new attendees from entities and notify only the newly added ones // Skip notifications for past events (bookkeeping after the fact) - if (!string.IsNullOrWhiteSpace(model.entities)) + if (model.Item.SignupType == (int)CalendarItemSignupTypes.None && !string.IsNullOrWhiteSpace(model.entities)) { var updatedItem = await _calendarService.GetCalendarItemByIdAsync(model.Item.CalendarItemId); var newUserIds = await AddEntitiesAsAttendeesAsync(updatedItem, model.entities, existingAttendeeIds, cancellationToken); @@ -1090,7 +1090,7 @@ await _calendarService.SignupForEvent(calendarItem.CalendarItemId, person.UserId if (int.TryParse(val.Replace("G:", ""), out groupId)) { var group = await _departmentGroupsService.GetGroupByIdAsync(groupId); - if (group?.Members != null) + if (group != null && group.DepartmentId == calendarItem.DepartmentId && group.Members != null) { foreach (var member in group.Members) { diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs index e4f596fc..02a98514 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs @@ -1775,7 +1775,7 @@ await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, model.Au [ValidateAntiForgeryToken] [Authorize(Policy = ResgridResources.Department_Update)] public async Task SaveCheckInTimerConfig(string configId, int timerTargetType, int? unitTypeId, - int durationMinutes, int warningThresholdMinutes, bool isEnabled, CancellationToken cancellationToken) + int durationMinutes, int warningThresholdMinutes, bool isEnabled, string activeForStates, CancellationToken cancellationToken) { if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId)) return Unauthorized(); @@ -1789,6 +1789,7 @@ public async Task SaveCheckInTimerConfig(string configId, int tim DurationMinutes = durationMinutes, WarningThresholdMinutes = warningThresholdMinutes, IsEnabled = isEnabled, + ActiveForStates = string.IsNullOrWhiteSpace(activeForStates) ? null : activeForStates, CreatedByUserId = UserId }; @@ -1805,7 +1806,7 @@ public async Task DeleteCheckInTimerConfig(string configId, Cance if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId)) return Unauthorized(); - await _checkInTimerService.DeleteTimerConfigAsync(configId, cancellationToken); + await _checkInTimerService.DeleteTimerConfigAsync(configId, DepartmentId, cancellationToken); return RedirectToAction("DispatchSettings"); } @@ -1814,7 +1815,7 @@ public async Task DeleteCheckInTimerConfig(string configId, Cance [ValidateAntiForgeryToken] [Authorize(Policy = ResgridResources.Department_Update)] public async Task SaveCheckInTimerOverride(string overrideId, int? callTypeId, int? callPriority, - int timerTargetType, int? unitTypeId, int durationMinutes, int warningThresholdMinutes, bool isEnabled, CancellationToken cancellationToken) + int timerTargetType, int? unitTypeId, int durationMinutes, int warningThresholdMinutes, bool isEnabled, string activeForStates, CancellationToken cancellationToken) { if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId)) return Unauthorized(); @@ -1830,6 +1831,7 @@ public async Task SaveCheckInTimerOverride(string overrideId, int DurationMinutes = durationMinutes, WarningThresholdMinutes = warningThresholdMinutes, IsEnabled = isEnabled, + ActiveForStates = string.IsNullOrWhiteSpace(activeForStates) ? null : activeForStates, CreatedByUserId = UserId }; @@ -1846,11 +1848,61 @@ public async Task DeleteCheckInTimerOverride(string overrideId, C if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId)) return Unauthorized(); - await _checkInTimerService.DeleteTimerOverrideAsync(overrideId, cancellationToken); + await _checkInTimerService.DeleteTimerOverrideAsync(overrideId, DepartmentId, cancellationToken); return RedirectToAction("DispatchSettings"); } + [HttpGet] + [Authorize(Policy = ResgridResources.Department_View)] + public async Task GetActiveStatesForTimerTarget(int timerTargetType, int? unitTypeId) + { + var states = new List(); + + if (timerTargetType == (int)CheckInTimerTargetType.UnitType) + { + // Unit-type target: check if the selected unit type has a custom state set + if (unitTypeId.HasValue) + { + var unitTypes = await _unitsService.GetUnitTypesForDepartmentAsync(DepartmentId); + var unitType = unitTypes?.FirstOrDefault(ut => ut.UnitTypeId == unitTypeId.Value); + + if (unitType?.CustomStatesId != null && unitType.CustomStatesId.Value > 0) + { + var customState = await _customStateService.GetCustomSateByIdAsync(unitType.CustomStatesId.Value); + if (customState != null) + { + foreach (var detail in customState.GetActiveDetails()) + { + states.Add(new { id = detail.CustomStateDetailId.ToString(), text = detail.ButtonText }); + } + } + } + } + + // If no custom states found, use defaults + if (!states.Any()) + { + var defaults = _customStateService.GetDefaultUnitStatuses(); + foreach (var d in defaults) + { + states.Add(new { id = d.CustomStateDetailId.ToString(), text = d.ButtonText }); + } + } + } + else + { + // Personnel-based targets: use custom personnel statuses or defaults + var personnelStatuses = await _customStateService.GetCustomPersonnelStatusesOrDefaultsAsync(DepartmentId); + foreach (var s in personnelStatuses) + { + states.Add(new { id = s.CustomStateDetailId.ToString(), text = s.ButtonText }); + } + } + + return Json(states); + } + #endregion Check-In Timer Settings #region Mapping Settings diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs index 416e6880..5533209e 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs @@ -1749,6 +1749,7 @@ public async Task GetCheckInHistory(int callId) } [HttpPost] + [ValidateAntiForgeryToken] public async Task PerformCheckIn([FromBody] PerformCheckInInput input, CancellationToken cancellationToken) { if (input == null || input.CallId <= 0) diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs index 145ed038..9fe426e4 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs @@ -2193,10 +2193,20 @@ private async Task BuildEventAttendanceDetailReportMo var checkIns = await _calendarService.GetCheckInsByUserDateRangeAsync(userId, DepartmentId, start, end); var personnelNames = await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(DepartmentId); + // Batch-fetch calendar items to avoid N+1 queries + var calendarItemIds = checkIns.Select(c => c.CalendarItemId).Distinct().ToList(); + var calendarItemsDict = new Dictionary(); + foreach (var id in calendarItemIds) + { + var item = await _calendarService.GetCalendarItemByIdAsync(id); + if (item != null) + calendarItemsDict[id] = item; + } + double totalSeconds = 0; foreach (var checkIn in checkIns) { - var calItem = await _calendarService.GetCalendarItemByIdAsync(checkIn.CalendarItemId); + calendarItemsDict.TryGetValue(checkIn.CalendarItemId, out var calItem); var detail = new EventAttendanceDetail(); detail.EventTitle = calItem?.Title ?? "Unknown Event"; diff --git a/Web/Resgrid.Web/Areas/User/Models/Calendar/SignupSheetView.cs b/Web/Resgrid.Web/Areas/User/Models/Calendar/SignupSheetView.cs index 1b0e3804..429cc0eb 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Calendar/SignupSheetView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Calendar/SignupSheetView.cs @@ -7,7 +7,7 @@ public class SignupSheetView { public CalendarItem CalendarItem { get; set; } public Department Department { get; set; } - public List PersonnelNames { get; set; } + public List PersonnelNames { get; set; } = new List(); public int TotalRows { get; set; } } } diff --git a/Web/Resgrid.Web/Areas/User/Views/Calendar/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Calendar/Edit.cshtml index 48518d5c..7d277497 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Calendar/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Calendar/Edit.cshtml @@ -405,19 +405,24 @@ } diff --git a/Web/Resgrid.Web/Areas/User/Views/Calendar/New.cshtml b/Web/Resgrid.Web/Areas/User/Views/Calendar/New.cshtml index bf5af023..515381b9 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Calendar/New.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Calendar/New.cshtml @@ -357,19 +357,24 @@ } diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExport.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExport.cshtml index c7089df3..66d259a7 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExport.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExport.cshtml @@ -5,7 +5,6 @@ @using Microsoft.Extensions.Localization @inject IStringLocalizer localizer @model Resgrid.Web.Areas.User.Models.Dispatch.CallExportView -@inject IStringLocalizer localizer @{ Layout = null; } diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml index 01b85437..843161f3 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml @@ -408,7 +408,7 @@
diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml index 1a4ec818..2fae790d 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml @@ -862,6 +862,7 @@
- @if (Model.UserCheckIn == null && (checkInWindowOpen || adminBypassWindow)) + @if (Model.UserCheckIn == null && ((checkInWindowOpen && !eventIsPast) || adminBypassWindow)) { @using (Html.BeginForm("CheckIn", "Calendar", FormMethod.Post, new { area = "User" })) { @@ -402,7 +402,7 @@ } } - else if (Model.UserCheckIn == null && !checkInWindowOpen && !adminBypassWindow) + else if (Model.UserCheckIn == null && (!checkInWindowOpen || eventIsPast) && !adminBypassWindow) {

@localizer["CheckInNotYetAvailable"]

} diff --git a/Web/Resgrid.Web/Areas/User/Views/Department/DispatchSettings.cshtml b/Web/Resgrid.Web/Areas/User/Views/Department/DispatchSettings.cshtml index 46a775b5..d307913f 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Department/DispatchSettings.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Department/DispatchSettings.cshtml @@ -141,7 +141,7 @@ @localizer["CheckInTimerDurationLabel"] @localizer["CheckInTimerWarningLabel"] @localizer["CheckInTimerEnabledLabel"] - Active States + @localizer["CheckInTimerActiveStatesLabel"] @@ -160,7 +160,11 @@ @config.DurationMinutes @config.WarningThresholdMinutes @(config.IsEnabled ? localizer["CheckInTimerYes"] : localizer["CheckInTimerNo"]) - @(string.IsNullOrWhiteSpace(config.ActiveForStates) ? "All" : config.ActiveForStates) + @if (string.IsNullOrWhiteSpace(config.ActiveForStates)) + { @localizer["CheckInTimerAllStates"] } + else + { @string.Join(", ", config.ActiveForStates.Split(',').Select(id => id.Trim()).Select(id => Model.StateNames.ContainsKey(id) ? Model.StateNames[id] : id)) } +
@Html.AntiForgeryToken() @@ -222,12 +226,12 @@
- +
- Leave empty for "All States". + @localizer["CheckInTimerLeaveEmptyForAllStates"]
@@ -255,7 +259,7 @@ @localizer["CheckInTimerDurationLabel"] @localizer["CheckInTimerWarningLabel"] @localizer["CheckInTimerEnabledLabel"] - Active States + @localizer["CheckInTimerActiveStatesLabel"] @@ -286,7 +290,11 @@ @ovr.DurationMinutes @ovr.WarningThresholdMinutes @(ovr.IsEnabled ? localizer["CheckInTimerYes"] : localizer["CheckInTimerNo"]) - @(string.IsNullOrWhiteSpace(ovr.ActiveForStates) ? "All" : ovr.ActiveForStates) + @if (string.IsNullOrWhiteSpace(ovr.ActiveForStates)) + { @localizer["CheckInTimerAllStates"] } + else + { @string.Join(", ", ovr.ActiveForStates.Split(',').Select(id => id.Trim()).Select(id => Model.StateNames.ContainsKey(id) ? Model.StateNames[id] : id)) } + @Html.AntiForgeryToken() @@ -372,12 +380,12 @@
- +
- Leave empty for "All States". + @localizer["CheckInTimerLeaveEmptyForAllStates"]
@@ -428,7 +436,7 @@ var $hidden = $('#' + hiddenId); $statesSelect.select2({ - placeholder: 'All States', + placeholder: '@localizer["CheckInTimerActiveStatesLabel"]', allowClear: true, width: '300px' }); diff --git a/Web/Resgrid.Web/Areas/User/Views/Documents/EditDocument.cshtml b/Web/Resgrid.Web/Areas/User/Views/Documents/EditDocument.cshtml index 32c8e430..f4739993 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Documents/EditDocument.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Documents/EditDocument.cshtml @@ -41,7 +41,7 @@
- +
diff --git a/Web/Resgrid.Web/Areas/User/Views/Documents/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Documents/Index.cshtml index 634c79c5..9c5f411c 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Documents/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Documents/Index.cshtml @@ -69,7 +69,14 @@
- @doc.Name + @if (ClaimsAuthorizationHelper.IsUserDepartmentAdmin() || doc.UserId == Model.UserId) + { + @doc.Name + } + else + { + @doc.Name + }
@commonLocalizer["Added"]: @doc.AddedOn.TimeConverterToString(Model.Department)
diff --git a/Web/Resgrid.Web/Areas/User/Views/Documents/NewDocument.cshtml b/Web/Resgrid.Web/Areas/User/Views/Documents/NewDocument.cshtml index 72f073a7..0aa07fd6 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Documents/NewDocument.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Documents/NewDocument.cshtml @@ -32,7 +32,7 @@
- +
diff --git a/Web/Resgrid.Web/Areas/User/Views/Logs/NewLog.cshtml b/Web/Resgrid.Web/Areas/User/Views/Logs/NewLog.cshtml index 574e23ca..02363c4c 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Logs/NewLog.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Logs/NewLog.cshtml @@ -30,7 +30,7 @@
- +
diff --git a/Web/Resgrid.Web/Areas/User/Views/Personnel/AddPerson.cshtml b/Web/Resgrid.Web/Areas/User/Views/Personnel/AddPerson.cshtml index c1d3fa14..1b701089 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Personnel/AddPerson.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Personnel/AddPerson.cshtml @@ -95,7 +95,7 @@
- +
diff --git a/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml index a51e93e3..02123d68 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml @@ -163,6 +163,16 @@ + + Delete Log Entries + Who in your department is allowed to delete log entries + @Html.DropDownListFor(m => m.DeleteLog, Model.DeleteLogPermissions) + @localizer["PermissionNA"] + + @localizer["PermissionNoRoles"] + + + @localizer["PermCreateShiftLabel"] @localizer["PermCreateShiftNote"] diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.checkin-timers.js b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.checkin-timers.js index f2b30b44..69ca8b93 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.checkin-timers.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.checkin-timers.js @@ -66,7 +66,7 @@ case 'Critical': return 'OVERDUE'; default: - return '' + status + ''; + return '' + escapeHtml(status) + ''; } } diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/logs/resgrid.logs.index.js b/Web/Resgrid.Web/wwwroot/js/app/internal/logs/resgrid.logs.index.js index 17567a15..fc1086e3 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/logs/resgrid.logs.index.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/logs/resgrid.logs.index.js @@ -18,6 +18,8 @@ var resgrid; { data: 'Group', title: 'Group' }, { data: 'LoggedBy', title: 'Logged By' }, { data: 'LoggedOn', title: 'Logged On' }, + { data: 'Narrative', title: 'Narrative', visible: false, searchable: true }, + { data: 'SearchTerms', title: 'SearchTerms', visible: false, searchable: true }, { data: 'LogId', title: 'Actions', @@ -26,7 +28,7 @@ var resgrid; render: function (data, type, row) { var html = 'View '; if (row.CanDelete) { - html += 'Delete'; + html += 'Delete'; } return html; } diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/security/resgrid.security.permissions.js b/Web/Resgrid.Web/wwwroot/js/app/internal/security/resgrid.security.permissions.js index 328624fa..2d5c3120 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/security/resgrid.security.permissions.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/security/resgrid.security.permissions.js @@ -204,6 +204,36 @@ var resgrid; $('#logCreateRolesDiv').hide(); } initPermRoles("#logCreateRoles", 7); + + // Delete Log + //////////////////////////////////////////////////////// + $('#DeleteLog').change(function () { + var val = this.value; + $.ajax({ + url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=27&perm=' + val, + type: 'GET' + }).done(function (results) { + }); + if ($("#DeleteLog").val() === "2") { + $('#logDeleteNoRolesSpan').hide(); + $('#logDeleteRolesDiv').show(); + } + else { + $('#logDeleteNoRolesSpan').show(); + $('#logDeleteRolesDiv').hide(); + } + }); + if ($("#DeleteLog").val() === "2") { + $('#logDeleteNoRolesSpan').hide(); + $('#logDeleteRolesDiv').show(); + } + else { + $('#logDeleteNoRolesSpan').show(); + $('#logDeleteRolesDiv').hide(); + } + initPermRoles("#logDeleteRoles", 27); + //////////////////////////////////////////////////////// + $('#CreateShift').change(function () { var val = this.value; $.ajax({ @@ -558,12 +588,12 @@ var resgrid; //////////////////////////////////////////////////////// - // View Contacts + // View Contacts (ContactView = 20) //////////////////////////////////////////////////////// $('#ViewContacts').change(function () { var val = this.value; $.ajax({ - url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=19&perm=' + val + '&lockToGroup=false', + url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=20&perm=' + val + '&lockToGroup=false', type: 'GET' }).done(function (results) { }); @@ -584,16 +614,16 @@ var resgrid; $('#viewContactsRolesSpan').show(); $('#viewContactsRolesDiv').hide(); } - initPermRoles("#viewContactsRoles", 19); + initPermRoles("#viewContactsRoles", 20); //////////////////////////////////////////////////////// - // View Contacts + // Edit Contacts (ContactEdit = 19) //////////////////////////////////////////////////////// $('#EditContacts').change(function () { var val = this.value; $.ajax({ - url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=20&perm=' + val + '&lockToGroup=false', + url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=19&perm=' + val + '&lockToGroup=false', type: 'GET' }).done(function (results) { }); @@ -606,7 +636,7 @@ var resgrid; $('#editContactsRolesDiv').hide(); } }); - if ($("#ViewContacts").val() === "2") { + if ($("#EditContacts").val() === "2") { $('#editContactsRolesSpan').hide(); $('#editContactsRolesDiv').show(); } @@ -614,7 +644,7 @@ var resgrid; $('#editContactsRolesSpan').show(); $('#editContactsRolesDiv').hide(); } - initPermRoles("#editContactsRoles", 20); + initPermRoles("#editContactsRoles", 19); //////////////////////////////////////////////////////// From 9a0cf3174b002ca3554956f0e60a13a107dadfea Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 28 Mar 2026 17:24:53 -0700 Subject: [PATCH 5/7] RE1-T109 PR#312 fixes --- .../Areas/User/Security/Security.en.resx | 2 ++ .../M0060_FixCheckInTimerNullUniqueness.cs | 6 ++-- .../M0060_FixCheckInTimerNullUniquenessPg.cs | 28 +++++++++++++------ .../Areas/User/Controllers/LogsController.cs | 3 ++ .../Areas/User/Views/Calendar/View.cshtml | 4 +-- .../Views/Department/DispatchSettings.cshtml | 2 +- .../Areas/User/Views/Documents/Index.cshtml | 24 ++++++++-------- .../Areas/User/Views/Security/Index.cshtml | 4 +-- .../resgrid.dispatch.checkin-timers.js | 12 +++++++- 9 files changed, 55 insertions(+), 30 deletions(-) diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.en.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.en.resx index 6cbda9ac..568a926a 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.en.resx @@ -370,6 +370,8 @@ Password must contain at least one lowercase letter. Password must be at least {0} characters long. Minimum password length cannot be less than the system default of 8 characters. + Delete Log Entries + Who in your department is allowed to delete log entries diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0060_FixCheckInTimerNullUniqueness.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0060_FixCheckInTimerNullUniqueness.cs index 3dab8b3f..edba80f5 100644 --- a/Providers/Resgrid.Providers.Migrations/Migrations/M0060_FixCheckInTimerNullUniqueness.cs +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0060_FixCheckInTimerNullUniqueness.cs @@ -12,13 +12,13 @@ public override void Up() Execute.Sql("DROP INDEX IF EXISTS UQ_CheckInTimerOverrides_Dept_Call_Target_Unit ON CheckInTimerOverrides;"); // Remove duplicate rows that may have accumulated while the filtered indexes - // allowed multiple NULL entries (keep the row with the latest CheckInTimerConfigId per group) + // allowed multiple NULL entries (keep the most recently modified row per group) Execute.Sql(@" WITH cte AS ( SELECT CheckInTimerConfigId, ROW_NUMBER() OVER ( PARTITION BY DepartmentId, TimerTargetType, ISNULL(UnitTypeId, -1) - ORDER BY CheckInTimerConfigId DESC + ORDER BY ISNULL(UpdatedOn, CreatedOn) DESC, CheckInTimerConfigId DESC ) AS rn FROM CheckInTimerConfigs ) @@ -30,7 +30,7 @@ WITH cte AS ( SELECT CheckInTimerOverrideId, ROW_NUMBER() OVER ( PARTITION BY DepartmentId, ISNULL(CallTypeId, -1), ISNULL(CallPriority, -1), TimerTargetType, ISNULL(UnitTypeId, -1) - ORDER BY CheckInTimerOverrideId DESC + ORDER BY ISNULL(UpdatedOn, CreatedOn) DESC, CheckInTimerOverrideId DESC ) AS rn FROM CheckInTimerOverrides ) diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0060_FixCheckInTimerNullUniquenessPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0060_FixCheckInTimerNullUniquenessPg.cs index e5f92c87..97679347 100644 --- a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0060_FixCheckInTimerNullUniquenessPg.cs +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0060_FixCheckInTimerNullUniquenessPg.cs @@ -12,22 +12,32 @@ public override void Up() Execute.Sql("DROP INDEX IF EXISTS uq_checkintimeroverrides_dept_call_target_unit;"); // Remove duplicate rows that may exist from before the NULLS NOT DISTINCT - // constraint was applied (keep the row with the latest id per group) + // constraint was applied (keep the most recently modified row per group) Execute.Sql(@" DELETE FROM checkintimerconfigs - WHERE checkintimerconfigid NOT IN ( - SELECT MAX(checkintimerconfigid) - FROM checkintimerconfigs - GROUP BY departmentid, timertargettype, COALESCE(unittypeid, -1) + WHERE checkintimerconfigid IN ( + SELECT checkintimerconfigid FROM ( + SELECT checkintimerconfigid, + ROW_NUMBER() OVER ( + PARTITION BY departmentid, timertargettype, COALESCE(unittypeid, -1) + ORDER BY COALESCE(updatedon, createdon) DESC, checkintimerconfigid DESC + ) AS rn + FROM checkintimerconfigs + ) sub WHERE rn > 1 ); "); Execute.Sql(@" DELETE FROM checkintimeroverrides - WHERE checkintimeroverrideid NOT IN ( - SELECT MAX(checkintimeroverrideid) - FROM checkintimeroverrides - GROUP BY departmentid, COALESCE(calltypeid, -1), COALESCE(callpriority, -1), timertargettype, COALESCE(unittypeid, -1) + WHERE checkintimeroverrideid IN ( + SELECT checkintimeroverrideid FROM ( + SELECT checkintimeroverrideid, + ROW_NUMBER() OVER ( + PARTITION BY departmentid, COALESCE(calltypeid, -1), COALESCE(callpriority, -1), timertargettype, COALESCE(unittypeid, -1) + ORDER BY COALESCE(updatedon, createdon) DESC, checkintimeroverrideid DESC + ) AS rn + FROM checkintimeroverrides + ) sub WHERE rn > 1 ); "); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/LogsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/LogsController.cs index 88ec8e78..bab47669 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/LogsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/LogsController.cs @@ -481,6 +481,9 @@ public async Task DeleteWorkLog(int logId, CancellationToken canc var log = await _workLogsService.GetWorkLogByIdAsync(logId); + if (log == null) + return NotFound(); + var auditEvent = new AuditEvent(); auditEvent.DepartmentId = DepartmentId; auditEvent.UserId = UserId; diff --git a/Web/Resgrid.Web/Areas/User/Views/Calendar/View.cshtml b/Web/Resgrid.Web/Areas/User/Views/Calendar/View.cshtml index 28743498..f7f7f64a 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Calendar/View.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Calendar/View.cshtml @@ -305,7 +305,7 @@ } - @if (attendee.UserId == ClaimsAuthorizationHelper.GetUserId() && Model.CalendarItem.Start >= DateTime.UtcNow) + @if (string.Equals(attendee.UserId, ClaimsAuthorizationHelper.GetUserId(), StringComparison.OrdinalIgnoreCase) && Model.CalendarItem.Start >= DateTime.UtcNow) { @@ -534,7 +534,7 @@ @if (Model.IsAdmin && Model.CalendarItem.Attendees != null && Model.CalendarItem.Attendees.Any()) { var uncheckedAttendees = Model.CalendarItem.Attendees - .Where(a => Model.CheckIns == null || !Model.CheckIns.Any(c => c.UserId == a.UserId)) + .Where(a => Model.CheckIns == null || !Model.CheckIns.Any(c => string.Equals(c.UserId, a.UserId, StringComparison.OrdinalIgnoreCase))) .ToList(); if (uncheckedAttendees.Any()) { diff --git a/Web/Resgrid.Web/Areas/User/Views/Department/DispatchSettings.cshtml b/Web/Resgrid.Web/Areas/User/Views/Department/DispatchSettings.cshtml index d307913f..c5912691 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Department/DispatchSettings.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Department/DispatchSettings.cshtml @@ -436,7 +436,7 @@ var $hidden = $('#' + hiddenId); $statesSelect.select2({ - placeholder: '@localizer["CheckInTimerActiveStatesLabel"]', + placeholder: '@Html.Raw(System.Web.HttpUtility.JavaScriptStringEncode(localizer["CheckInTimerActiveStatesLabel"].Value))', allowClear: true, width: '300px' }); diff --git a/Web/Resgrid.Web/Areas/User/Views/Documents/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Documents/Index.cshtml index 9c5f411c..ec07d521 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Documents/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Documents/Index.cshtml @@ -68,19 +68,19 @@
-
- @if (ClaimsAuthorizationHelper.IsUserDepartmentAdmin() || doc.UserId == Model.UserId) - { - @doc.Name - } - else - { - @doc.Name - } -
- @commonLocalizer["Added"]: @doc.AddedOn.TimeConverterToString(Model.Department) -
+
+ @if (ClaimsAuthorizationHelper.IsUserDepartmentAdmin() || doc.UserId == Model.UserId) + { + @doc.Name + } + else + { + @doc.Name + } +
+ @commonLocalizer["Added"]: @doc.AddedOn.TimeConverterToString(Model.Department) +
@if (ClaimsAuthorizationHelper.CanDeleteDocument() && (ClaimsAuthorizationHelper.IsUserDepartmentAdmin() || doc.UserId == Model.UserId)) { - Delete Log Entries - Who in your department is allowed to delete log entries + @localizer["PermDeleteLogLabel"] + @localizer["PermDeleteLogNote"] @Html.DropDownListFor(m => m.DeleteLog, Model.DeleteLogPermissions) @localizer["PermissionNA"] diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.checkin-timers.js b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.checkin-timers.js index 69ca8b93..700dd4ae 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.checkin-timers.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.checkin-timers.js @@ -44,7 +44,7 @@ '' + escapeHtml(t.TargetName || t.TargetTypeName) + '
' + escapeHtml(t.TargetTypeName) + '' + '' + remaining + '' + '' + statusBadge + '' + - '' + + '' + ''; tbody.append(row); } @@ -152,6 +152,16 @@ $('#checkInModal').modal('show'); }; + // Delegated click handler for check-in buttons + $(document).on('click', '.btn-checkin-dialog', function () { + var targetType = parseInt($(this).data('target-type')); + var unitIdRaw = $(this).data('unit-id'); + var unitId = (unitIdRaw !== '' && unitIdRaw != null) ? parseInt(unitIdRaw) : null; + if (!isNaN(targetType)) { + showCheckInDialog(targetType, unitId); + } + }); + // Wire up modal submit button $(document).on('click', '#checkInSubmitBtn', function () { var checkInType = parseInt($('#checkInTargetType').val()); From e7594be8dec95bc5a3ec5a4419e269394513fd22 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sun, 29 Mar 2026 07:29:41 -0700 Subject: [PATCH 6/7] RE1-T109 PR#312 fixes --- .../Areas/User/Calendar/Calendar.en.resx | 9 +++++++++ .../Areas/User/Controllers/CalendarController.cs | 3 +++ .../Areas/User/Controllers/LogsController.cs | 5 +++-- Web/Resgrid.Web/Areas/User/Views/Calendar/View.cshtml | 10 +++++++--- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.en.resx b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.en.resx index 551209e8..b8ebdc3a 100644 --- a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.en.resx @@ -402,4 +402,13 @@ Print Signup Sheet + + Check-in is no longer available for this event. + + + Checked in by {0} + + + Checked out by {0} + diff --git a/Web/Resgrid.Web/Areas/User/Controllers/CalendarController.cs b/Web/Resgrid.Web/Areas/User/Controllers/CalendarController.cs index 9f8c4f74..e8761ddb 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/CalendarController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/CalendarController.cs @@ -661,6 +661,9 @@ public async Task SignupSheet(int calendarItemId, int rows = 0) if (calendarItem == null || calendarItem.DepartmentId != DepartmentId) return Unauthorized(); + if (!await _authorizationService.CanUserModifyCalendarEntryAsync(UserId, calendarItemId)) + return Unauthorized(); + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); var personnelNames = await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(DepartmentId); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/LogsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/LogsController.cs index bab47669..85a729bd 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/LogsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/LogsController.cs @@ -489,14 +489,15 @@ public async Task DeleteWorkLog(int logId, CancellationToken canc auditEvent.UserId = UserId; auditEvent.Before = log.CloneJsonToString(); auditEvent.Type = AuditLogTypes.LogDeleted; - auditEvent.Successful = true; auditEvent.IpAddress = IpAddressHelper.GetRequestIP(Request, true); auditEvent.ServerName = Environment.MachineName; auditEvent.UserAgent = $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}"; - _eventAggregator.SendMessage(auditEvent); await _workLogsService.DeleteLogAsync(logId, cancellationToken); + auditEvent.Successful = true; + _eventAggregator.SendMessage(auditEvent); + return RedirectToAction("Index"); } diff --git a/Web/Resgrid.Web/Areas/User/Views/Calendar/View.cshtml b/Web/Resgrid.Web/Areas/User/Views/Calendar/View.cshtml index f7f7f64a..892ae7e7 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Calendar/View.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Calendar/View.cshtml @@ -402,7 +402,11 @@ } } - else if (Model.UserCheckIn == null && (!checkInWindowOpen || eventIsPast) && !adminBypassWindow) + else if (Model.UserCheckIn == null && eventIsPast && !adminBypassWindow) + { +

@localizer["CheckInClosed"]

+ } + else if (Model.UserCheckIn == null && !checkInWindowOpen && !adminBypassWindow) {

@localizer["CheckInNotYetAvailable"]

} @@ -472,12 +476,12 @@ @if (!string.IsNullOrWhiteSpace(ci.CheckInByUserId)) { var byName = Model.PersonnelNames?.FirstOrDefault(x => string.Equals(x.UserId, ci.CheckInByUserId, StringComparison.OrdinalIgnoreCase)); -
@localizer["CheckIn"] by @(byName?.Name ?? ci.CheckInByUserId) +
@localizer["CheckInBy", byName?.Name ?? ci.CheckInByUserId] } @if (!string.IsNullOrWhiteSpace(ci.CheckOutByUserId)) { var byName = Model.PersonnelNames?.FirstOrDefault(x => string.Equals(x.UserId, ci.CheckOutByUserId, StringComparison.OrdinalIgnoreCase)); -
@localizer["CheckOut"] by @(byName?.Name ?? ci.CheckOutByUserId) +
@localizer["CheckOutBy", byName?.Name ?? ci.CheckOutByUserId] } @ci.CheckInTime.TimeConverterToString(Model.Department) From a8fedd2bedad10d7dffcc83ec331ef720d465f5d Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sun, 29 Mar 2026 07:42:25 -0700 Subject: [PATCH 7/7] RE1-T109 PR#312 fixes --- Tests/Resgrid.Tests/Services/CalendarServiceCheckInTests.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Tests/Resgrid.Tests/Services/CalendarServiceCheckInTests.cs b/Tests/Resgrid.Tests/Services/CalendarServiceCheckInTests.cs index 354713e1..53d52db9 100644 --- a/Tests/Resgrid.Tests/Services/CalendarServiceCheckInTests.cs +++ b/Tests/Resgrid.Tests/Services/CalendarServiceCheckInTests.cs @@ -333,7 +333,8 @@ public async Task CanUserCheckIn_returns_false_when_different_department() public async Task CanUserAdminCheckIn_returns_true_when_department_admin() { SetupAuthService(); - var dept = new Department { DepartmentId = 10, ManagingUserId = "admin1" }; + var dept = new Department { DepartmentId = 10, ManagingUserId = "admin1", + Members = new List { new DepartmentMember { UserId = "admin1", IsAdmin = true }, new DepartmentMember { UserId = "user1" } } }; _authDeptService.Setup(x => x.GetDepartmentByUserIdAsync("admin1", It.IsAny())).ReturnsAsync(dept); _authCalService.Setup(x => x.GetCalendarItemByIdAsync(1)) .ReturnsAsync(new CalendarItem { CalendarItemId = 1, DepartmentId = 10, CheckInType = (int)CalendarItemCheckInTypes.SelfCheckIn }); @@ -347,7 +348,8 @@ public async Task CanUserAdminCheckIn_returns_true_when_department_admin() public async Task CanUserAdminCheckIn_returns_true_when_event_creator() { SetupAuthService(); - var dept = new Department { DepartmentId = 10, ManagingUserId = "someoneelse" }; + var dept = new Department { DepartmentId = 10, ManagingUserId = "someoneelse", + Members = new List { new DepartmentMember { UserId = "creator1" }, new DepartmentMember { UserId = "user1" } } }; _authDeptService.Setup(x => x.GetDepartmentByUserIdAsync("creator1", It.IsAny())).ReturnsAsync(dept); _authCalService.Setup(x => x.GetCalendarItemByIdAsync(1)) .ReturnsAsync(new CalendarItem { CalendarItemId = 1, DepartmentId = 10, CheckInType = (int)CalendarItemCheckInTypes.AdminOnly, CreatorUserId = "creator1" });