diff --git a/Core/Resgrid.Localization/Account/Login.ar.resx b/Core/Resgrid.Localization/Account/Login.ar.resx
index 4d7ab3280..46537eea0 100644
--- a/Core/Resgrid.Localization/Account/Login.ar.resx
+++ b/Core/Resgrid.Localization/Account/Login.ar.resx
@@ -78,6 +78,9 @@
إذا واجهتك صعوبة في إكمال الدعوة أو صادفت مشكلات، يُرجى التواصل مع مسؤول القسم للحصول على المساعدة. يمكنه إضافة حسابك يدويًا إذا لزم الأمر.
+
+ لا يمكن لهذا القسم قبول أعضاء جدد الآن: فقد بلغ الحد الأقصى لعدد الأفراد في خطته، أو تعذر التحقق من الخطة. يرجى التواصل مع مسؤول القسم الذي دعاك.
+
تأكيد كلمة المرور
diff --git a/Core/Resgrid.Localization/Account/Login.de.resx b/Core/Resgrid.Localization/Account/Login.de.resx
index 09f4c6791..10a40ac89 100644
--- a/Core/Resgrid.Localization/Account/Login.de.resx
+++ b/Core/Resgrid.Localization/Account/Login.de.resx
@@ -83,6 +83,9 @@
If you are having trouble completing the invite or are running into issues, please reach out to the Administrator of the department for the invite you are trying to complete for assistance. They can manually add your account if necessary.
+
+ Diese Abteilung kann derzeit keine neuen Mitglieder aufnehmen: Sie hat das Personallimit ihres Tarifs erreicht, oder der Tarif konnte nicht geprüft werden. Bitte wenden Sie sich an den Abteilungsadministrator, der Sie eingeladen hat.
+
Passwort bestätigen
diff --git a/Core/Resgrid.Localization/Account/Login.el.resx b/Core/Resgrid.Localization/Account/Login.el.resx
index f1f6b8b06..4e6acc720 100644
--- a/Core/Resgrid.Localization/Account/Login.el.resx
+++ b/Core/Resgrid.Localization/Account/Login.el.resx
@@ -132,6 +132,9 @@
Αν αντιμετωπίζετε δυσκολίες στην ολοκλήρωση της πρόσκλησης ή προβλήματα, επικοινωνήστε με τον Διαχειριστή του τμήματος της πρόσκλησης που προσπαθείτε να ολοκληρώσετε για βοήθεια. Μπορεί να προσθέσει τον λογαριασμό σας χειροκίνητα αν χρειαστεί.
+
+ Αυτό το τμήμα δεν μπορεί να δεχτεί νέα μέλη αυτή τη στιγμή: έχει φτάσει το όριο προσωπικού του πακέτου του ή δεν ήταν δυνατός ο έλεγχος του πακέτου. Επικοινωνήστε με τον διαχειριστή του τμήματος που σας προσκάλεσε.
+
Επιβεβαίωση Κωδικού Πρόσβασης
diff --git a/Core/Resgrid.Localization/Account/Login.en.resx b/Core/Resgrid.Localization/Account/Login.en.resx
index 29fe28ec9..300127f54 100644
--- a/Core/Resgrid.Localization/Account/Login.en.resx
+++ b/Core/Resgrid.Localization/Account/Login.en.resx
@@ -132,6 +132,9 @@
If you are having trouble completing the invite or are running into issues, please reach out to the Administrator of the department for the invite you are trying to complete for assistance. They can manually add your account if necessary.
+
+ This department cannot take new members right now: it has reached the personnel limit of its plan, or the plan could not be checked. Please contact the department administrator who invited you.
+
Confirm Password
diff --git a/Core/Resgrid.Localization/Account/Login.es.resx b/Core/Resgrid.Localization/Account/Login.es.resx
index 5caaa286b..147257ebd 100644
--- a/Core/Resgrid.Localization/Account/Login.es.resx
+++ b/Core/Resgrid.Localization/Account/Login.es.resx
@@ -132,6 +132,9 @@
Si tiene problemas para completar la invitación o tiene problemas, comuníquese con el administrador del departamento de la invitación que está tratando de completar para obtener ayuda. Pueden agregar manualmente su cuenta si es necesario.
+
+ Este departamento no puede aceptar nuevos miembros en este momento: ha alcanzado el límite de personal de su plan o no se pudo comprobar el plan. Póngase en contacto con el administrador del departamento que le invitó.
+
confirmar Contraseña
diff --git a/Core/Resgrid.Localization/Account/Login.fr.resx b/Core/Resgrid.Localization/Account/Login.fr.resx
index 827064290..3632a63b0 100644
--- a/Core/Resgrid.Localization/Account/Login.fr.resx
+++ b/Core/Resgrid.Localization/Account/Login.fr.resx
@@ -83,6 +83,9 @@
If you are having trouble completing the invite or are running into issues, please reach out to the Administrator of the department for the invite you are trying to complete for assistance. They can manually add your account if necessary.
+
+ Ce département ne peut pas accueillir de nouveaux membres pour le moment : il a atteint la limite de personnel de son forfait, ou le forfait n'a pas pu être vérifié. Veuillez contacter l'administrateur du département qui vous a invité.
+
Confirmer le mot de passe
diff --git a/Core/Resgrid.Localization/Account/Login.it.resx b/Core/Resgrid.Localization/Account/Login.it.resx
index d65770a55..e86952625 100644
--- a/Core/Resgrid.Localization/Account/Login.it.resx
+++ b/Core/Resgrid.Localization/Account/Login.it.resx
@@ -83,6 +83,9 @@
If you are having trouble completing the invite or are running into issues, please reach out to the Administrator of the department for the invite you are trying to complete for assistance. They can manually add your account if necessary.
+
+ Questo dipartimento non può accettare nuovi membri in questo momento: ha raggiunto il limite di personale del suo piano, oppure non è stato possibile verificare il piano. Contatta l'amministratore del dipartimento che ti ha invitato.
+
Conferma password
diff --git a/Core/Resgrid.Localization/Account/Login.pl.resx b/Core/Resgrid.Localization/Account/Login.pl.resx
index 954451df1..307d3188f 100644
--- a/Core/Resgrid.Localization/Account/Login.pl.resx
+++ b/Core/Resgrid.Localization/Account/Login.pl.resx
@@ -83,6 +83,9 @@
If you are having trouble completing the invite or are running into issues, please reach out to the Administrator of the department for the invite you are trying to complete for assistance. They can manually add your account if necessary.
+
+ Ten oddział nie może teraz przyjąć nowych członków: osiągnął limit personelu swojego planu albo nie można było sprawdzić planu. Skontaktuj się z administratorem oddziału, który Cię zaprosił.
+
Potwierdź hasło
diff --git a/Core/Resgrid.Localization/Account/Login.sv.resx b/Core/Resgrid.Localization/Account/Login.sv.resx
index d42c26d1c..240e8d57e 100644
--- a/Core/Resgrid.Localization/Account/Login.sv.resx
+++ b/Core/Resgrid.Localization/Account/Login.sv.resx
@@ -83,6 +83,9 @@
If you are having trouble completing the invite or are running into issues, please reach out to the Administrator of the department for the invite you are trying to complete for assistance. They can manually add your account if necessary.
+
+ Den här avdelningen kan inte ta emot nya medlemmar just nu: den har nått personalgränsen för sin plan, eller så gick det inte att kontrollera planen. Kontakta den avdelningsadministratör som bjöd in dig.
+
Bekräfta lösenord
diff --git a/Core/Resgrid.Localization/Account/Login.uk.resx b/Core/Resgrid.Localization/Account/Login.uk.resx
index 5c51a9e6f..3ebc5e5f2 100644
--- a/Core/Resgrid.Localization/Account/Login.uk.resx
+++ b/Core/Resgrid.Localization/Account/Login.uk.resx
@@ -83,6 +83,9 @@
If you are having trouble completing the invite or are running into issues, please reach out to the Administrator of the department for the invite you are trying to complete for assistance. They can manually add your account if necessary.
+
+ Цей підрозділ зараз не може прийняти нових членів: він досяг ліміту персоналу свого тарифного плану, або план не вдалося перевірити. Зверніться до адміністратора підрозділу, який вас запросив.
+
Підтвердити пароль
diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.ar.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.ar.resx
index 054ced160..ff69c306d 100644
--- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.ar.resx
+++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.ar.resx
@@ -102,7 +102,7 @@
إضافة عضو
لا يستوفي العضو متطلبات المقعد. حدد «التعيين حتى لو لم يتحقق أحد المتطلبات» للتجاوز.
تقرير وقت جديد
- تقرير واحد لكل يوم؛ تُعبأ الإدخالات مسبقًا من التشكيل.
+ تقرير واحد يوميًا لكل طاقم أو شخص؛ تُملأ القيود مسبقًا من قائمة الأفراد.
لا توجد تقارير وقت بعد.
تاريخ التقرير
تم الإرسال
@@ -275,4 +275,12 @@
يجب عودة جميع الموارد إلى القاعدة قبل إكمال الانتشار.
عرض كشف الموارد
تصدير الموارد بصيغة CSV
+ يشمل
+ عملية النشر بالكامل
+ الطاقم: {0}
+ فردي: {0}
+ قيد وقت يخص شخصًا خارج طاقم هذا التقرير أو الشخص المعني
+ لهذا البند وقت مسجل بالفعل في تقرير آخر لنفس اليوم
+ هذا الطاقم أو الشخص غير نشط في عملية النشر هذه.
+ لهذا الطاقم أو الشخص وقت مسجل بالفعل في تقرير آخر لهذا اليوم.
diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.de.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.de.resx
index a4c5474b5..3f3723c52 100644
--- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.de.resx
+++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.de.resx
@@ -102,7 +102,7 @@
Mitglied hinzufügen
Das Mitglied erfüllt die Anforderungen des Platzes nicht. Aktivieren Sie „Auch bei nicht erfüllter Anforderung einteilen“, um dies zu übergehen.
Neuer Zeitbericht
- Ein Bericht je Tag; die Einträge werden aus der Besetzung vorbelegt.
+ Ein Bericht je Tag für jede Besatzung oder Person; die Einträge werden aus der Besetzung vorbelegt.
Noch keine Zeitberichte.
Berichtsdatum
Eingereicht
@@ -275,4 +275,12 @@
Alle Ressourcen müssen zurückgekehrt sein, bevor der Einsatz abgeschlossen werden kann.
Manifest anzeigen
Ressourcen als CSV exportieren
+ Gilt für
+ Gesamter Einsatz
+ Besatzung: {0}
+ Einzelperson: {0}
+ Ein Zeiteintrag betrifft jemanden außerhalb der Besatzung oder Person dieses Berichts
+ Für diesen Eintrag ist am selben Tag bereits Zeit in einem anderen Bericht erfasst
+ Diese Besatzung oder Person ist in diesem Einsatz nicht aktiv.
+ Für diese Besatzung oder Person ist an diesem Tag bereits Zeit in einem anderen Bericht erfasst.
diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.el.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.el.resx
index a07823984..d155b218a 100644
--- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.el.resx
+++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.el.resx
@@ -102,7 +102,7 @@
Προσθήκη μέλους
Το μέλος δεν πληροί τις απαιτήσεις της θέσης. Επιλέξτε «Τοποθέτηση ακόμη κι αν αποτύχει μια απαίτηση» για παράκαμψη.
Νέα αναφορά χρόνου
- Μία αναφορά ανά ημέρα· οι καταχωρίσεις προσυμπληρώνονται από τη σύνθεση.
+ Μία αναφορά ανά ημέρα για κάθε πλήρωμα ή πρόσωπο· οι καταχωρίσεις συμπληρώνονται από τη σύνθεση.
Δεν υπάρχουν αναφορές χρόνου ακόμη.
Ημερομηνία αναφοράς
Υποβλήθηκε
@@ -275,4 +275,12 @@
Όλοι οι πόροι πρέπει να επιστρέψουν στη βάση πριν ολοκληρωθεί η ανάπτυξη.
Προβολή καταλόγου
Εξαγωγή πόρων CSV
+ Καλύπτει
+ Ολόκληρη η ανάπτυξη
+ Πλήρωμα: {0}
+ Ατομικό: {0}
+ Μια καταχώριση χρόνου αφορά κάποιον εκτός του πληρώματος ή του προσώπου αυτής της αναφοράς
+ Αυτό το αντικείμενο έχει ήδη χρόνο σε άλλη αναφορά για την ίδια ημέρα
+ Αυτό το πλήρωμα ή πρόσωπο δεν είναι ενεργό σε αυτή την ανάπτυξη.
+ Αυτό το πλήρωμα ή πρόσωπο έχει ήδη χρόνο σε άλλη αναφορά για αυτή την ημέρα.
diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.en.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.en.resx
index 19236356b..40bb1e7e5 100644
--- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.en.resx
+++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.en.resx
@@ -102,7 +102,7 @@
Add member
The member does not meet the seat's requirements. Tick "Seat even when a requirement fails" to override.
New time report
- One report per day; entries are prefilled from the roster.
+ One report per day for each crew or person; entries are prefilled from the roster.
No time reports yet.
Report date
Submitted
@@ -275,4 +275,12 @@
Every resource must return home before the deployment can be completed.
View manifest
Export resource CSV
+ Covers
+ Whole deployment
+ Crew: {0}
+ Individual: {0}
+ A time entry is for someone outside this report's crew or person
+ This subject already has time on another report for the same day
+ That crew or person is not active on this deployment.
+ That crew or person already has time on another report for this day.
diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.es.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.es.resx
index 38d460802..ee1e0c54c 100644
--- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.es.resx
+++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.es.resx
@@ -102,7 +102,7 @@
Añadir miembro
El miembro no cumple los requisitos del puesto. Marque «Asignar aunque falle un requisito» para forzarlo.
Nuevo parte de horas
- Un parte por día; las entradas se rellenan desde la dotación.
+ Un parte por día para cada tripulación o persona; las entradas se rellenan desde la dotación.
Aún no hay partes de horas.
Fecha del parte
Enviado
@@ -275,4 +275,12 @@
Todos los recursos deben regresar a su base antes de finalizar el despliegue.
Ver manifiesto
Exportar recursos CSV
+ Cubre
+ Todo el despliegue
+ Tripulación: {0}
+ Persona: {0}
+ Una entrada de tiempo corresponde a alguien ajeno a la tripulación o persona de este parte
+ Este sujeto ya tiene horas en otro parte del mismo día
+ Esa tripulación o persona no está activa en este despliegue.
+ Esa tripulación o persona ya tiene horas en otro parte de este día.
diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.fr.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.fr.resx
index 574b3f49a..ced7563a1 100644
--- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.fr.resx
+++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.fr.resx
@@ -102,7 +102,7 @@
Ajouter un membre
Le membre ne remplit pas les exigences du poste. Cochez « Affecter même si une exigence échoue » pour passer outre.
Nouveau rapport de temps
- Un rapport par jour ; les entrées sont pré-remplies depuis l'effectif.
+ Un rapport par jour pour chaque équipage ou personne ; les entrées sont pré-remplies depuis l'effectif.
Aucun rapport de temps pour l'instant.
Date du rapport
Soumis
@@ -275,4 +275,12 @@
Toutes les ressources doivent être rentrées à leur base avant de terminer le déploiement.
Voir le manifeste
Exporter les ressources CSV
+ Couvre
+ Tout le déploiement
+ Équipage : {0}
+ Individuel : {0}
+ Une entrée de temps concerne quelqu'un hors de l'équipage ou de la personne de ce rapport
+ Ce sujet a déjà du temps sur un autre rapport pour la même journée
+ Cet équipage ou cette personne n'est pas actif sur ce déploiement.
+ Cet équipage ou cette personne a déjà du temps sur un autre rapport pour cette journée.
diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.it.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.it.resx
index 48936e0aa..b39ed2413 100644
--- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.it.resx
+++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.it.resx
@@ -102,7 +102,7 @@
Aggiungi membro
Il membro non soddisfa i requisiti del posto. Spunta «Assegna anche se un requisito non è soddisfatto» per forzare.
Nuovo rapporto ore
- Un rapporto al giorno; le voci sono precompilate dall'organico.
+ Un rapporto al giorno per ogni equipaggio o persona; le voci sono precompilate dall'organico.
Ancora nessun rapporto ore.
Data del rapporto
Inviato
@@ -275,4 +275,12 @@
Tutte le risorse devono rientrare alla base prima di completare il dispiegamento.
Visualizza manifesto
Esporta risorse CSV
+ Copre
+ Intero dispiegamento
+ Equipaggio: {0}
+ Individuale: {0}
+ Una voce di tempo riguarda qualcuno al di fuori dell'equipaggio o della persona di questo rapporto
+ Questo soggetto ha già del tempo su un altro rapporto per lo stesso giorno
+ Questo equipaggio o questa persona non è attivo in questo dispiegamento.
+ Questo equipaggio o questa persona ha già del tempo su un altro rapporto per questo giorno.
diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.pl.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.pl.resx
index 4eb0d6c98..a0a8217a7 100644
--- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.pl.resx
+++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.pl.resx
@@ -102,7 +102,7 @@
Dodaj członka
Członek nie spełnia wymagań stanowiska. Zaznacz „Przydziel nawet gdy wymaganie nie jest spełnione”, aby wymusić.
Nowy raport czasu
- Jeden raport dziennie; wpisy są wypełniane z obsady.
+ Jeden raport dziennie dla każdej załogi lub osoby; wpisy są wstępnie wypełniane ze składu.
Brak raportów czasu.
Data raportu
Przesłano
@@ -275,4 +275,12 @@
Wszystkie zasoby muszą wrócić do bazy przed zakończeniem działań.
Wyświetl wykaz
Eksportuj zasoby CSV
+ Obejmuje
+ Całe rozmieszczenie
+ Załoga: {0}
+ Indywidualny: {0}
+ Wpis czasu dotyczy kogoś spoza załogi lub osoby tego raportu
+ Ten podmiot ma już czas w innym raporcie z tego samego dnia
+ Ta załoga lub osoba nie jest aktywna w tym rozmieszczeniu.
+ Ta załoga lub osoba ma już czas w innym raporcie z tego dnia.
diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.resx
index 19236356b..40bb1e7e5 100644
--- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.resx
+++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.resx
@@ -102,7 +102,7 @@
Add member
The member does not meet the seat's requirements. Tick "Seat even when a requirement fails" to override.
New time report
- One report per day; entries are prefilled from the roster.
+ One report per day for each crew or person; entries are prefilled from the roster.
No time reports yet.
Report date
Submitted
@@ -275,4 +275,12 @@
Every resource must return home before the deployment can be completed.
View manifest
Export resource CSV
+ Covers
+ Whole deployment
+ Crew: {0}
+ Individual: {0}
+ A time entry is for someone outside this report's crew or person
+ This subject already has time on another report for the same day
+ That crew or person is not active on this deployment.
+ That crew or person already has time on another report for this day.
diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.sv.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.sv.resx
index 2bcd7d7ec..f26dfc8b6 100644
--- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.sv.resx
+++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.sv.resx
@@ -102,7 +102,7 @@
Lägg till medlem
Medlemmen uppfyller inte platsens krav. Markera ”Bemanna även om ett krav inte uppfylls” för att åsidosätta.
Ny tidrapport
- En rapport per dag; posterna förifylls från bemanningen.
+ En rapport per dag för varje besättning eller person; posterna förifylls från bemanningen.
Inga tidrapporter ännu.
Rapportdatum
Inskickad
@@ -275,4 +275,12 @@
Alla resurser måste ha återvänt till basen innan insatsen kan avslutas.
Visa manifest
Exportera resurser som CSV
+ Omfattar
+ Hela insatsen
+ Besättning: {0}
+ Individuell: {0}
+ En tidspost gäller någon utanför rapportens besättning eller person
+ Detta objekt har redan tid på en annan rapport för samma dag
+ Den besättningen eller personen är inte aktiv i denna insats.
+ Den besättningen eller personen har redan tid på en annan rapport för denna dag.
diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.uk.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.uk.resx
index d3ebf3d2e..15ee780fb 100644
--- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.uk.resx
+++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.uk.resx
@@ -102,7 +102,7 @@
Додати учасника
Учасник не відповідає вимогам місця. Позначте «Призначити навіть якщо вимога не виконана», щоб обійти.
Новий звіт про час
- Один звіт на день; записи заповнюються зі складу.
+ Один звіт на день для кожного екіпажу або особи; записи заповнюються зі складу.
Звітів про час поки немає.
Дата звіту
Подано
@@ -275,4 +275,12 @@
Усі ресурси мають повернутися на базу, перш ніж розгортання можна буде завершити.
Переглянути маніфест
Експортувати ресурси CSV
+ Охоплює
+ Усе розгортання
+ Екіпаж: {0}
+ Індивідуальний: {0}
+ Запис часу стосується когось поза екіпажем або особою цього звіту
+ Цей суб'єкт уже має час в іншому звіті за той самий день
+ Цей екіпаж або особа не активні в цьому розгортанні.
+ Цей екіпаж або особа вже мають час в іншому звіті за цей день.
diff --git a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.ar.resx b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.ar.resx
index 0a530e48b..52605c656 100644
--- a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.ar.resx
+++ b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.ar.resx
@@ -26,6 +26,7 @@
مدير القسم
إعدادات القسم
تعطيل المستخدم
+ لا يمكن تفعيل هذا الشخص: فقد بلغ القسم الحد الأقصى لعدد الأفراد في خطته، أو تعذر التحقق من الخطة. لا يُحتسب الأعضاء المعطَّلون ضمن الحد. أزل شخصًا آخر أو عطّله، أو قم بترقية الخطة، ثم حاول مرة أخرى. لم يتم حفظ أي شيء في هذه الصفحة.
تعديل
يجب أن تكون عناوين البريد الإلكتروني فريدة
عنوان البريد الإلكتروني
diff --git a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.de.resx b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.de.resx
index 18d4c90a9..7d124f621 100644
--- a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.de.resx
+++ b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.de.resx
@@ -131,6 +131,9 @@
Disable User
+
+ Diese Person kann nicht aktiviert werden: Die Abteilung hat das Personallimit ihres Tarifs erreicht, oder der Tarif konnte nicht geprüft werden. Deaktivierte Mitglieder zählen nicht zum Limit. Entfernen oder deaktivieren Sie eine andere Person oder wechseln Sie in einen größeren Tarif und versuchen Sie es erneut. Auf dieser Seite wurde nichts gespeichert.
+
Bearbeiten
diff --git a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.el.resx b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.el.resx
index 643339a20..555b1fae8 100644
--- a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.el.resx
+++ b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.el.resx
@@ -180,6 +180,9 @@
Απενεργοποίηση Χρήστη
+
+ Αυτό το άτομο δεν μπορεί να ενεργοποιηθεί: το τμήμα έχει φτάσει το όριο προσωπικού του πακέτου του ή δεν ήταν δυνατός ο έλεγχος του πακέτου. Τα απενεργοποιημένα μέλη δεν μετρούν στο όριο. Αφαιρέστε ή απενεργοποιήστε κάποιον άλλον ή αναβαθμίστε το πακέτο και δοκιμάστε ξανά. Τίποτα σε αυτή τη σελίδα δεν αποθηκεύτηκε.
+
Επεξεργασία
diff --git a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.en.resx b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.en.resx
index 1c0011bde..717d5a448 100644
--- a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.en.resx
+++ b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.en.resx
@@ -180,6 +180,9 @@
Disable User
+
+ This person cannot be enabled: the department has reached the personnel limit of its plan, or the plan could not be checked. Disabled members do not count against the limit. Remove or disable someone else, or upgrade the plan, then try again. Nothing on this page was saved.
+
Edit
diff --git a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.es.resx b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.es.resx
index 77a797d66..d3a00f62b 100644
--- a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.es.resx
+++ b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.es.resx
@@ -174,6 +174,9 @@
Desactivar el usuario
+
+ No se puede habilitar a esta persona: el departamento ha alcanzado el límite de personal de su plan o no se pudo comprobar el plan. Los miembros deshabilitados no cuentan para el límite. Elimine o deshabilite a otra persona, o mejore el plan, y vuelva a intentarlo. No se guardó nada de esta página.
+
Editar
diff --git a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.fr.resx b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.fr.resx
index 9b25d2837..5681f628c 100644
--- a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.fr.resx
+++ b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.fr.resx
@@ -131,6 +131,9 @@
Disable User
+
+ Cette personne ne peut pas être réactivée : le département a atteint la limite de personnel de son forfait, ou le forfait n'a pas pu être vérifié. Les membres désactivés ne comptent pas dans la limite. Retirez ou désactivez quelqu'un d'autre, ou passez à un forfait supérieur, puis réessayez. Rien sur cette page n'a été enregistré.
+
Modifier
diff --git a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.it.resx b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.it.resx
index f234f1bff..622e68192 100644
--- a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.it.resx
+++ b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.it.resx
@@ -131,6 +131,9 @@
Disable User
+
+ Questa persona non può essere abilitata: il dipartimento ha raggiunto il limite di personale del suo piano, oppure non è stato possibile verificare il piano. I membri disabilitati non contano nel limite. Rimuovi o disabilita qualcun altro, oppure aggiorna il piano, quindi riprova. Nulla in questa pagina è stato salvato.
+
Modifica
diff --git a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.pl.resx b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.pl.resx
index 7601ad0a6..fd0cc96c1 100644
--- a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.pl.resx
+++ b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.pl.resx
@@ -131,6 +131,9 @@
Disable User
+
+ Nie można włączyć tej osoby: oddział osiągnął limit personelu swojego planu albo nie można było sprawdzić planu. Wyłączeni członkowie nie wliczają się do limitu. Usuń lub wyłącz inną osobę albo zmień plan na wyższy, a następnie spróbuj ponownie. Nic na tej stronie nie zostało zapisane.
+
Edytuj
diff --git a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.sv.resx b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.sv.resx
index 57f9380c8..55e4489aa 100644
--- a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.sv.resx
+++ b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.sv.resx
@@ -131,6 +131,9 @@
Disable User
+
+ Den här personen kan inte aktiveras: avdelningen har nått personalgränsen för sin plan, eller så gick det inte att kontrollera planen. Inaktiverade medlemmar räknas inte mot gränsen. Ta bort eller inaktivera någon annan, eller uppgradera planen, och försök sedan igen. Inget på den här sidan sparades.
+
Redigera
diff --git a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.uk.resx b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.uk.resx
index 65cc99fa5..b64b515e3 100644
--- a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.uk.resx
+++ b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.uk.resx
@@ -131,6 +131,9 @@
Disable User
+
+ Цю особу неможливо ввімкнути: підрозділ досяг ліміту персоналу свого тарифного плану, або план не вдалося перевірити. Вимкнені члени не враховуються в ліміті. Видаліть або вимкніть когось іншого чи оновіть план і спробуйте ще раз. Нічого на цій сторінці не збережено.
+
Редагувати
diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.ar.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.ar.resx
index 1b665e0de..0f4b80c99 100644
--- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.ar.resx
+++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.ar.resx
@@ -446,6 +446,7 @@
تنتهي الصلاحية قريبًا
منتهي الصلاحية
إرجاع متأخر
+ بحوزة عضو مغادر
مفتوح
تمت المعالجة
موعد الاستحقاق (UTC)
@@ -516,6 +517,7 @@
اقتراب انتهاء صلاحية المخزون
اكتمال جرد المخزون
تأخر إعادة العهدة
+ عهدة بحوزة عضو مغادر
هذا المورد مرتبط بسجل المخزون ويجب الاحتفاظ به.
تم تعديل المخزون
اكتمل نقل المخزون
diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.de.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.de.resx
index f235babd5..14a5a726c 100644
--- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.de.resx
+++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.de.resx
@@ -570,6 +570,7 @@
Läuft bald ab
Abgelaufen
Überfällige Rückgabe
+ Bei ausgeschiedenem Mitglied
Offen
Erledigt
Fällig am (UTC)
@@ -640,6 +641,7 @@
Inventar läuft ab
Inventurzählung abgeschlossen
Inventarrückgabe überfällig
+ Inventar bei ausgeschiedenem Mitglied
Diese Ressource wird im Bestandsverlauf referenziert und muss erhalten bleiben.
Bestand angepasst
Bestandsübertragung abgeschlossen
diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.el.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.el.resx
index f113f3e3d..09b2451e6 100644
--- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.el.resx
+++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.el.resx
@@ -629,6 +629,7 @@
Λήγει σύντομα
Έχει λήξει
Εκπρόθεσμη επιστροφή
+ Σε κατοχή μέλους που αποχώρησε
Ανοιχτή
Επιλύθηκε
Προθεσμία (UTC)
@@ -699,6 +700,7 @@
Λήξη αποθέματος σύντομα
Ολοκλήρωση απογραφής
Εκπρόθεσμη επιστροφή εξοπλισμού
+ Εξοπλισμός σε κατοχή μέλους που αποχώρησε
Αυτός ο πόρος αναφέρεται στο ιστορικό αποθέματος και πρέπει να διατηρηθεί.
Το απόθεμα προσαρμόστηκε
Η μεταφορά αποθέματος ολοκληρώθηκε
diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.en.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.en.resx
index bc8a0c070..532eee040 100644
--- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.en.resx
+++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.en.resx
@@ -629,6 +629,7 @@
Expiring soon
Expired
Overdue return
+ Held by departed member
Open
Resolved
Due at (UTC)
@@ -699,6 +700,7 @@
Inventory expiring
Inventory count completed
Inventory return overdue
+ Inventory held by departed member
This resource is referenced by inventory history and must be retained.
Inventory adjusted
Inventory transfer completed
diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.es.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.es.resx
index 70a16330f..9146cbbe3 100644
--- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.es.resx
+++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.es.resx
@@ -570,6 +570,7 @@
Próxima caducidad
Caducado
Devolución vencida
+ En poder de un miembro dado de baja
Abierta
Resuelta
Vencimiento (UTC)
@@ -640,6 +641,7 @@
Inventario próximo a vencer
Recuento de inventario completado
Devolución de inventario atrasada
+ Inventario en poder de un miembro dado de baja
Este recurso está vinculado al historial de inventario y debe conservarse.
Inventario ajustado
Transferencia de inventario completada
diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.fr.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.fr.resx
index ca5674cd9..d2639a3a0 100644
--- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.fr.resx
+++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.fr.resx
@@ -570,6 +570,7 @@
Péremption prochaine
Périmé
Retour en retard
+ Détenu par un membre parti
Ouverte
Résolue
Échéance (UTC)
@@ -640,6 +641,7 @@
Expiration prochaine du stock
Inventaire terminé
Retour de matériel en retard
+ Matériel détenu par un membre parti
Cette ressource est référencée dans l’historique du stock et doit être conservée.
Stock ajusté
Transfert de stock terminé
diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.it.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.it.resx
index 3d64ef5cf..7bef66d7b 100644
--- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.it.resx
+++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.it.resx
@@ -570,6 +570,7 @@
In scadenza
Scaduto
Restituzione in ritardo
+ In possesso di un membro uscito
Aperto
Risolto
Scadenza (UTC)
@@ -640,6 +641,7 @@
Inventario in scadenza
Conteggio inventario completato
Restituzione inventario scaduta
+ Inventario in possesso di un membro uscito
Questa risorsa è citata nello storico dell’inventario e deve essere conservata.
Inventario rettificato
Trasferimento di inventario completato
diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.pl.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.pl.resx
index 8bd1ce156..6eb3c1d5b 100644
--- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.pl.resx
+++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.pl.resx
@@ -570,6 +570,7 @@
Wkrótce traci ważność
Przeterminowane
Zaległy zwrot
+ U byłego członka
Otwarty
Rozwiązany
Termin (UTC)
@@ -640,6 +641,7 @@
Zbliżający się termin ważności zapasów
Zakończono inwentaryzację
Zaległy zwrot wyposażenia
+ Wyposażenie u byłego członka
Ten zasób jest powiązany z historią magazynu i musi zostać zachowany.
Skorygowano stan magazynowy
Zakończono przesunięcie magazynowe
diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.sv.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.sv.resx
index cd1b29171..ec4ba3b52 100644
--- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.sv.resx
+++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.sv.resx
@@ -570,6 +570,7 @@
Förfaller snart
Utgånget
Försenad återlämning
+ Hos avgången medlem
Öppen
Åtgärdad
Förfaller (UTC)
@@ -640,6 +641,7 @@
Lager med nära utgångsdatum
Inventering slutförd
Försenad återlämning
+ Utrustning hos avgången medlem
Den här resursen refereras i lagerhistoriken och måste behållas.
Lager justerat
Lageröverföring slutförd
diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.uk.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.uk.resx
index 07a3d28a9..6d06b7f36 100644
--- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.uk.resx
+++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.uk.resx
@@ -570,6 +570,7 @@
Термін придатності скоро закінчиться
Термін придатності закінчився
Повернення прострочене
+ У колишнього члена
Відкрите
Усунено
Кінцевий термін (UTC)
@@ -640,6 +641,7 @@
Наближається закінчення терміну придатності запасів
Інвентаризацію завершено
Прострочене повернення майна
+ Майно у колишнього члена
На цей ресурс є посилання в історії запасів, тому його потрібно зберегти.
Запаси скориговано
Переміщення запасів завершено
diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.ar.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.ar.resx
index b6a768ad6..50f94ebf9 100644
--- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.ar.resx
+++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.ar.resx
@@ -29,6 +29,10 @@
عنوان البريد الإلكتروني (يجب أن يكون فريداً)
هذا المستخدم
، بناءً على عنوان البريد الإلكتروني، كان لديه حساب Resgrid في قسم آخر. تمت إضافة حساب المستخدم <b>بإعدادات ملفه الشخصي السابقة (الاسم، أرقام الهاتف، التفضيلات، إلخ)</b> إلى القسم. نظراً لكون المستخدم في أقسام متعددة، يجب عليه تفعيل هذا القسم لرؤية معلوماته من خلال خيار "عرض أقسامك" ضمن قائمة الملف الشخصي.
+ إضافة مستخدم موجود
+ ، بناءً على عنوان البريد الإلكتروني، لديه بالفعل حساب Resgrid في قسم آخر. تؤدي الإضافة إلى ضم هذا الحساب إلى هذا القسم بإعدادات ملفه الشخصي الحالية (الاسم وأرقام الهاتف والتفضيلات). ينضم المستخدم عضوًا عاديًا، ويرى هذا القسم بعد التبديل إليه من "أقسامك" في قائمة الملف الشخصي.
+ إضافة إلى القسم
+ بلغ هذا القسم الحد الأقصى لعدد الأفراد في خطته، أو تعذر التحقق من الخطة الآن. أزل شخصًا لم يعد بحاجة إلى الوصول أو قم بترقية الخطة، ثم حاول مرة أخرى.
الاسم الأول
الاسم الأول
المجموعة
@@ -69,6 +73,8 @@
هذا المستخدم
بناءً على عنوان البريد الإلكتروني، كان لديه حساب في قسم
لكنه حُذف سابقاً. تمت إعادة تفعيل حساب المستخدم داخل هذا القسم وسيظهر <b>بإعدادات ملفه الشخصي السابقة (الاسم، أرقام الهاتف، التفضيلات، إلخ)</b> في القسم.
+ لكنه أُزيل. تؤدي إعادة التنشيط إلى استعادة الحساب داخل هذه الإدارة بإعدادات ملفه الشخصي السابقة (الاسم وأرقام الهاتف والتفضيلات). يعود الشخص عضوًا عاديًا؛ امنحه صلاحيات المسؤول مرة أخرى بشكل منفصل إذا احتاج إليها.
+ إعادة التنشيط
تحذير: سيؤدي هذا الإجراء إلى حذف هذا الدور نهائياً. هل أنت متأكد من حذف الدور
وصف الدور
اسم الدور
diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.de.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.de.resx
index 6ca6511e8..3322a6eff 100644
--- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.de.resx
+++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.de.resx
@@ -140,6 +140,18 @@
, based on email address, already had a Resgrid account in another department. The user account has now been added <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> to the department. Because the user is in multiple departments they need activate this department to see it's information from their "View Your Departments" option under their profile dropdown (under the profile picture in the upper left hand corner).
+
+ Vorhandenen Benutzer hinzufügen
+
+
+ , hat laut E-Mail-Adresse bereits ein Resgrid-Konto in einer anderen Abteilung. Beim Hinzufügen kommt dieses Konto mit seinen bestehenden Profileinstellungen (Name, Telefonnummern, Einstellungen) in diese Abteilung. Die Person tritt als normales Mitglied bei und sieht diese Abteilung, sobald sie über "Ihre Abteilungen" im Profilmenü zu ihr wechselt.
+
+
+ Zur Abteilung hinzufügen
+
+
+ Diese Abteilung hat das Personallimit ihres Tarifs erreicht, oder der Tarif konnte gerade nicht geprüft werden. Entfernen Sie eine Person, die keinen Zugang mehr braucht, oder wechseln Sie in einen größeren Tarif und versuchen Sie es erneut.
+
Vorname
@@ -248,6 +260,12 @@
department but was deleted previously. The user account has now be un-deleted and reactivated inside this department and will appear <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> in the department.
+
+ Abteilung, wurde aber entfernt. Durch die Reaktivierung kehrt das Konto mit seinen bisherigen Profileinstellungen (Name, Telefonnummern, Einstellungen) in diese Abteilung zurück. Die Person kehrt als normales Mitglied zurück; Administratorrechte müssen bei Bedarf separat erneut vergeben werden.
+
+
+ Reaktivieren
+
WARNING: This will permanently delete this role. Are you sure you want to delete the role
diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.el.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.el.resx
index 15ba7e8d3..f3d38495a 100644
--- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.el.resx
+++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.el.resx
@@ -189,6 +189,18 @@
, με βάση τη διεύθυνση email, είχε ήδη λογαριασμό Resgrid σε άλλο τμήμα. Ο λογαριασμός χρήστη προστέθηκε πλέον <b>με τις προηγούμενες ρυθμίσεις προφίλ του (όνομα, αριθμοί τηλεφώνου, προτιμήσεις κ.λπ.)</b> στο τμήμα. Επειδή ο χρήστης ανήκει σε πολλά τμήματα, πρέπει να ενεργοποιήσει αυτό το τμήμα για να δει τις πληροφορίες του από την επιλογή «Προβολή των Τμημάτων Σας» στο αναπτυσσόμενο μενού του προφίλ του (κάτω από τη φωτογραφία προφίλ στην επάνω αριστερή γωνία).
+
+ Προσθήκη υπάρχοντος χρήστη
+
+
+ , με βάση τη διεύθυνση email, έχει ήδη λογαριασμό Resgrid σε άλλο τμήμα. Η προσθήκη φέρνει αυτόν τον λογαριασμό σε αυτό το τμήμα με τις υπάρχουσες ρυθμίσεις προφίλ (όνομα, τηλέφωνα, προτιμήσεις). Ο χρήστης μπαίνει ως απλό μέλος και βλέπει αυτό το τμήμα όταν μεταβεί σε αυτό από την επιλογή "Τα Τμήματά Σας" στο μενού προφίλ.
+
+
+ Προσθήκη στο τμήμα
+
+
+ Αυτό το τμήμα έχει φτάσει το όριο προσωπικού του πακέτου του ή δεν ήταν δυνατός ο έλεγχος του πακέτου αυτή τη στιγμή. Αφαιρέστε κάποιον που δεν χρειάζεται πλέον πρόσβαση ή αναβαθμίστε το πακέτο και δοκιμάστε ξανά.
+
Όνομα
@@ -297,6 +309,12 @@
αλλά διαγράφηκε προηγουμένως. Ο λογαριασμός χρήστη επαναφέρθηκε και ενεργοποιήθηκε ξανά μέσα σε αυτό το τμήμα και θα εμφανίζεται <b>με τις προηγούμενες ρυθμίσεις προφίλ του (όνομα, αριθμοί τηλεφώνου, προτιμήσεις κ.λπ.)</b> στο τμήμα.
+
+ αλλά αφαιρέθηκε. Η επανενεργοποίηση επαναφέρει τον λογαριασμό σε αυτό το τμήμα με τις προηγούμενες ρυθμίσεις προφίλ (όνομα, τηλέφωνα, προτιμήσεις). Το άτομο επιστρέφει ως απλό μέλος· δώστε ξανά δικαιώματα διαχειριστή ξεχωριστά αν τα χρειάζεται.
+
+
+ Επανενεργοποίηση
+
ΠΡΟΕΙΔΟΠΟΙΗΣΗ: Αυτό θα διαγράψει οριστικά αυτόν τον ρόλο. Είστε βέβαιοι ότι θέλετε να διαγράψετε τον ρόλο
diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.en.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.en.resx
index 01a9863c6..ddf353041 100644
--- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.en.resx
+++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.en.resx
@@ -189,6 +189,18 @@
, based on email address, already had a Resgrid account in another department. The user account has now been added <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> to the department. Because the user is in multiple departments they need activate this department to see it's information from their "View Your Departments" option under their profile dropdown (under the profile picture in the upper left hand corner).
+
+ Add Existing User
+
+
+ , based on email address, already has a Resgrid account in another department. Adding them brings that account into this department with its existing profile settings (name, phone numbers, preferences). They join as a regular member, and see this department once they switch to it from "Your Departments" in their profile menu.
+
+
+ Add to Department
+
+
+ This department has reached the personnel limit of its plan, or the plan could not be checked right now. Remove someone who no longer needs access or upgrade the plan, then try again.
+
First Name
@@ -297,6 +309,12 @@
department but was deleted previously. The user account has now be un-deleted and reactivated inside this department and will appear <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> in the department.
+
+ department but was removed. Reactivating brings the account back inside this department with its previous profile settings (name, phone numbers, preferences). The person returns as a regular member; grant admin rights again separately if they need them.
+
+
+ Reactivate
+
WARNING: This will permanently delete this role. Are you sure you want to delete the role
diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.es.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.es.resx
index 759a70ca3..769aa7557 100644
--- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.es.resx
+++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.es.resx
@@ -189,6 +189,18 @@
, según la dirección de correo electrónico, ya tenía una cuenta de Resgrid en otro departamento. La cuenta de usuario ahora se ha agregado <b>con su configuración de perfil anterior (nombre, números de teléfono, preferencias, etc.)</b> al departamento. Debido a que el usuario está en varios departamentos, necesita activar este departamento para ver su información desde su opción "Ver sus departamentos" en el menú desplegable de su perfil (debajo de la imagen de perfil en la esquina superior izquierda).
+
+ Agregar usuario existente
+
+
+ , según su dirección de correo electrónico, ya tiene una cuenta de Resgrid en otro departamento. Al agregarlo, esa cuenta se incorpora a este departamento con su configuración de perfil actual (nombre, teléfonos, preferencias). Se une como miembro normal y verá este departamento cuando cambie a él desde "Tus Departamentos" en el menú de perfil.
+
+
+ Agregar al departamento
+
+
+ Este departamento ha alcanzado el límite de personal de su plan, o no se pudo comprobar el plan en este momento. Elimine a alguien que ya no necesite acceso o mejore el plan y vuelva a intentarlo.
+
Nombre de pila
@@ -297,6 +309,12 @@
departamento pero fue borrado previamente. La cuenta de usuario ahora se ha recuperado y reactivado dentro de este departamento y aparecerá <b>con su configuración de perfil anterior (nombre, números de teléfono, preferencias, etc.)</b> en el departamento.
+
+ departamento, pero fue eliminado. Al reactivarlo, la cuenta vuelve a este departamento con su configuración de perfil anterior (nombre, teléfonos, preferencias). La persona vuelve como miembro normal; conceda de nuevo los permisos de administrador por separado si los necesita.
+
+
+ Reactivar
+
ADVERTENCIA: Esto eliminará permanentemente este rol. ¿Estás seguro de que quieres eliminar el rol?
diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.fr.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.fr.resx
index f2a5143d3..26421423a 100644
--- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.fr.resx
+++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.fr.resx
@@ -140,6 +140,18 @@
, based on email address, already had a Resgrid account in another department. The user account has now been added <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> to the department. Because the user is in multiple departments they need activate this department to see it's information from their "View Your Departments" option under their profile dropdown (under the profile picture in the upper left hand corner).
+
+ Ajouter un utilisateur existant
+
+
+ , d'après son adresse e-mail, possède déjà un compte Resgrid dans un autre département. L'ajout intègre ce compte à ce département avec ses paramètres de profil actuels (nom, numéros de téléphone, préférences). La personne rejoint le département comme membre ordinaire et le voit une fois qu'elle y bascule depuis « Vos départements » dans le menu du profil.
+
+
+ Ajouter au département
+
+
+ Ce département a atteint la limite de personnel de son forfait, ou le forfait n'a pas pu être vérifié pour le moment. Retirez une personne qui n'a plus besoin d'accès ou passez à un forfait supérieur, puis réessayez.
+
Prénom
@@ -248,6 +260,12 @@
department but was deleted previously. The user account has now be un-deleted and reactivated inside this department and will appear <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> in the department.
+
+ département, mais en a été retiré. La réactivation rétablit le compte dans ce département avec ses paramètres de profil précédents (nom, numéros de téléphone, préférences). La personne revient comme membre ordinaire ; accordez de nouveau les droits d'administrateur séparément si nécessaire.
+
+
+ Réactiver
+
WARNING: This will permanently delete this role. Are you sure you want to delete the role
diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.it.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.it.resx
index 149d0f879..d7b0d8935 100644
--- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.it.resx
+++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.it.resx
@@ -140,6 +140,18 @@
, based on email address, already had a Resgrid account in another department. The user account has now been added <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> to the department. Because the user is in multiple departments they need activate this department to see it's information from their "View Your Departments" option under their profile dropdown (under the profile picture in the upper left hand corner).
+
+ Aggiungi utente esistente
+
+
+ , in base all'indirizzo e-mail, ha già un account Resgrid in un altro dipartimento. L'aggiunta porta quell'account in questo dipartimento con le impostazioni del profilo esistenti (nome, numeri di telefono, preferenze). Entra come membro normale e vede questo dipartimento dopo esservi passato da "I tuoi dipartimenti" nel menu del profilo.
+
+
+ Aggiungi al dipartimento
+
+
+ Questo dipartimento ha raggiunto il limite di personale del suo piano, oppure non è stato possibile verificare il piano in questo momento. Rimuovi qualcuno che non ha più bisogno di accesso o aggiorna il piano, quindi riprova.
+
Nome
@@ -248,6 +260,12 @@
department but was deleted previously. The user account has now be un-deleted and reactivated inside this department and will appear <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> in the department.
+
+ dipartimento, ma è stato rimosso. La riattivazione riporta l'account in questo dipartimento con le precedenti impostazioni del profilo (nome, numeri di telefono, preferenze). La persona rientra come membro normale; concedere di nuovo i diritti di amministratore separatamente se necessario.
+
+
+ Riattiva
+
WARNING: This will permanently delete this role. Are you sure you want to delete the role
diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.pl.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.pl.resx
index 2da27d77b..1a24a9d42 100644
--- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.pl.resx
+++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.pl.resx
@@ -140,6 +140,18 @@
, based on email address, already had a Resgrid account in another department. The user account has now been added <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> to the department. Because the user is in multiple departments they need activate this department to see it's information from their "View Your Departments" option under their profile dropdown (under the profile picture in the upper left hand corner).
+
+ Dodaj istniejącego użytkownika
+
+
+ , na podstawie adresu e-mail, ma już konto Resgrid w innym oddziale. Dodanie przenosi to konto do tego oddziału z istniejącymi ustawieniami profilu (imię i nazwisko, numery telefonów, preferencje). Osoba dołącza jako zwykły członek i zobaczy ten oddział po przełączeniu się na niego przez "Twoje oddziały" w menu profilu.
+
+
+ Dodaj do oddziału
+
+
+ Ten oddział osiągnął limit personelu swojego planu albo nie można teraz sprawdzić planu. Usuń osobę, która nie potrzebuje już dostępu, lub zmień plan na wyższy, a następnie spróbuj ponownie.
+
Imię
@@ -248,6 +260,12 @@
department but was deleted previously. The user account has now be un-deleted and reactivated inside this department and will appear <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> in the department.
+
+ oddziale, ale został usunięty. Ponowna aktywacja przywraca konto w tym oddziale z poprzednimi ustawieniami profilu (imię i nazwisko, numery telefonów, preferencje). Osoba wraca jako zwykły członek; w razie potrzeby uprawnienia administratora należy nadać ponownie osobno.
+
+
+ Aktywuj ponownie
+
WARNING: This will permanently delete this role. Are you sure you want to delete the role
diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.sv.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.sv.resx
index 0f10843ce..f9cc29eff 100644
--- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.sv.resx
+++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.sv.resx
@@ -140,6 +140,18 @@
, based on email address, already had a Resgrid account in another department. The user account has now been added <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> to the department. Because the user is in multiple departments they need activate this department to see it's information from their "View Your Departments" option under their profile dropdown (under the profile picture in the upper left hand corner).
+
+ Lägg till befintlig användare
+
+
+ , baserat på e-postadressen, har redan ett Resgrid-konto i en annan avdelning. När du lägger till personen förs kontot in i den här avdelningen med sina befintliga profilinställningar (namn, telefonnummer, inställningar). Personen går med som vanlig medlem och ser avdelningen när hen byter till den via "Dina avdelningar" i profilmenyn.
+
+
+ Lägg till i avdelningen
+
+
+ Den här avdelningen har nått personalgränsen för sin plan, eller så gick det inte att kontrollera planen just nu. Ta bort någon som inte längre behöver åtkomst eller uppgradera planen och försök sedan igen.
+
Förnamn
@@ -248,6 +260,12 @@
department but was deleted previously. The user account has now be un-deleted and reactivated inside this department and will appear <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> in the department.
+
+ avdelningen men har tagits bort. Återaktivering för tillbaka kontot i den här avdelningen med dess tidigare profilinställningar (namn, telefonnummer, inställningar). Personen återvänder som vanlig medlem; ge administratörsbehörighet på nytt separat om den behövs.
+
+
+ Återaktivera
+
WARNING: This will permanently delete this role. Are you sure you want to delete the role
diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.uk.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.uk.resx
index 88bfff373..108c8558d 100644
--- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.uk.resx
+++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.uk.resx
@@ -140,6 +140,18 @@
, based on email address, already had a Resgrid account in another department. The user account has now been added <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> to the department. Because the user is in multiple departments they need activate this department to see it's information from their "View Your Departments" option under their profile dropdown (under the profile picture in the upper left hand corner).
+
+ Додати наявного користувача
+
+
+ , за адресою електронної пошти, вже має обліковий запис Resgrid в іншому підрозділі. Додавання переносить цей обліковий запис до цього підрозділу з наявними налаштуваннями профілю (ім'я, номери телефонів, уподобання). Особа приєднується як звичайний член і побачить цей підрозділ, коли перейде до нього через "Ваші підрозділи" в меню профілю.
+
+
+ Додати до підрозділу
+
+
+ Цей підрозділ досяг ліміту персоналу свого тарифного плану, або план зараз не вдалося перевірити. Видаліть когось, кому більше не потрібен доступ, або оновіть план і спробуйте ще раз.
+
Ім'я
@@ -248,6 +260,12 @@
department but was deleted previously. The user account has now be un-deleted and reactivated inside this department and will appear <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> in the department.
+
+ підрозділі, але був видалений. Повторна активація повертає обліковий запис у цей підрозділ із попередніми налаштуваннями профілю (ім'я, номери телефонів, уподобання). Особа повертається як звичайний член; за потреби надайте права адміністратора окремо.
+
+
+ Активувати повторно
+
WARNING: This will permanently delete this role. Are you sure you want to delete the role
diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.ar.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.ar.resx
index a5d3a6421..b2b2b1a9e 100644
--- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.ar.resx
+++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.ar.resx
@@ -45,6 +45,7 @@
الانضمام لقسم
الانضمام لقسم
يجب عليك تقديم معرف القسم ورمزه الحالي للقسم الذي تريد الانضمام إليه في Resgrid.
+ لا يمكن لهذا القسم قبول أعضاء جدد الآن: فقد بلغ الحد الأقصى لعدد الأفراد في خطته، أو تعذر التحقق من الخطة. يرجى التواصل مع أحد مسؤولي القسم.
كلمة المرور الجديدة
كلمة المرور الجديدة لهذا المستخدم
يجب أن تتكون كلمة المرور من 8 أحرف أو أكثر وتتضمن رقماً وحرفاً كبيراً وحرفاً صغيراً
diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.de.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.de.resx
index f23de7165..feb54542b 100644
--- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.de.resx
+++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.de.resx
@@ -176,6 +176,9 @@
You need to supply the current Department Id and Code for the Resgrid Department you wish to join.
+
+ Diese Abteilung kann derzeit keine neuen Mitglieder aufnehmen: Sie hat das Personallimit ihres Tarifs erreicht, oder der Tarif konnte nicht geprüft werden. Bitte wenden Sie sich an einen Administrator der Abteilung.
+
New Password
diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.el.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.el.resx
index acd04958c..025a99832 100644
--- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.el.resx
+++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.el.resx
@@ -225,6 +225,9 @@
Πρέπει να δώσετε το τρέχον Αναγνωριστικό και Κωδικό Τμήματος για το Τμήμα Resgrid στο οποίο θέλετε να συνδεθείτε.
+
+ Αυτό το τμήμα δεν μπορεί να δεχτεί νέα μέλη αυτή τη στιγμή: έχει φτάσει το όριο προσωπικού του πακέτου του ή δεν ήταν δυνατός ο έλεγχος του πακέτου. Επικοινωνήστε με έναν διαχειριστή του τμήματος.
+
Νέος Κωδικός Πρόσβασης
diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.en.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.en.resx
index d3665b3a3..476424617 100644
--- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.en.resx
+++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.en.resx
@@ -225,6 +225,9 @@
You need to supply the current Department Id and Code for the Resgrid Department you wish to join.
+
+ This department cannot take new members right now: it has reached the personnel limit of its plan, or the plan could not be checked. Please contact an administrator of the department.
+
New Password
diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.es.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.es.resx
index ece0af800..05d39c47b 100644
--- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.es.resx
+++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.es.resx
@@ -225,6 +225,9 @@
Debe proporcionar la identificación y código del departamento actual para el departamento de regrid que desea unirse.
+
+ Este departamento no puede aceptar nuevos miembros en este momento: ha alcanzado el límite de personal de su plan o no se pudo comprobar el plan. Póngase en contacto con un administrador del departamento.
+
Nueva contraseña
diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.fr.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.fr.resx
index 4f230809a..8e3b3ce50 100644
--- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.fr.resx
+++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.fr.resx
@@ -176,6 +176,9 @@
You need to supply the current Department Id and Code for the Resgrid Department you wish to join.
+
+ Ce département ne peut pas accueillir de nouveaux membres pour le moment : il a atteint la limite de personnel de son forfait, ou le forfait n'a pas pu être vérifié. Veuillez contacter un administrateur du département.
+
New Password
diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.it.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.it.resx
index 3c661109d..e2c6c37a3 100644
--- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.it.resx
+++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.it.resx
@@ -176,6 +176,9 @@
You need to supply the current Department Id and Code for the Resgrid Department you wish to join.
+
+ Questo dipartimento non può accettare nuovi membri in questo momento: ha raggiunto il limite di personale del suo piano, oppure non è stato possibile verificare il piano. Contatta un amministratore del dipartimento.
+
New Password
diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.pl.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.pl.resx
index 8308202e2..23be55714 100644
--- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.pl.resx
+++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.pl.resx
@@ -176,6 +176,9 @@
You need to supply the current Department Id and Code for the Resgrid Department you wish to join.
+
+ Ten oddział nie może teraz przyjąć nowych członków: osiągnął limit personelu swojego planu albo nie można było sprawdzić planu. Skontaktuj się z administratorem oddziału.
+
New Password
diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.sv.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.sv.resx
index 12549101f..00779d6f7 100644
--- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.sv.resx
+++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.sv.resx
@@ -176,6 +176,9 @@
You need to supply the current Department Id and Code for the Resgrid Department you wish to join.
+
+ Den här avdelningen kan inte ta emot nya medlemmar just nu: den har nått personalgränsen för sin plan, eller så gick det inte att kontrollera planen. Kontakta en administratör för avdelningen.
+
New Password
diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.uk.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.uk.resx
index 35de6fdc1..9b4d0e94e 100644
--- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.uk.resx
+++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.uk.resx
@@ -176,6 +176,9 @@
You need to supply the current Department Id and Code for the Resgrid Department you wish to join.
+
+ Цей підрозділ зараз не може прийняти нових членів: він досяг ліміту персоналу свого тарифного плану, або план не вдалося перевірити. Зверніться до адміністратора підрозділу.
+
New Password
diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.ar.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.ar.resx
index 4cf5e2fb0..76172e281 100644
--- a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.ar.resx
+++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.ar.resx
@@ -705,6 +705,7 @@
قراءة الاستخدام غير صالحة.
نمط العمل غير صالح.
هذا العضو لديه سجل عامل بالفعل.
+ اختر عضوًا حاليًا في هذه الإدارة.
يحتاج العامل إلى عضو أو مفتاح خارجي.
نوع العامل غير صالح.
لم يتم العثور على العامل.
diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.de.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.de.resx
index 1806923a5..9c6b6dd31 100644
--- a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.de.resx
+++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.de.resx
@@ -705,6 +705,7 @@
Die Nutzungsablesung ist ungültig.
Der Arbeitsmodus ist ungültig.
Dieses Mitglied hat bereits einen Beschäftigtendatensatz.
+ Wählen Sie ein aktuelles Mitglied dieser Abteilung.
Eine Person benötigt ein Mitglied oder einen externen Schlüssel.
Die Beschäftigungsart ist ungültig.
Die Person wurde nicht gefunden.
diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.el.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.el.resx
index fd574c337..9398c051e 100644
--- a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.el.resx
+++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.el.resx
@@ -705,6 +705,7 @@
Η ένδειξη χρήσης δεν είναι έγκυρη.
Ο τρόπος εργασίας δεν είναι έγκυρος.
Αυτό το μέλος έχει ήδη εγγραφή εργαζομένου.
+ Επιλέξτε ένα τρέχον μέλος αυτής της υπηρεσίας.
Ένας εργαζόμενος χρειάζεται μέλος ή εξωτερικό κλειδί.
Το είδος εργαζομένου δεν είναι έγκυρο.
Ο εργαζόμενος δεν βρέθηκε.
diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.en.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.en.resx
index 205cc76df..9bf61427a 100644
--- a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.en.resx
+++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.en.resx
@@ -705,6 +705,7 @@
The usage reading is not valid.
The work mode is not valid.
That member already has a worker row.
+ Select a current member of this department.
A worker needs a member or an external key.
The worker kind is not valid.
The worker was not found.
diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.es.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.es.resx
index ea1c6087b..eb12589a8 100644
--- a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.es.resx
+++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.es.resx
@@ -705,6 +705,7 @@
La lectura de uso no es válida.
La modalidad de trabajo no es válida.
Ese miembro ya tiene un registro de trabajador.
+ Seleccione un miembro actual de este departamento.
Un trabajador necesita un miembro o una clave externa.
El tipo de trabajador no es válido.
No se encontró el trabajador.
diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.fr.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.fr.resx
index 9eeda2cf0..a4901b907 100644
--- a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.fr.resx
+++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.fr.resx
@@ -705,6 +705,7 @@
Le relevé d'utilisation n'est pas valide.
Le mode de travail n'est pas valide.
Ce membre a déjà une fiche travailleur.
+ Sélectionnez un membre actuel de ce service.
Un travailleur a besoin d'un membre ou d'une clé externe.
Le type de travailleur n'est pas valide.
Le travailleur est introuvable.
diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.it.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.it.resx
index daf3960fa..32aa86758 100644
--- a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.it.resx
+++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.it.resx
@@ -705,6 +705,7 @@
La lettura di utilizzo non è valida.
La modalità di lavoro non è valida.
Quel membro ha già una scheda lavoratore.
+ Selezionare un membro attuale di questo dipartimento.
Un lavoratore richiede un membro o una chiave esterna.
Il tipo di lavoratore non è valido.
Il lavoratore non è stato trovato.
diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.pl.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.pl.resx
index 4ff8f06be..8d433a04a 100644
--- a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.pl.resx
+++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.pl.resx
@@ -705,6 +705,7 @@
Odczyt wykorzystania jest nieprawidłowy.
Tryb pracy jest nieprawidłowy.
Ten członek ma już rekord pracownika.
+ Wybierz obecnego członka tej jednostki.
Pracownik wymaga członka lub klucza zewnętrznego.
Rodzaj pracownika jest nieprawidłowy.
Nie znaleziono pracownika.
diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.resx
index 205cc76df..9bf61427a 100644
--- a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.resx
+++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.resx
@@ -705,6 +705,7 @@
The usage reading is not valid.
The work mode is not valid.
That member already has a worker row.
+ Select a current member of this department.
A worker needs a member or an external key.
The worker kind is not valid.
The worker was not found.
diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.sv.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.sv.resx
index 2c9612ce1..001efe140 100644
--- a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.sv.resx
+++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.sv.resx
@@ -705,6 +705,7 @@
Användningsavläsningen är ogiltig.
Arbetsformen är ogiltig.
Den medlemmen har redan en arbetstagarpost.
+ Välj en nuvarande medlem i den här avdelningen.
En arbetstagare behöver en medlem eller en extern nyckel.
Typen av arbetstagare är ogiltig.
Arbetstagaren hittades inte.
diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.uk.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.uk.resx
index ea186becd..4e04d2095 100644
--- a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.uk.resx
+++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.uk.resx
@@ -705,6 +705,7 @@
Показник використання недійсний.
Режим роботи недійсний.
Цей член уже має запис працівника.
+ Виберіть чинного члена цього підрозділу.
Працівник потребує члена або зовнішнього ключа.
Тип працівника недійсний.
Працівника не знайдено.
diff --git a/Core/Resgrid.Model/AuditLogTypes.cs b/Core/Resgrid.Model/AuditLogTypes.cs
index 265f26925..d381ba4ab 100644
--- a/Core/Resgrid.Model/AuditLogTypes.cs
+++ b/Core/Resgrid.Model/AuditLogTypes.cs
@@ -340,6 +340,9 @@ public enum AuditLogTypes
ResourceCostProfileChanged,
ResourceUsageChanged,
FieldCostRunCreated,
- FieldCostRunFrozen
+ FieldCostRunFrozen,
+
+ // Member removal lifecycle (2026-09-22): a removed membership brought back; it returns without its old admin standing. Append-only.
+ UserReactivated
}
}
diff --git a/Core/Resgrid.Model/Helpers/DepartmentMemberStateHelper.cs b/Core/Resgrid.Model/Helpers/DepartmentMemberStateHelper.cs
new file mode 100644
index 000000000..1f06a5e27
--- /dev/null
+++ b/Core/Resgrid.Model/Helpers/DepartmentMemberStateHelper.cs
@@ -0,0 +1,22 @@
+namespace Resgrid.Model.Helpers
+{
+ ///
+ /// Membership-state predicates shared by the automated paths. Two tiers, on purpose:
+ ///
+ /// - — not deleted, not disabled. Who may still act in the department
+ /// (perform an assigned checklist, log labor on a work order).
+ /// - — current and not hidden. Who automation addresses and who reports
+ /// name: reminders, escalations, digests, notifications, compliance rows, pickers.
+ ///
+ /// The nullable flags read null as false.
+ ///
+ public static class DepartmentMemberStateHelper
+ {
+ public static bool IsCurrentMember(DepartmentMember member, int departmentId) =>
+ member != null && member.DepartmentId == departmentId && !string.IsNullOrWhiteSpace(member.UserId) &&
+ !member.IsDeleted && !member.IsDisabled.GetValueOrDefault();
+
+ public static bool IsActiveMember(DepartmentMember member, int departmentId) =>
+ IsCurrentMember(member, departmentId) && !member.IsHidden.GetValueOrDefault();
+ }
+}
diff --git a/Core/Resgrid.Model/Helpers/TimeConverterHelper.cs b/Core/Resgrid.Model/Helpers/TimeConverterHelper.cs
index 81bb934b9..d18ad6c2f 100644
--- a/Core/Resgrid.Model/Helpers/TimeConverterHelper.cs
+++ b/Core/Resgrid.Model/Helpers/TimeConverterHelper.cs
@@ -43,6 +43,21 @@ public static DateTime TimeConverter(this DateTime timestamp, Department departm
}
}
+ ///
+ /// The inverse of : a department-local wall-clock value to its UTC instant, resolved in the same
+ /// zone (Pacific when the department has none). DST gaps and overlaps resolve leniently, like the MVC DepartmentTime input
+ /// path, so a typed 02:30 on the spring-forward day still saves. A value already marked UTC keeps its instant.
+ ///
+ public static DateTime DepartmentLocalToUtc(this DateTime local, Department department)
+ {
+ if (local.Kind == DateTimeKind.Utc) return local;
+ if (local.Kind == DateTimeKind.Local) return local.ToUniversalTime();
+ var timeZone = string.IsNullOrEmpty(department?.TimeZone) ? "Pacific Standard Time" : department.TimeZone;
+ var id = DateTimeHelpers.ConvertTimeZoneString(timeZone);
+ var zone = DateTimeZoneProviders.Tzdb.GetZoneOrNull(id) ?? DateTimeZoneProviders.Tzdb[TZConvert.WindowsToIana(id)];
+ return LocalDateTime.FromDateTime(local).InZoneLeniently(zone).ToDateTimeUtc();
+ }
+
public static string TimeConverterToString(this DateTime timestamp, Department department)
{
department ??= new Department();
diff --git a/Core/Resgrid.Model/Inventories/InventoryOperations.cs b/Core/Resgrid.Model/Inventories/InventoryOperations.cs
index 9aee10124..4c5465ed1 100644
--- a/Core/Resgrid.Model/Inventories/InventoryOperations.cs
+++ b/Core/Resgrid.Model/Inventories/InventoryOperations.cs
@@ -4,7 +4,8 @@
namespace Resgrid.Model.Inventories
{
public enum InventoryCountStatus { Draft = 0, AwaitingWitness = 1, Completed = 2, Cancelled = 3 }
- public enum InventoryAlertType { LowStock = 0, ExpiringSoon = 1, Expired = 2, OverdueReturn = 3 }
+ /// DepartedHolder: stock or assets still at a personnel location whose member was removed, disabled or hidden (a recovery task).
+ public enum InventoryAlertType { LowStock = 0, ExpiringSoon = 1, Expired = 2, OverdueReturn = 3, DepartedHolder = 4 }
public sealed class InventoryCount : InventoryMutableRow
{
public string LocationId { get; set; }
@@ -30,6 +31,8 @@ public sealed class InventoryCountContent
public string Name { get; set; }
public string Note { get; set; }
public int VarianceLineCount { get; set; }
+ /// The locations a location-scoped count fences (root plus, when asked, its compartments and kit containers).
+ public List ScopeLocationIds { get; set; }
public List Totals { get; set; } = new();
}
public sealed class InventoryCountItemContent
@@ -41,7 +44,42 @@ public sealed class InventoryCountItemContent
public decimal? UnitCost { get; set; }
public string CurrencyCode { get; set; }
}
- public sealed class InventoryCountInput { public string Id { get; set; } public string LocationId { get; set; } public string Name { get; set; } public string Note { get; set; } }
+ public sealed class InventoryCountInput
+ {
+ public string Id { get; set; }
+ public string LocationId { get; set; }
+ public string Name { get; set; }
+ public string Note { get; set; }
+ ///
+ /// Add a zero-expected line for every active bulk catalog item not already at the location, so a surplus of an unlisted
+ /// item can be counted. Null keeps the original behaviour (on); a field count of an apparatus turns it off because the
+ /// whole catalog would blow the 100-line count limit.
+ ///
+ public bool? IncludeCatalogItems { get; set; }
+ /// Also count the location's compartments (child locations) and the kit containers sitting in it.
+ public bool IncludeChildLocations { get; set; }
+ }
+ /// What a field app may do with inventory (and, for a unit, the unit's own locations to count).
+ public sealed class InventoryFieldAccess
+ {
+ /// Module on for the department (writes also need ).
+ public bool Enabled { get; set; }
+ public bool Migrated { get; set; }
+ /// AdjustInventory at the unit's holder location (or department-wide when no unit was asked for).
+ public bool CanCount { get; set; }
+ public bool CanIssue { get; set; }
+ public bool CanTransfer { get; set; }
+ public List UnitLocations { get; set; } = new();
+ }
+ public sealed class InventoryFieldLocation
+ {
+ public string Id { get; set; }
+ public string Name { get; set; }
+ public string ParentLocationId { get; set; }
+ public int LocationType { get; set; }
+ /// The unit's holder location itself (compartments and containers hang off it).
+ public bool IsRoot { get; set; }
+ }
public sealed class InventoryCountObservation { public string Id { get; set; } public decimal Quantity { get; set; } }
public sealed class InventoryCountUpdate { public string CountId { get; set; } public int Revision { get; set; } public List Lines { get; set; } = new(); }
public sealed class InventoryCountComplete { public string CountId { get; set; } public int Revision { get; set; } public string RequestId { get; set; } }
diff --git a/Core/Resgrid.Model/Inventories/InventoryWorkflowPayload.cs b/Core/Resgrid.Model/Inventories/InventoryWorkflowPayload.cs
index c8cf6eb08..2d74a1a63 100644
--- a/Core/Resgrid.Model/Inventories/InventoryWorkflowPayload.cs
+++ b/Core/Resgrid.Model/Inventories/InventoryWorkflowPayload.cs
@@ -13,7 +13,7 @@ namespace Resgrid.Model.Inventories
public static class InventoryWorkflowPayload
{
public const int CatalogVersion = 22;
- public static readonly IReadOnlyList Triggers = Array.AsReadOnly(new[] { 22, 58, 59, 60, 61, 62, 63, 64, 65, 66, 166 });
+ public static readonly IReadOnlyList Triggers = Array.AsReadOnly(new[] { 22, 58, 59, 60, 61, 62, 63, 64, 65, 66, 166, 188 });
public static readonly (string Variable, string Property)[] Variables =
{
("transaction_id", "TransactionId"), ("item_id", "ItemId"), ("asset_id", "AssetId"), ("lot_id", "LotId"),
@@ -76,7 +76,7 @@ public static string Routing(JObject payload)
foreach (var name in new[] { "TransactionType", "OldStatus", "NewStatus", "ReferenceType" }) CopyInteger(payload, safe, name);
CopyInteger(payload, safe, "UsageType", 3);
CopyInteger(payload, safe, "PurchaseOrderStatus", 4); CopyInteger(payload, safe, "LineCount", 100);
- CopyInteger(payload, safe, "VarianceLineCount", 100); CopyInteger(payload, safe, "AlertType", 3); CopyTimestamp(payload, safe, "DueOn");
+ CopyInteger(payload, safe, "VarianceLineCount", 100); CopyInteger(payload, safe, "AlertType", 4); CopyTimestamp(payload, safe, "DueOn");
if (payload["CurrencyCode"]?.Type == JTokenType.String && payload.Value("CurrencyCode") is { Length: 3 } currency && currency.All(c => c >= 'A' && c <= 'Z')) safe["CurrencyCode"] = currency;
foreach (var name in new[] { "Quantity", "FromQuantityBefore", "FromQuantityAfter", "ToQuantityBefore", "ToQuantityAfter" })
{
diff --git a/Core/Resgrid.Model/Invoicing/DeploymentContracts.cs b/Core/Resgrid.Model/Invoicing/DeploymentContracts.cs
index 5b92c6ffa..d892acf31 100644
--- a/Core/Resgrid.Model/Invoicing/DeploymentContracts.cs
+++ b/Core/Resgrid.Model/Invoicing/DeploymentContracts.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Linq;
namespace Resgrid.Model.Invoicing
{
@@ -84,6 +85,10 @@ public sealed class TimeReportValidation
public const string BreakRule = "break_rule";
public const string LongTravel = "long_travel";
public const string OutsideReportDate = "outside_report_date";
+ /// A crew or individual report carries a subject outside its unit's crew/equipment or its one person (M0227).
+ public const string SubjectOutsideScope = "subject_outside_report_scope";
+ /// The subject already has time on another live report for the same day, which would bill it twice (M0227).
+ public const string SubjectOnOtherReport = "subject_on_other_report";
public List Errors { get; set; } = new List();
public List Warnings { get; set; } = new List();
@@ -98,6 +103,43 @@ public sealed class TimeReportIssue
public string Detail { get; set; }
}
+ ///
+ /// What one person may do with a deployment's time (M0227). Managers write every subject. Anyone else writes their own
+ /// roster row plus, for every deployed unit they crew (seated on that unit's deployment roster, or holding an active
+ /// unit role on the apparatus), the unit, its crew and its equipment — the Crew Time Report a crew boss or the unit's
+ /// tablet files. Reads follow ; the server re-checks all of it on every write.
+ ///
+ public sealed class DeploymentTimeAccess
+ {
+ public bool CanManage { get; set; }
+ /// On the roster now or before (removed rows still see their history).
+ public bool IsRostered { get; set; }
+ /// The caller's active roster row, when they have one.
+ public string PersonnelId { get; set; }
+ /// Deployment unit ids the caller crews.
+ public List CrewUnitIds { get; set; } = new List();
+ /// Subject ids (personnel, unit, equipment) the caller may write; ignored for managers.
+ public HashSet WritableSubjectIds { get; set; } = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ public bool CanRead => CanManage || IsRostered || CrewUnitIds.Count > 0;
+ public bool CanWrite => CanManage || WritableSubjectIds.Count > 0;
+ public bool CanWriteSubject(string subjectId) => CanManage || (!string.IsNullOrWhiteSpace(subjectId) && WritableSubjectIds.Contains(subjectId));
+
+ /// Whether the caller may act on (write, sign, submit) the report as a whole: its scope must be theirs.
+ public bool CanActOn(DeploymentTimeReport report)
+ {
+ if (report == null) return false;
+ if (CanManage) return true;
+ return report.Scope switch
+ {
+ DeploymentTimeReportScopes.Individual => CanWriteSubject(report.DeploymentPersonnelId),
+ DeploymentTimeReportScopes.Crew => CrewUnitIds.Contains(report.DeploymentUnitId, StringComparer.OrdinalIgnoreCase),
+ // A deployment-wide report touches every crew; only a caller who may write every entry on it acts on it whole.
+ _ => report.Entries != null && report.Entries.Count > 0 && report.Entries.TrueForAll(e => CanWriteSubject(e.SubjectId))
+ };
+ }
+ }
+
/// A saved time report plus the validation that ran on it.
public sealed class TimeReportSaveResult
{
diff --git a/Core/Resgrid.Model/Invoicing/DeploymentModels.cs b/Core/Resgrid.Model/Invoicing/DeploymentModels.cs
index 343ce028c..2794acc79 100644
--- a/Core/Resgrid.Model/Invoicing/DeploymentModels.cs
+++ b/Core/Resgrid.Model/Invoicing/DeploymentModels.cs
@@ -38,6 +38,18 @@ public enum DeploymentTimeReportStatuses
Void = 4
}
+ ///
+ /// Who a daily time report covers (M0227). A deployment-wide DTR is the contractor/manager paper; a crew report is the
+ /// Crew Time Report (CTR) one deployed unit files for itself, its crew and its equipment; an individual report is a single
+ /// resource's own time. A subject may sit on only one live report per day, so the scopes never double-count.
+ ///
+ public enum DeploymentTimeReportScopes
+ {
+ Deployment = 0,
+ Crew = 1,
+ Individual = 2
+ }
+
public enum DeploymentTimeSubjectTypes
{
Personnel = 0,
@@ -243,6 +255,10 @@ public class DeploymentTimeReport : IEntity
public int DepartmentId { get; set; }
public int ReportNumber { get; set; }
public DateTime ReportDate { get; set; }
+ /// Crew time report scope: the deployed unit whose crew and equipment this report covers (M0227).
+ public string DeploymentUnitId { get; set; }
+ /// Individual time report scope: the single roster row this report covers (M0227).
+ public string DeploymentPersonnelId { get; set; }
/// .
public int Status { get; set; }
public string IncidentNumber { get; set; }
@@ -274,11 +290,17 @@ public class DeploymentTimeReport : IEntity
public List Entries { get; set; } = new List();
[NotMapped]
public bool IsEditable => Status is (int)DeploymentTimeReportStatuses.Draft or (int)DeploymentTimeReportStatuses.Submitted;
+ [NotMapped]
+ public DeploymentTimeReportScopes Scope => !string.IsNullOrWhiteSpace(DeploymentPersonnelId) ? DeploymentTimeReportScopes.Individual
+ : !string.IsNullOrWhiteSpace(DeploymentUnitId) ? DeploymentTimeReportScopes.Crew : DeploymentTimeReportScopes.Deployment;
+ /// True while the report still counts toward billing and the one-report-per-subject-per-day rule.
+ [NotMapped]
+ public bool IsLive => !IsDeleted && Status != (int)DeploymentTimeReportStatuses.Void;
[NotMapped] public string TableName => "DeploymentTimeReports";
[NotMapped] public string IdName => "DeploymentTimeReportId";
[NotMapped] public int IdType => 1;
[NotMapped] [JsonIgnore] public object IdValue { get => DeploymentTimeReportId; set => DeploymentTimeReportId = (string)value; }
- [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "Entries", "IsEditable" };
+ [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "Entries", "IsEditable", "Scope", "IsLive" };
}
/// One time span for one subject on a DTR (multi-span per day supported). Times are UTC.
diff --git a/Core/Resgrid.Model/Repositories/IDeploymentRepositories.cs b/Core/Resgrid.Model/Repositories/IDeploymentRepositories.cs
index 5a08ee82b..fac6ff2b2 100644
--- a/Core/Resgrid.Model/Repositories/IDeploymentRepositories.cs
+++ b/Core/Resgrid.Model/Repositories/IDeploymentRepositories.cs
@@ -32,6 +32,8 @@ public interface IDeploymentUnitRepository : IRepository
Task> GetByDeploymentAsync(string deploymentId);
/// Units seated on another open deployment overlapping the window (wizard conflict detection).
Task> GetActiveAssignmentsForUnitsAsync(int departmentId, IEnumerable unitIds, DateTime windowStart, DateTime windowEnd, string excludingDeploymentId);
+ /// Every roster row (active or removed) for these department units; the seated crew's "my deployments" scope (M0227).
+ Task> GetForUnitsAsync(int departmentId, IEnumerable unitIds);
}
public interface IDeploymentPersonnelRepository : IRepository
diff --git a/Core/Resgrid.Model/Services/IChecklistAuthorizationService.cs b/Core/Resgrid.Model/Services/IChecklistAuthorizationService.cs
index a797430fa..685756a50 100644
--- a/Core/Resgrid.Model/Services/IChecklistAuthorizationService.cs
+++ b/Core/Resgrid.Model/Services/IChecklistAuthorizationService.cs
@@ -13,5 +13,7 @@ public interface IChecklistAuthorizationService
Task>> ReadFilterAsync(ChecklistActor actor);
Task TargetAsync(ChecklistActor actor, ChecklistTargetType type, string id);
Task> TargetsAsync(ChecklistActor actor, ChecklistTargetType type);
+ /// The department's active members (deleted, disabled and hidden excluded): who reminders reach and whose personnel checks compliance reports count.
+ Task> ActiveMemberIdsAsync(int departmentId);
}
}
diff --git a/Core/Resgrid.Model/Services/ICommunicationService.cs b/Core/Resgrid.Model/Services/ICommunicationService.cs
index 078839161..dcdd05ff2 100644
--- a/Core/Resgrid.Model/Services/ICommunicationService.cs
+++ b/Core/Resgrid.Model/Services/ICommunicationService.cs
@@ -71,6 +71,14 @@ Task SendCancelUnitCallAsync(Call call, CallDispatchUnit dispatch, string
Task SendNotificationAsync(string userId, int departmentId, string message, string departmentNumber, Department department,
string title = "Notification", UserProfile profile = null, bool sendToICApp = false);
+ ///
+ /// Sends the notification with a push event code the app routes on when the push is tapped
+ /// (for example "NWO:{workOrderId}"). The code travels only in the push payload; SMS and email
+ /// carry the same text as the overload without it. A null or blank code keeps the default "N{id}".
+ ///
+ Task SendNotificationAsync(string userId, int departmentId, string message, string departmentNumber, Department department,
+ string title, UserProfile profile, bool sendToICApp, string eventCode);
+
///
/// Sends the chat.
///
diff --git a/Core/Resgrid.Model/Services/IDepartmentsService.cs b/Core/Resgrid.Model/Services/IDepartmentsService.cs
index b5bccb238..233b176ab 100644
--- a/Core/Resgrid.Model/Services/IDepartmentsService.cs
+++ b/Core/Resgrid.Model/Services/IDepartmentsService.cs
@@ -43,7 +43,11 @@ Task UpdateDepartmentAsync(Department department,
Task GetUserIdForDeletedUserInDepartmentAsync(int departmentId, string email);
- Task ReactivateUserAsync(int departmentId, string userId,
+ ///
+ /// Brings a removed membership back: not deleted, disabled or hidden, and never an admin (admin standing is granted
+ /// again deliberately, not restored from the removed row). Audited as UserReactivated; null when there is no row.
+ ///
+ Task ReactivateUserAsync(int departmentId, string userId, string reactivatingUserId,
CancellationToken cancellationToken = default(CancellationToken));
Task AddExistingUserAsync(int departmentId, string userId,
@@ -97,8 +101,22 @@ Task> GetAllUsersForDepartmentAsync(int departmentId, bool re
Task> GetAllPersonnelNamesForDepartmentAsync(int departmentId);
+ ///
+ /// The names a person picker may offer: active members only (removed, disabled and hidden excluded), taken from the
+ /// unlimited roster and ordered by name. still labels historical
+ /// rows and the value already stored on an edit form, which may belong to someone who has since gone inactive.
+ ///
+ Task> GetSelectablePersonnelNamesAsync(int departmentId);
+
+ /// The department's admins and managing user; removed and disabled memberships are excluded.
Task> GetAllAdminsForDepartmentAsync(int departmentId);
+ ///
+ /// without hidden memberships either: the recipients of automated admin
+ /// digests and sweep notices (certifications, compliance documents, finance and MARS reminders, pay-data readiness).
+ ///
+ Task> GetActiveAdminsForDepartmentAsync(int departmentId);
+
Task> GetAllMembersForDepartmentAsync(int departmentId);
Task> GetAllUsersForDepartmentUnlimitedAsync(int departmentId, bool bypassCache = false);
@@ -145,6 +163,14 @@ Task SaveDepartmentCallPruningAsync(DepartmentCallPruning
///
Task> GetMemberUserIdsInDepartmentAsync(int departmentId, IEnumerable userIds);
+ ///
+ /// The user ids of the department's active members: deleted, disabled and hidden memberships are excluded.
+ /// Unlimited (never truncated to the plan's personnel limit) and read in one query, uncached. This is the set
+ /// automated sweeps, digests, notifications and personnel reports address; a stored user id (an assignee, a
+ /// certificate holder, a report subject) outside it belongs to someone who has left or been switched off.
+ ///
+ Task> GetActiveMemberUserIdsAsync(int departmentId);
+
Task> GetAllDepartmentNamesAsync();
Task> GetAllDepartmentsForUserAsync(string userId);
diff --git a/Core/Resgrid.Model/Services/IDeploymentService.cs b/Core/Resgrid.Model/Services/IDeploymentService.cs
index 92e65427a..f561c1ebc 100644
--- a/Core/Resgrid.Model/Services/IDeploymentService.cs
+++ b/Core/Resgrid.Model/Services/IDeploymentService.cs
@@ -29,6 +29,10 @@ public interface IDeploymentService
/// Header rows for the ids (no roster), for a batched existence / ownership check such as search authorization.
Task> GetDeploymentsByIdsAsync(int departmentId, IEnumerable deploymentIds);
Task IsRosteredAsync(string deploymentId, int departmentId, string userId);
+ /// The field member's read scope: rostered (now or before), or seated on a unit (active unit role) that is actively deployed on it.
+ Task CanFieldMemberSeeAsync(string deploymentId, int departmentId, string userId);
+ /// What the member may do with the deployment's time (M0227): own roster row, crewed units and the subjects they may write. Managers write everything.
+ Task GetTimeAccessAsync(Deployment deployment, string userId, bool canManage);
Task SaveDeploymentAsync(Deployment deployment, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default);
/// Planned→Standby→Active→Demobilizing→Completed, Cancelled from any open state; Completed/Cancelled are terminal.
diff --git a/Core/Resgrid.Model/Services/IInventoryModernizationService.cs b/Core/Resgrid.Model/Services/IInventoryModernizationService.cs
index cb34fb402..5e83d7923 100644
--- a/Core/Resgrid.Model/Services/IInventoryModernizationService.cs
+++ b/Core/Resgrid.Model/Services/IInventoryModernizationService.cs
@@ -49,5 +49,7 @@ public interface IInventoryAuthorizationService
Task CanLocationAsync(InventoryActor actor, InventoryLocation location);
Task ValidateHolderAsync(InventoryActor actor, InventoryLocation location);
Task IsEnabledAsync(int departmentId);
+ /// The department's active members (removed, disabled and hidden excluded): a personnel holder outside it has departed.
+ Task> ActiveMemberIdsAsync(int departmentId);
}
}
diff --git a/Core/Resgrid.Model/Services/IInventoryOperationsService.cs b/Core/Resgrid.Model/Services/IInventoryOperationsService.cs
index 6c7136626..d00535625 100644
--- a/Core/Resgrid.Model/Services/IInventoryOperationsService.cs
+++ b/Core/Resgrid.Model/Services/IInventoryOperationsService.cs
@@ -11,6 +11,8 @@ public interface IInventoryOperationsService
Task SaveCountAsync(InventoryActor actor, InventoryCountUpdate input);
Task CompleteCountAsync(InventoryActor actor, InventoryCountComplete input);
Task CancelCountAsync(InventoryActor actor, string id, int revision);
+ /// Never throws for a member: whether inventory is usable and what the caller may do, plus the unit's countable locations.
+ Task GetFieldAccessAsync(InventoryActor actor, int? unitId);
Task RefreshAlertsAsync(InventoryActor actor);
Task BuildReportAsync(InventoryActor actor, InventoryReportInput input);
}
diff --git a/Core/Resgrid.Model/Services/ILimitsService.cs b/Core/Resgrid.Model/Services/ILimitsService.cs
index 7f906702a..772f6893d 100644
--- a/Core/Resgrid.Model/Services/ILimitsService.cs
+++ b/Core/Resgrid.Model/Services/ILimitsService.cs
@@ -18,8 +18,10 @@ public interface ILimitsService
/// Determines whether this instance [can department add new user asynchronous] the specified department identifier.
///
/// The department identifier.
+ /// Read the plan and current counts fresh (the cached limits live 14 days). Pass true when
+ /// the answer gates a write, such as actually adding a person.
/// Task<System.Boolean>.
- Task CanDepartmentAddNewUserAsync(int departmentId);
+ Task CanDepartmentAddNewUserAsync(int departmentId, bool bypassCache = false);
///
/// Determines whether this instance [can department add new group] the specified department identifier.
diff --git a/Core/Resgrid.Model/Services/IRecordsAuthorizationService.cs b/Core/Resgrid.Model/Services/IRecordsAuthorizationService.cs
index 41690f909..9941b9ac1 100644
--- a/Core/Resgrid.Model/Services/IRecordsAuthorizationService.cs
+++ b/Core/Resgrid.Model/Services/IRecordsAuthorizationService.cs
@@ -13,6 +13,12 @@ namespace Resgrid.Model.Services
public interface IRecordsAuthorizationService
{
Task IsActiveMemberAsync(string userId, int departmentId);
+ ///
+ /// Whether Records work may be handed to this person (an owner, inspector, assignee or custodian): a member of the
+ /// department who is not removed, disabled or hidden. Stricter than , which gates who
+ /// may still read and act, and which a hidden member passes.
+ ///
+ Task IsAssignableMemberAsync(string userId, int departmentId);
Task IsDepartmentAdminAsync(string userId, int departmentId);
Task HasPermissionAsync(string userId, int departmentId, PermissionTypes permissionType);
Task CanReadSourceCallAsync(string userId, int departmentId, Call call);
diff --git a/Core/Resgrid.Model/Services/ITimeTrackingService.cs b/Core/Resgrid.Model/Services/ITimeTrackingService.cs
index 2c8537091..f64b710ce 100644
--- a/Core/Resgrid.Model/Services/ITimeTrackingService.cs
+++ b/Core/Resgrid.Model/Services/ITimeTrackingService.cs
@@ -24,10 +24,22 @@ public interface ITimeTrackingService
/// Allocates the next report number, copies the deployment's agency identifiers and prefills one Deployment entry per active roster subject (prior report's times when one exists).
Task CreateTimeReportAsync(string deploymentId, int departmentId, DateTime reportDate, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default);
+ ///
+ /// A scoped report (M0227): makes it that unit's Crew Time Report (the unit, its crew and its
+ /// equipment), one person's own report; both null is the deployment-wide DTR. One live
+ /// report per scope per day; subjects already on another live report that day are not prefilled (timereports_subject_covered
+ /// when the scope's own unit or person is).
+ ///
+ Task CreateTimeReportAsync(string deploymentId, int departmentId, DateTime reportDate, string deploymentUnitId, string deploymentPersonnelId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default);
/// Header fields only (flags, notes, identifiers); entries go through .
Task UpdateTimeReportAsync(DeploymentTimeReport report, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default);
/// Replaces the report's entries as a batch after validation; errors leave the stored entries untouched.
Task SaveTimeEntriesAsync(string deploymentTimeReportId, int departmentId, List entries, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default);
+ ///
+ /// The scoped save: for a caller who does not manage deployments only the subjects in are replaced;
+ /// every other subject's stored entries are kept untouched (never deleted), so concurrent crews cannot erase each other.
+ ///
+ Task SaveTimeEntriesAsync(string deploymentTimeReportId, int departmentId, List entries, DeploymentTimeAccess access, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default);
TimeReportValidation Validate(DeploymentTimeReport report, IReadOnlyList entries, IReadOnlyCollection rosterSubjectIds);
Task SubmitTimeReportAsync(string deploymentTimeReportId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default);
diff --git a/Core/Resgrid.Model/Services/IWorkOrdersService.cs b/Core/Resgrid.Model/Services/IWorkOrdersService.cs
index b60be319c..d8b80cc1a 100644
--- a/Core/Resgrid.Model/Services/IWorkOrdersService.cs
+++ b/Core/Resgrid.Model/Services/IWorkOrdersService.cs
@@ -35,6 +35,8 @@ public interface IWorkOrderAuthorizationService
Task ValidateAssignmentAsync(ChecklistActor actor, WorkOrder row, string userId, int? roleId);
Task ChoicesAsync(ChecklistActor actor);
Task> RecipientsAsync(int departmentId, WorkOrder row);
+ /// The department's active members (removed, disabled and hidden excluded): who automation may assign work to.
+ Task> ActiveMemberIdsAsync(int departmentId);
}
}
@@ -147,7 +149,14 @@ public sealed class WorkOrderChoice { public string Id { get; set; } public stri
public sealed class WorkOrderChoices
{
public string Currency { get; set; } = "USD";
+ /// Who may be picked: active members only.
public List Users { get; set; } = new List();
+ ///
+ /// Display names for every current member the actor may view, hidden ones included (removed and disabled members are
+ /// not): labels history rows and keeps an existing hidden assignee on an edit form after they stop being offered in
+ /// .
+ ///
+ public Dictionary UserNames { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase);
public List Roles { get; set; } = new List();
public List Units { get; set; } = new List();
public List Groups { get; set; } = new List();
diff --git a/Core/Resgrid.Model/Services/IWorkforceServices.cs b/Core/Resgrid.Model/Services/IWorkforceServices.cs
index 4d401a0d7..68de260d4 100644
--- a/Core/Resgrid.Model/Services/IWorkforceServices.cs
+++ b/Core/Resgrid.Model/Services/IWorkforceServices.cs
@@ -35,6 +35,12 @@ public interface IWorkforceService
/// Validates that a worker's periods never overlap.
Task SaveEmploymentAsync(WorkforceEmployment employment, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default);
Task DeleteEmploymentAsync(string id, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default);
+ ///
+ /// A member left the department: every open employment of their worker row is end-dated on
+ /// (never deleted, so past-period statutory data keeps them); one that had not started by then is withdrawn. Audited;
+ /// returns how many rows changed. A member with no worker row is a no-op.
+ ///
+ Task EndEmploymentsForMemberAsync(int departmentId, string userId, DateTime endOn, string actorUserId, CancellationToken cancellationToken = default);
/// Validates that an employment's assignments never overlap and that the establishment is the department's.
Task SaveJobAssignmentAsync(WorkforceJobAssignment assignment, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default);
Task DeleteJobAssignmentAsync(string id, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default);
diff --git a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs
index 4674f0935..f03f1230f 100644
--- a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs
+++ b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs
@@ -839,6 +839,7 @@ pair.Variable is "status" or "old_status" or "contract_type" or "days_until_end"
case WorkflowTriggerEventType.InventoryExpiring:
case WorkflowTriggerEventType.InventoryCountCompleted:
case WorkflowTriggerEventType.InventoryReturnOverdue:
+ case WorkflowTriggerEventType.InventoryDepartedHolder:
case WorkflowTriggerEventType.ControlledSubstanceRecorded:
foreach (var pair in Inventories.InventoryWorkflowPayload.Variables)
{
diff --git a/Core/Resgrid.Model/WorkflowTriggerEventType.cs b/Core/Resgrid.Model/WorkflowTriggerEventType.cs
index 92c19886a..90a41eed3 100644
--- a/Core/Resgrid.Model/WorkflowTriggerEventType.cs
+++ b/Core/Resgrid.Model/WorkflowTriggerEventType.cs
@@ -242,7 +242,10 @@ public enum WorkflowTriggerEventType
CertificationCreditAdded = 184,
TimeReportCreated = 185,
TimeReportVoided = 186,
- DeploymentAttachmentAdded = 187
+ DeploymentAttachmentAdded = 187,
+
+ // Inventory held by a removed, disabled or hidden member (registry 188, 2026-09-22): raised with the DepartedHolder alert.
+ InventoryDepartedHolder = 188
}
public static class WorkflowTriggerEventTypes
diff --git a/Core/Resgrid.Services/AuditService.cs b/Core/Resgrid.Services/AuditService.cs
index da51f3a05..be7ab3882 100644
--- a/Core/Resgrid.Services/AuditService.cs
+++ b/Core/Resgrid.Services/AuditService.cs
@@ -248,6 +248,8 @@ public string GetAuditLogTypeString(AuditLogTypes logType)
return "Moderation Request Completed";
case AuditLogTypes.ModerationEvidenceDownloaded:
return "Moderation Evidence Downloaded";
+ case AuditLogTypes.UserReactivated:
+ return "User Reactivated";
}
return $"Unknown ({logType})";
diff --git a/Core/Resgrid.Services/CertificationService.Sweep.cs b/Core/Resgrid.Services/CertificationService.Sweep.cs
index 0cb27c128..f256caeeb 100644
--- a/Core/Resgrid.Services/CertificationService.Sweep.cs
+++ b/Core/Resgrid.Services/CertificationService.Sweep.cs
@@ -43,6 +43,10 @@ public async Task RunExpirySweepAsync(int departmentId
var leadDays = settings.GetNotifyLeadDays();
var horizon = today.AddDays(leadDays.Count > 0 ? leadDays.Max() : 60);
var types = (await GetAllCertificationTypesByDepartmentAsync(departmentId)).Where(t => !t.IsDeleted).ToDictionary(t => t.DepartmentCertificationTypeId);
+ // Deleted, disabled and hidden members are out of every pass: their records are neither expired nor announced,
+ // they are not notified, enforced against or named in the digest, and no admin among them receives it. The expire
+ // pass catches up on its own if a member is re-enabled (any live record past its date is expired on the next run).
+ var activeMembers = await ActiveMemberUserIdsAsync(departmentId);
var nameCache = new Dictionary();
async Task Name(string userId)
{
@@ -51,7 +55,8 @@ async Task Name(string userId)
}
// ---- Pass 1 + 2: personnel records ---------------------------------------------------------------------
- var records = (await _personnelCertificationRepository.GetExpiringAsync(departmentId, horizon.AddDays(1)))?.Where(r => r.IsTyped && types.ContainsKey(r.DepartmentCertificationTypeId.Value)).ToList() ?? new List();
+ var records = (await _personnelCertificationRepository.GetExpiringAsync(departmentId, horizon.AddDays(1)))?
+ .Where(r => r.IsTyped && types.ContainsKey(r.DepartmentCertificationTypeId.Value) && r.UserId != null && activeMembers.Contains(r.UserId)).ToList() ?? new List();
foreach (var record in records)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -135,7 +140,7 @@ async Task Name(string userId)
cancellationToken.ThrowIfCancellationRequested();
var role = await _roles.Value.GetRoleByIdAsync(roleId);
if (role == null || role.DepartmentId != departmentId) continue;
- var members = (await _roles.Value.GetAllMembersOfRoleAsync(roleId))?.ToList() ?? new List();
+ var members = (await _roles.Value.GetAllMembersOfRoleAsync(roleId))?.Where(m => m?.UserId != null && activeMembers.Contains(m.UserId)).ToList() ?? new List();
if (members.Count == 0) continue;
var roleRequirements = requirements.Where(r => r.PersonnelRoleId == roleId).ToList();
var memberIds = members.Select(m => m.UserId).Distinct().ToList();
@@ -186,7 +191,7 @@ async Task Name(string userId)
{
try
{
- var admins = await _departments.Value.GetAllAdminsForDepartmentAsync(departmentId);
+ var admins = await _departments.Value.GetActiveAdminsForDepartmentAsync(departmentId);
var lines = new List { $"Certification summary for {today:yyyy-MM-dd}: {result.Expired + result.UnitsExpired} expired, {result.ExpiringNotified + result.UnitsExpiringNotified} expiring, {result.InGrace} in grace, {result.Removed} removed from roles." };
if (result.ExpiredNames.Count > 0) lines.Add("Expired: " + string.Join("; ", result.ExpiredNames.Take(25)));
if (result.ExpiringNames.Count > 0) lines.Add("Expiring: " + string.Join("; ", result.ExpiringNames.Take(25)));
diff --git a/Core/Resgrid.Services/CertificationService.cs b/Core/Resgrid.Services/CertificationService.cs
index 17b11a1d1..5ac221ab7 100644
--- a/Core/Resgrid.Services/CertificationService.cs
+++ b/Core/Resgrid.Services/CertificationService.cs
@@ -842,7 +842,11 @@ public async Task GetExpiryDashboardAsync(int depa
var leadDays = (await GetCertificationSettingsAsync(departmentId)).GetNotifyLeadDays();
var horizon = leadDays.Count > 0 ? leadDays.Max() : 60;
- var people = (await GetCertificationsForDepartmentAsync(departmentId)).Where(r => r.IsTyped && byType.ContainsKey(r.DepartmentCertificationTypeId.Value)).ToList();
+ // Deleted, disabled and hidden members stay out of the matrix, its totals and everything built on it (the
+ // dashboard, its CSV, the compliance report and its scheduled delivery); their records remain on file.
+ var activeMembers = await ActiveMemberUserIdsAsync(departmentId);
+ var people = (await GetCertificationsForDepartmentAsync(departmentId))
+ .Where(r => r.IsTyped && byType.ContainsKey(r.DepartmentCertificationTypeId.Value) && r.UserId != null && activeMembers.Contains(r.UserId)).ToList();
// One cached department name list instead of a profile read per member; a member missing from it (a fresh
// account, a stale list) still resolves through the profile.
var departmentNames = (await _departments.Value.GetAllPersonnelNamesForDepartmentAsync(departmentId) ?? new List())
@@ -892,6 +896,13 @@ private static CertificationDashboardCell Cell(string subjectId, string subjectN
#region Helpers
+ /// The department's active members (deleted, disabled and hidden excluded), case-insensitive; empty when none resolve.
+ private async Task> ActiveMemberUserIdsAsync(int departmentId)
+ {
+ var ids = await _departments.Value.GetActiveMemberUserIdsAsync(departmentId);
+ return ids == null ? new HashSet(StringComparer.OrdinalIgnoreCase) : new HashSet(ids, StringComparer.OrdinalIgnoreCase);
+ }
+
private async Task DisplayNameAsync(string userId)
{
try
diff --git a/Core/Resgrid.Services/ChecklistAssignmentService.cs b/Core/Resgrid.Services/ChecklistAssignmentService.cs
index 3d4d73c3d..4124ef178 100644
--- a/Core/Resgrid.Services/ChecklistAssignmentService.cs
+++ b/Core/Resgrid.Services/ChecklistAssignmentService.cs
@@ -4,6 +4,7 @@
using System.Linq;
using System.Threading.Tasks;
using Resgrid.Model.Checklists;
+using Resgrid.Model.Helpers;
using Resgrid.Model.Services;
namespace Resgrid.Services
@@ -55,7 +56,8 @@ public async Task> MembersAsync(int departmentId, int type, stri
public async Task> ChoicesAsync(ChecklistActor actor)
{
var choices = new List();
- foreach (var member in (await _departments.GetAllMembersForDepartmentUnlimitedAsync(actor.DepartmentId, true)).Where(m => m.DepartmentId == actor.DepartmentId && !m.IsDeleted && m.IsDisabled != true))
+ // Pickers offer active members only; hidden members can still perform what they are already assigned (MembersAsync).
+ foreach (var member in (await _departments.GetAllMembersForDepartmentUnlimitedAsync(actor.DepartmentId, true)).Where(m => DepartmentMemberStateHelper.IsActiveMember(m, actor.DepartmentId)))
choices.Add(new ChecklistAssignmentChoice { Type = 1, Id = member.UserId, Name = member.User?.UserName ?? member.UserId });
foreach (var role in (await _roles.GetRolesForDepartmentUnlimitedAsync(actor.DepartmentId)).Where(r => r.DepartmentId == actor.DepartmentId)) choices.Add(new ChecklistAssignmentChoice { Type = 2, Id = role.PersonnelRoleId.ToString(CultureInfo.InvariantCulture), Name = role.Name });
foreach (var group in (await _groups.GetAllGroupsForDepartmentUnlimitedThinAsync(actor.DepartmentId)).Where(g => g.DepartmentId == actor.DepartmentId)) choices.Add(new ChecklistAssignmentChoice { Type = 3, Id = group.DepartmentGroupId.ToString(CultureInfo.InvariantCulture), Name = group.Name });
diff --git a/Core/Resgrid.Services/ChecklistAuthorizationService.cs b/Core/Resgrid.Services/ChecklistAuthorizationService.cs
index f2383e443..7138e4936 100644
--- a/Core/Resgrid.Services/ChecklistAuthorizationService.cs
+++ b/Core/Resgrid.Services/ChecklistAuthorizationService.cs
@@ -26,6 +26,8 @@ public async Task RequireMemberAsync(ChecklistActor actor)
var member = await _departments.GetDepartmentMemberAsync(actor.UserId, actor.DepartmentId, true);
if (member == null || member.IsDeleted || member.IsDisabled.GetValueOrDefault()) throw new ChecklistException(403, "Active department membership is required.");
}
+ public async Task> ActiveMemberIdsAsync(int departmentId)
+ => await _departments.GetActiveMemberUserIdsAsync(departmentId) ?? new HashSet(StringComparer.OrdinalIgnoreCase);
private async Task AllowedAsync(ChecklistActor actor, PermissionTypes type, PermissionActions fallback, int? targetGroup = null)
=> (await PermissionFilterAsync(actor, type, fallback))(targetGroup);
private async Task> PermissionFilterAsync(ChecklistActor actor, PermissionTypes type, PermissionActions fallback)
@@ -103,7 +105,9 @@ public async Task> TargetsAsync(ChecklistActor actor, Chec
case ChecklistTargetType.Department: ids = new[] { actor.DepartmentId.ToString() }; break;
case ChecklistTargetType.Unit: ids = (await _units.GetUnitsForDepartmentAsync(actor.DepartmentId)).Select(u => u.UnitId.ToString()); break;
case ChecklistTargetType.Group: ids = (await _groups.GetAllGroupsForDepartmentAsync(actor.DepartmentId)).Select(g => g.DepartmentGroupId.ToString()); break;
- case ChecklistTargetType.Personnel: ids = (await _departments.GetAllMembersForDepartmentAsync(actor.DepartmentId)).Where(m => !m.IsDeleted && !m.IsDisabled.GetValueOrDefault()).Select(m => m.UserId); break;
+ // The picker offers active members from the unlimited roster; a schedule already aimed at someone who has since
+ // gone hidden still validates through TargetAsync and keeps its value on the edit form.
+ case ChecklistTargetType.Personnel: ids = await _departments.GetActiveMemberUserIdsAsync(actor.DepartmentId) ?? new HashSet(); break;
case ChecklistTargetType.InventoryAsset:
if (_assets == null || !await _assets.IsAvailableAsync(actor.DepartmentId)) return new List();
ids = (await _assets.ListAsync(actor)).Where(a => a.DepartmentId == actor.DepartmentId).Select(a => a.Id); break;
diff --git a/Core/Resgrid.Services/ChecklistReminderService.cs b/Core/Resgrid.Services/ChecklistReminderService.cs
index fd8371893..c5513c85e 100644
--- a/Core/Resgrid.Services/ChecklistReminderService.cs
+++ b/Core/Resgrid.Services/ChecklistReminderService.cs
@@ -9,6 +9,7 @@
using Resgrid.Localization;
using Resgrid.Model;
using Resgrid.Model.Checklists;
+using Resgrid.Model.Helpers;
using Resgrid.Model.Repositories;
using Resgrid.Model.Repositories.Queries;
using Resgrid.Model.Services;
@@ -132,8 +133,10 @@ private async Task> RecipientsAsync(ChecklistOccurrence row, Che
}
var department = await _departments.GetDepartmentByIdAsync(row.DepartmentId, true);
if (department == null) return new HashSet();
+ // Reminders and escalations reach active members only: removed, disabled and hidden members (admins included) are
+ // never recipients, whatever routed them here.
var members = (await _departments.GetAllMembersForDepartmentUnlimitedAsync(row.DepartmentId, true))
- .Where(m => m.DepartmentId == row.DepartmentId && !m.IsDeleted && m.IsDisabled != true && !string.IsNullOrWhiteSpace(m.UserId)).ToList();
+ .Where(m => DepartmentMemberStateHelper.IsActiveMember(m, row.DepartmentId)).ToList();
var allowed = members.Select(m => m.UserId).ToHashSet(StringComparer.Ordinal);
var admins = members.Where(m => m.IsAdmin == true || m.UserId == department.ManagingUserId).Select(m => m.UserId).ToHashSet(StringComparer.Ordinal);
var users = new HashSet(StringComparer.Ordinal);
diff --git a/Core/Resgrid.Services/ChecklistReporting.cs b/Core/Resgrid.Services/ChecklistReporting.cs
index d869e6753..d91c392a5 100644
--- a/Core/Resgrid.Services/ChecklistReporting.cs
+++ b/Core/Resgrid.Services/ChecklistReporting.cs
@@ -21,7 +21,10 @@ public async Task GetComplianceSummaryAsync(Checklis
await RequireWriteAsync(actor); ReportRange(query);
var asOf = _clock.GetUtcNow().UtcDateTime;
var summary = new ChecklistComplianceSummary { FromUtc = query.FromUtc, UntilUtc = query.UntilUtc, AsOfUtc = asOf, TargetType = query.TargetType, TargetId = query.TargetId };
- summary.Entries = await ReportEntriesAsync(actor, query, asOf, summary.UnavailableSources);
+ // Personnel checks of deleted, disabled or hidden members are not counted or listed (the compliance report, its CSV and
+ // scheduled delivery, and the readiness dashboard all read this summary). The entity history keeps them on file.
+ var activePersonnel = await _authorization.ActiveMemberIdsAsync(actor.DepartmentId) ?? new HashSet(StringComparer.OrdinalIgnoreCase);
+ summary.Entries = await ReportEntriesAsync(actor, query, asOf, summary.UnavailableSources, activePersonnel: activePersonnel);
summary.Groups = summary.Entries.GroupBy(e => (e.Target.Type, e.Target.Id)).Select(g => new ChecklistComplianceGroup
{
Target = g.Last().Target, Expected = g.Count(e => e.Expected), Completed = g.Count(e => e.Expected && e.Completed),
@@ -40,7 +43,7 @@ public async Task> GetEntityChecklistHistoryAsync(Che
return await ReportEntriesAsync(actor, query, _clock.GetUtcNow().UtcDateTime, new List());
}
private async Task> ReportEntriesAsync(ChecklistActor actor, ChecklistReportQuery query, DateTime asOf, List unavailable,
- HashSet<(ChecklistTargetType, string)> targets = null)
+ HashSet<(ChecklistTargetType, string)> targets = null, HashSet activePersonnel = null)
{
var result = new List();
var versions = new Dictionary();
@@ -53,7 +56,8 @@ private async Task> ReportEntriesAsync(ChecklistActor
{
var type = (ChecklistTargetType)occurrence.TargetType;
if (occurrence.DepartmentId != actor.DepartmentId || query.TargetType.HasValue && query.TargetType != type || query.TargetId != null && query.TargetId != occurrence.TargetId
- || targets != null && !targets.Contains((type, occurrence.TargetId))) continue;
+ || targets != null && !targets.Contains((type, occurrence.TargetId))
+ || activePersonnel != null && type == ChecklistTargetType.Personnel && (occurrence.TargetId == null || !activePersonnel.Contains(occurrence.TargetId))) continue;
var completion = occurrence.CompletionId == null ? null : await _store.GetAsync(actor.DepartmentId, occurrence.CompletionId);
ChecklistSchedule schedule = null;
if (occurrence.ScheduleId != null && !schedules.TryGetValue(occurrence.ScheduleId, out schedule))
diff --git a/Core/Resgrid.Services/ChecklistTimedReminders.cs b/Core/Resgrid.Services/ChecklistTimedReminders.cs
index 148cc01f1..9521f2b03 100644
--- a/Core/Resgrid.Services/ChecklistTimedReminders.cs
+++ b/Core/Resgrid.Services/ChecklistTimedReminders.cs
@@ -6,6 +6,7 @@
using System.Threading.Tasks;
using Resgrid.Model;
using Resgrid.Model.Checklists;
+using Resgrid.Model.Helpers;
namespace Resgrid.Services
{
@@ -33,6 +34,14 @@ private async Task TimedRecipientAsync(ChecklistOccurrence row, ChecklistR
private async Task> ShiftCrewAsync(Unit unit)
{
var crew = (await _units.GetActiveRolesForUnitAsync(unit.UnitId)).Where(r => r.DepartmentId == unit.DepartmentId && r.UnitId == unit.UnitId && !string.IsNullOrWhiteSpace(r.UserId)).Select(r => r.UserId).ToHashSet(StringComparer.Ordinal);
+ // A unit seat still held by a removed, disabled or hidden member counts as empty, the same way RecipientsAsync treats
+ // it: otherwise the station fallback never runs and the shift gets no reminder at all.
+ if (crew.Count > 0)
+ {
+ var active = (await _departments.GetAllMembersForDepartmentUnlimitedAsync(unit.DepartmentId, true))
+ .Where(m => DepartmentMemberStateHelper.IsActiveMember(m, unit.DepartmentId)).Select(m => m.UserId).ToHashSet(StringComparer.Ordinal);
+ crew.IntersectWith(active);
+ }
if (crew.Count == 0 && unit.StationGroupId.HasValue && (await _groups.GetGroupByIdAsync(unit.StationGroupId.Value, true))?.DepartmentId == unit.DepartmentId)
crew.UnionWith((await _groups.GetAllMembersForGroupAsync(unit.StationGroupId.Value)).Where(m => m.DepartmentId == unit.DepartmentId).Select(m => m.UserId));
return crew;
diff --git a/Core/Resgrid.Services/ChecklistsScheduling.cs b/Core/Resgrid.Services/ChecklistsScheduling.cs
index 39e34bfad..df6cfb4bd 100644
--- a/Core/Resgrid.Services/ChecklistsScheduling.cs
+++ b/Core/Resgrid.Services/ChecklistsScheduling.cs
@@ -176,6 +176,22 @@ private async Task WorkerAuditAsync(ChecklistRow row, AuditLogTypes type, DateTi
if (result?.Success != true || result.IsProtected && !ProtectedDataEnvelope.HasEnvelopePrefix(audit.Data)) throw new InvalidOperationException("Checklist scheduling audit protection is unavailable.");
await _audit.UpdateAsync(audit, ct);
}
+ ///
+ /// A schedule addressed to one person (a Personnel target or a User assignment) is held suspended while that person is
+ /// removed or disabled: no occurrences, no missed marks, no ChecklistMissed events and no admin escalations for a check
+ /// nobody can perform. The suspended branch of the sweep resumes it without back-filled misses if the member returns.
+ ///
+ private async Task PersonSubjectsCurrentAsync(ChecklistSchedule schedule)
+ {
+ if (_assignments == null) return true;
+ try
+ {
+ if (schedule.TargetType == (int)ChecklistTargetType.Personnel) await _assignments.ValidateAsync(schedule.DepartmentId, (int)ChecklistAssignmentType.User, schedule.TargetId);
+ if (schedule.AssignmentType == (int)ChecklistAssignmentType.User) await _assignments.ValidateAsync(schedule.DepartmentId, (int)ChecklistAssignmentType.User, schedule.AssignmentId);
+ return true;
+ }
+ catch (ChecklistException) { return false; }
+ }
public async Task SweepSchedulesAsync(DateTime utcNow, CancellationToken ct = default)
{
var result = new ChecklistScheduleSweepResult(); var now = ChecklistRecurrence.Utc(utcNow); var afterDepartment = 0;
@@ -196,7 +212,8 @@ public async Task SweepSchedulesAsync(DateTime utc
await _uow.CreateOrGetConnectionAsync(ct); await _store.LockDepartmentAsync(department, ct);
var schedule = await _store.GetAsync(department, candidate.Id, ct);
if (!schedule.IsActive) { _uow.CommitChanges(); continue; }
- var enabled = await _access.CanUseChecklistsAsync(department) && (schedule.TargetType != (int)ChecklistTargetType.InventoryAsset || _assets != null && await _assets.IsAvailableAsync(department));
+ var enabled = await _access.CanUseChecklistsAsync(department) && (schedule.TargetType != (int)ChecklistTargetType.InventoryAsset || _assets != null && await _assets.IsAvailableAsync(department))
+ && await PersonSubjectsCurrentAsync(schedule);
if (!enabled)
{
schedule.IsSuspended = true; schedule.UpdatedOn = now; await WorkerWriteAsync(schedule, false, ct); _uow.CommitChanges(); continue;
diff --git a/Core/Resgrid.Services/CommunicationService.cs b/Core/Resgrid.Services/CommunicationService.cs
index cb8434874..28b5b7f69 100644
--- a/Core/Resgrid.Services/CommunicationService.cs
+++ b/Core/Resgrid.Services/CommunicationService.cs
@@ -653,7 +653,12 @@ public async Task SendCancelUnitCallAsync(Call call, CallDispatchUnit disp
return true;
}
- public async Task SendNotificationAsync(string userId, int departmentId, string message, string departmentNumber, Department department, string title = "Notification", UserProfile profile = null, bool sendToICApp = false)
+ public Task SendNotificationAsync(string userId, int departmentId, string message, string departmentNumber, Department department, string title = "Notification", UserProfile profile = null, bool sendToICApp = false)
+ {
+ return SendNotificationAsync(userId, departmentId, message, departmentNumber, department, title, profile, sendToICApp, null);
+ }
+
+ public async Task SendNotificationAsync(string userId, int departmentId, string message, string departmentNumber, Department department, string title, UserProfile profile, bool sendToICApp, string eventCode)
{
if (Config.SystemBehaviorConfig.DoNotBroadcast && !Config.SystemBehaviorConfig.BypassDoNotBroadcastDepartments.Contains(departmentId))
return false;
@@ -699,6 +704,8 @@ public async Task SendNotificationAsync(string userId, int departmentId, s
spm.SubTitle = $"{title} {message}";
spm.DepartmentCode = department?.Code;
spm.DepartmentId = departmentId;
+ if (!string.IsNullOrWhiteSpace(eventCode))
+ spm.Id = eventCode;
try
{
diff --git a/Core/Resgrid.Services/CommunicationTestService.cs b/Core/Resgrid.Services/CommunicationTestService.cs
index 3eaf0a555..b1f43c856 100644
--- a/Core/Resgrid.Services/CommunicationTestService.cs
+++ b/Core/Resgrid.Services/CommunicationTestService.cs
@@ -8,6 +8,7 @@
using Resgrid.Framework;
using CommunicationTestMessages = Resgrid.Localization.Areas.User.CommunicationTest.CommunicationTestMessageCatalog;
using Resgrid.Model;
+using Resgrid.Model.Helpers;
using Resgrid.Model.Messages;
using Resgrid.Model.Providers;
using Resgrid.Model.Queue;
@@ -415,7 +416,10 @@ public async Task BuildRunResultsAsync(Guid communicationT
var communicationTestId = run.CommunicationTestId;
var departmentId = run.DepartmentId;
- var members = await _departmentsService.GetAllMembersForDepartmentAsync(departmentId);
+ // Disabled and hidden members are not tested: nobody is paged on their account and the report does not count them
+ // (the member list already leaves removed members out).
+ var members = (await _departmentsService.GetAllMembersForDepartmentAsync(departmentId) ?? new List())
+ .Where(m => DepartmentMemberStateHelper.IsActiveMember(m, departmentId)).ToList();
var profiles = await _userProfileService.GetAllProfilesForDepartmentAsync(departmentId);
// A targeted test only covers the audience snapshotted when the run started, so editing
diff --git a/Core/Resgrid.Services/CostRecovery/CalOesMarsService.WorkItems.cs b/Core/Resgrid.Services/CostRecovery/CalOesMarsService.WorkItems.cs
index 59a879b9e..590a7264f 100644
--- a/Core/Resgrid.Services/CostRecovery/CalOesMarsService.WorkItems.cs
+++ b/Core/Resgrid.Services/CostRecovery/CalOesMarsService.WorkItems.cs
@@ -33,17 +33,24 @@ public async Task> GetActionQueueAsync(int departmentI
var items = (await _workItems.GetActionQueueAsync(departmentId))?.ToList() ?? new List();
var deploymentIds = items.Where(i => !string.IsNullOrWhiteSpace(i.DeploymentId)).Select(i => i.DeploymentId).Distinct().ToList();
var deployments = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ // "Mine" is the same rule IsRosteredForWorkItemAsync opens the item with (roster, or a seat on a deployed unit),
+ // answered once per deployment, so a field user finds in the queue every item they can open.
+ var visible = new HashSet(StringComparer.OrdinalIgnoreCase);
foreach (var id in deploymentIds)
{
var deployment = await _deploymentService.GetDeploymentByIdAsync(id, departmentId);
- if (deployment != null) deployments[id] = deployment;
+ if (deployment == null) continue;
+ deployments[id] = deployment;
+ if (string.IsNullOrWhiteSpace(userId)) continue;
+ // The roster is already loaded; only a member it does not name costs the seat lookup.
+ if (deployment.Personnel.Any(p => string.Equals(p.UserId, userId, StringComparison.OrdinalIgnoreCase)) || await _deploymentService.CanFieldMemberSeeAsync(id, departmentId, userId)) visible.Add(id);
}
var result = new List();
var now = DateTime.UtcNow;
foreach (var item in items)
{
deployments.TryGetValue(item.DeploymentId ?? string.Empty, out var deployment);
- var mine = deployment != null && !string.IsNullOrWhiteSpace(userId) && deployment.Personnel.Any(p => p.IsActive && string.Equals(p.UserId, userId, StringComparison.OrdinalIgnoreCase));
+ var mine = deployment != null && visible.Contains(item.DeploymentId);
// Field users see only their own incident-bound F-42 / expense drafts; managers see the department queue.
if (!managerScope && (!mine || item.RecordType == (int)CalOesMarsRecordTypes.GeneratedInvoice)) continue;
var validation = Deserialize(item.ValidationSummaryJson);
@@ -79,7 +86,7 @@ public async Task IsRosteredForWorkItemAsync(string workItemId, int depart
var item = await _workItems.GetByIdForDepartmentAsync(workItemId, departmentId);
if (item == null || item.IsDeleted || string.IsNullOrWhiteSpace(item.DeploymentId) || string.IsNullOrWhiteSpace(userId)) return false;
if (item.RecordType == (int)CalOesMarsRecordTypes.GeneratedInvoice) return false;
- return await _deploymentService.IsRosteredAsync(item.DeploymentId, departmentId, userId);
+ return await _deploymentService.CanFieldMemberSeeAsync(item.DeploymentId, departmentId, userId);
}
#endregion
@@ -894,7 +901,7 @@ private async Task NotifyManagersAsync(int departmentId, string message)
var department = await _departmentsService.GetDepartmentByIdAsync(departmentId, false);
var number = _departmentSettings?.Value == null ? null : await _departmentSettings.Value.GetTextToCallNumberForDepartmentAsync(departmentId);
// Permission 79 defaults to department administrators; the digest goes to them (a narrower role assignment still includes admins).
- foreach (var admin in await _departmentsService.GetAllAdminsForDepartmentAsync(departmentId))
+ foreach (var admin in await _departmentsService.GetActiveAdminsForDepartmentAsync(departmentId))
await _communication.Value.SendNotificationAsync(admin.UserId, departmentId, message, number, department, "Cal OES MARS");
}
catch (Exception ex) { Logging.LogException(ex, $"Cal OES MARS digest could not be sent for department {departmentId}."); }
diff --git a/Core/Resgrid.Services/DeleteService.cs b/Core/Resgrid.Services/DeleteService.cs
index 1f6032f78..9bed36f2c 100644
--- a/Core/Resgrid.Services/DeleteService.cs
+++ b/Core/Resgrid.Services/DeleteService.cs
@@ -7,6 +7,7 @@
using Resgrid.Framework;
using Resgrid.Model;
using Resgrid.Model.Events;
+using Resgrid.Model.Helpers;
using Resgrid.Model.Providers;
using Resgrid.Model.Repositories;
using Resgrid.Model.Services;
@@ -15,6 +16,10 @@ namespace Resgrid.Services
{
public class DeleteService : IDeleteService
{
+ // QueueItems.Data is nvarchar(255) on SQL Server (M0020 AsString()); a longer status message
+ // (an exception message) fails the whole update, losing the attempt count with it.
+ private const int QueueItemDataMaxLength = 255;
+
private readonly IAuthorizationService _authorizationService;
private readonly IDepartmentsService _departmentsService;
private readonly ICallsService _callsService;
@@ -44,6 +49,9 @@ public class DeleteService : IDeleteService
private readonly IDepartmentMemberEmergencyContactService _emergencyContactService;
private readonly IInventoryStore _inventoryStore;
private readonly Resgrid.Model.Repositories.Queries.IUnitOfWork _inventoryUnitOfWork;
+ private readonly IDeploymentService _deploymentService;
+ private readonly IDeploymentPersonnelRepository _deploymentPersonnel;
+ private readonly IWorkforceService _workforceService;
public DeleteService(IAuthorizationService authorizationService, IDepartmentsService departmentsService,
ICallsService callsService, IActionLogsService actionLogsService, IUsersService usersService,
@@ -56,8 +64,12 @@ public DeleteService(IAuthorizationService authorizationService, IDepartmentsSer
IScheduledTasksService scheduledTasksService, IUserSessionService userSessionService,
IDepartmentMemberSensitiveDataService memberSensitiveDataService,
IDepartmentMemberEmergencyContactService emergencyContactService,
- IInventoryStore inventoryStore = null, Resgrid.Model.Repositories.Queries.IUnitOfWork inventoryUnitOfWork = null)
+ IInventoryStore inventoryStore = null, Resgrid.Model.Repositories.Queries.IUnitOfWork inventoryUnitOfWork = null,
+ IDeploymentService deploymentService = null, IDeploymentPersonnelRepository deploymentPersonnel = null, IWorkforceService workforceService = null)
{
+ _deploymentService = deploymentService;
+ _deploymentPersonnel = deploymentPersonnel;
+ _workforceService = workforceService;
_authorizationService = authorizationService;
_departmentsService = departmentsService;
_callsService = callsService;
@@ -106,7 +118,7 @@ public DeleteService(IAuthorizationService authorizationService, IDepartmentsSer
{
// This is the user's only department: deactivate the whole account (same flow as the
// self-service "Delete My Account") so we don't strand a login with no departments.
- return await DeactivateUserAccountCoreAsync(userIdToDelete, departmentId, null, null, cancellationToken);
+ return await DeactivateUserAccountCoreAsync(userIdToDelete, departmentId, null, null, authorizingUserId, cancellationToken);
}
// The user belongs to other departments: revoke this department's access and
@@ -126,6 +138,7 @@ public DeleteService(IAuthorizationService authorizationService, IDepartmentsSer
await _departmentGroupsService.DeleteUserFromGroupsAsync(userId, departmentId, cancellationToken);
await _distributionListsService.RemoveUserFromAllListsInDepartmentAsync(userId, departmentId, cancellationToken);
await _scheduledTasksService.DeleteAllTasksForUserInDepartmentAsync(userId, departmentId, cancellationToken);
+ await ReleaseOperationalAssignmentsAsync(userId, departmentId, revokingUserId ?? userId, cancellationToken);
// Department-scoped personal data goes with the membership (ADP plan 5.1). Before the
// relocation these values lived on the global profile and a revoked member simply stopped
@@ -155,15 +168,43 @@ await _userSessionService.RevokeDepartmentSessionsAsync(userId, departmentId,
return member != null && member.IsDeleted;
}
+ ///
+ /// Ends what would keep a removed member working in the department after the membership is gone: seats on open
+ /// deployments (a time report prefills a billable line for every active seat) and open workforce employment (the
+ /// current MARS salary-survey and pay-data counts). Seats are released through DeploymentService, which audits the
+ /// roster change and raises the roster workflow event; a closed deployment's roster is history and is left alone.
+ /// Employment is end-dated on the department's local removal day, never deleted, so past-period statutory data
+ /// (CRD pay data, MARS F-42 rosters, time reports) keeps the member. Runs before the membership is soft-deleted, so a
+ /// failure leaves the removal retryable.
+ ///
+ private async Task ReleaseOperationalAssignmentsAsync(string userId, int departmentId, string actingUserId, CancellationToken cancellationToken)
+ {
+ if (_deploymentPersonnel != null && _deploymentService != null)
+ {
+ foreach (var seat in (await _deploymentPersonnel.GetForUserAsync(departmentId, userId) ?? Enumerable.Empty()).Where(p => p.IsActive).ToList())
+ {
+ try { await _deploymentService.RemovePersonnelAsync(seat.DeploymentPersonnelId, departmentId, actingUserId, null, null, cancellationToken); }
+ catch (InvalidOperationException ex) when (ex.Message is "deployments_closed" or "deployments_not_found") { }
+ }
+ }
+
+ if (_workforceService != null)
+ {
+ var department = await _departmentsService.GetDepartmentByIdAsync(departmentId, false);
+ var removalDay = department == null ? DateTime.UtcNow.Date : DateTime.UtcNow.TimeConverter(department).Date;
+ await _workforceService.EndEmploymentsForMemberAsync(departmentId, userId, removalDay, actingUserId, cancellationToken);
+ }
+ }
+
public async Task DeleteUserAccountAsync(int departmentId, string authorizingUserId, string userIdToDelete, string ipAddress, string userAgent, CancellationToken cancellationToken = default(CancellationToken))
{
if (authorizingUserId != userIdToDelete)
return DeleteUserResults.UnAuthroized;
- return await DeactivateUserAccountCoreAsync(userIdToDelete, departmentId, ipAddress, userAgent, cancellationToken);
+ return await DeactivateUserAccountCoreAsync(userIdToDelete, departmentId, ipAddress, userAgent, authorizingUserId, cancellationToken);
}
- private async Task DeactivateUserAccountCoreAsync(string userIdToDelete, int departmentId, string ipAddress, string userAgent, CancellationToken cancellationToken)
+ private async Task DeactivateUserAccountCoreAsync(string userIdToDelete, int departmentId, string ipAddress, string userAgent, string actingUserId, CancellationToken cancellationToken)
{
var departments = await _departmentsService.GetAllDepartmentsForUserAsync(userIdToDelete);
@@ -194,6 +235,7 @@ private async Task DeactivateUserAccountCoreAsync(string user
// legacy Addresses rows.
await _memberSensitiveDataService.DeleteForMemberAsync(dm.DepartmentId, userIdToDelete, cancellationToken);
await _emergencyContactService.DeleteAllForMemberAsync(dm.DepartmentId, userIdToDelete, cancellationToken);
+ await ReleaseOperationalAssignmentsAsync(userIdToDelete, dm.DepartmentId, actingUserId ?? userIdToDelete, cancellationToken);
}
}
@@ -405,7 +447,7 @@ await InventoryHolderRetention.DeleteAsync(_inventoryStore, _inventoryUnitOfWork
}
else
{
- item.Data = $"Department deletion attempt {item.AttemptCount} failed: {e.Message}";
+ item.Data = $"Department deletion attempt {item.AttemptCount} failed: {e.Message}".Truncate(QueueItemDataMaxLength);
try
{
@@ -466,7 +508,7 @@ private async Task SetTerminalQueueItemStateAsync(QueueItem item, string data, C
try
{
item.CompletedOn = DateTime.UtcNow;
- item.Data = data;
+ item.Data = data.Truncate(QueueItemDataMaxLength);
await _queueService.UpdateQueueItem(item, cancellationToken);
}
catch (Exception ex)
diff --git a/Core/Resgrid.Services/DepartmentSsoService.cs b/Core/Resgrid.Services/DepartmentSsoService.cs
index f1c5d471a..b5dc3248e 100644
--- a/Core/Resgrid.Services/DepartmentSsoService.cs
+++ b/Core/Resgrid.Services/DepartmentSsoService.cs
@@ -42,6 +42,7 @@ public class DepartmentSsoService : IDepartmentSsoService
private readonly IEncryptionService _encryptionService;
private readonly ICacheProvider _cacheProvider;
private readonly IExternalIdentityLinkService _externalIdentityLinkService;
+ private readonly ILimitsService _limitsService;
public DepartmentSsoService(
IDepartmentSsoConfigRepository ssoConfigRepository,
@@ -51,7 +52,8 @@ public DepartmentSsoService(
IUserProfileService userProfileService,
IEncryptionService encryptionService,
ICacheProvider cacheProvider,
- IExternalIdentityLinkService externalIdentityLinkService)
+ IExternalIdentityLinkService externalIdentityLinkService,
+ ILimitsService limitsService)
{
_ssoConfigRepository = ssoConfigRepository;
_securityPolicyRepository = securityPolicyRepository;
@@ -61,6 +63,7 @@ public DepartmentSsoService(
_encryptionService = encryptionService;
_cacheProvider = cacheProvider;
_externalIdentityLinkService = externalIdentityLinkService;
+ _limitsService = limitsService;
}
// ── SSO Config CRUD ───────────────────────────────────────────────────
@@ -243,6 +246,13 @@ await SaveExternalLinkAsync(link, member, config, externalClaims, externalSubjec
if (!config.AutoProvisionUsers || string.IsNullOrWhiteSpace(email))
return null;
+ // A provisioned member takes a personnel seat. Linking an existing member (above) never does.
+ if (!await _limitsService.CanDepartmentAddNewUserAsync(departmentId, true))
+ {
+ Logging.LogInfo($"DepartmentSsoService: auto-provision refused for dept={departmentId}: personnel limit reached or the plan could not be checked.");
+ return null;
+ }
+
var provisionedUser = await ProvisionNewUserAsync(departmentId, email, firstName, lastName,
externalSubject, config, departmentCode, cancellationToken);
if (provisionedUser != null)
diff --git a/Core/Resgrid.Services/DepartmentsService.cs b/Core/Resgrid.Services/DepartmentsService.cs
index c15835f70..ac4bcbf55 100644
--- a/Core/Resgrid.Services/DepartmentsService.cs
+++ b/Core/Resgrid.Services/DepartmentsService.cs
@@ -7,6 +7,7 @@
using Resgrid.Model;
using Resgrid.Model.Custom;
using Resgrid.Model.Events;
+using Resgrid.Model.Helpers;
using Resgrid.Model.Providers;
using Resgrid.Model.Repositories;
using Resgrid.Model.Search;
@@ -265,20 +266,39 @@ public async Task GetUserIdForDeletedUserInDepartmentAsync(int departmen
return null;
}
- public async Task ReactivateUserAsync(int departmentId, string userId, CancellationToken cancellationToken = default(CancellationToken))
+ public async Task ReactivateUserAsync(int departmentId, string userId, string reactivatingUserId, CancellationToken cancellationToken = default(CancellationToken))
{
var dm = await _departmentMembersRepository.GetDepartmentMemberByDepartmentIdAndUserIdAsync(departmentId, userId);
+ if (dm == null)
+ return null;
+
+ var before = dm.CloneJsonToString();
dm.IsDeleted = false;
dm.IsHidden = false;
dm.IsDisabled = false;
+ // Removal clears admin standing, but a row removed before it did may still carry it: a returning member comes
+ // back as a regular member either way, and an admin grants admin again on purpose.
+ dm.IsAdmin = false;
var saved = await _departmentMembersRepository.SaveOrUpdateAsync(dm, cancellationToken);
+ _eventAggregator.SendMessage(new AuditEvent
+ {
+ DepartmentId = departmentId,
+ UserId = reactivatingUserId,
+ Type = AuditLogTypes.UserReactivated,
+ Before = before,
+ After = saved.CloneJsonToString(),
+ Successful = true,
+ ServerName = Environment.MachineName
+ });
// The member row just changed its deleted/hidden/disabled flags and the department's user
// list gained a name back -- both are cached reads that would otherwise serve the old answer.
InvalidateDepartmentUsersInCache(departmentId);
InvalidateDepartmentMemberInCache(userId, departmentId);
SendMembershipVisibilityRefresh(departmentId);
+ // The returning member takes a personnel seat again, so the cached plan counts are stale.
+ await _limitsService.InvalidateDepartmentsEntityLimitsCache(departmentId);
return saved;
}
@@ -344,6 +364,9 @@ private void SendMembershipVisibilityRefresh(int departmentId)
if (member != null)
{
member.IsDeleted = true;
+ // A removed member is not an admin of anything: clear the standing with the removal (the UserRemoved audit
+ // records it before and after) so a later reactivation cannot bring it back silently.
+ member.IsAdmin = false;
await _departmentMembersRepository.SaveOrUpdateAsync(member, cancellationToken);
if (_searchProjections != null) await _searchProjections.Value.RemoveAsync(departmentId, SearchEntityTypes.Personnel, userIdToDelete, cancellationToken);
@@ -648,10 +671,34 @@ async Task> getDepartmentPersonnelNames()
return await getDepartmentPersonnelNames();
}
+ public async Task> GetSelectablePersonnelNamesAsync(int departmentId)
+ {
+ var active = await GetActiveMemberUserIdsAsync(departmentId);
+ return (await GetAllPersonnelNamesForDepartmentAsync(departmentId) ?? new List())
+ .Where(n => n != null && active.Contains(n.UserId))
+ .GroupBy(n => n.UserId, StringComparer.OrdinalIgnoreCase).Select(g => g.First())
+ .OrderBy(n => n.LastName, StringComparer.CurrentCultureIgnoreCase).ThenBy(n => n.FirstName, StringComparer.CurrentCultureIgnoreCase)
+ .ToList();
+ }
+
public async Task> GetAllAdminsForDepartmentAsync(int departmentId)
{
+ // department.Members carries every membership row, removed ones included, and removal (DeleteUserAsync) leaves
+ // IsAdmin set: a removed or disabled member is not an admin of anything.
var department = await GetDepartmentByIdAsync(departmentId);
- return department.Members.Where(x => x.IsAdmin.GetValueOrDefault() || x.UserId == department.ManagingUserId).Select(y => new IdentityUser() { UserId = y.UserId }).ToList();
+ return (department?.Members ?? Enumerable.Empty())
+ .Where(x => (x.IsAdmin.GetValueOrDefault() || x.UserId == department.ManagingUserId) && DepartmentMemberStateHelper.IsCurrentMember(x, departmentId))
+ .GroupBy(x => x.UserId, StringComparer.OrdinalIgnoreCase)
+ .Select(g => new IdentityUser() { UserId = g.First().UserId }).ToList();
+ }
+
+ public async Task> GetActiveAdminsForDepartmentAsync(int departmentId)
+ {
+ var department = await GetDepartmentByIdAsync(departmentId);
+ return (department?.Members ?? Enumerable.Empty())
+ .Where(x => (x.IsAdmin.GetValueOrDefault() || x.UserId == department.ManagingUserId) && DepartmentMemberStateHelper.IsActiveMember(x, departmentId))
+ .GroupBy(x => x.UserId, StringComparer.OrdinalIgnoreCase)
+ .Select(g => new IdentityUser() { UserId = g.First().UserId }).ToList();
}
public async Task> GetAllMembersForDepartmentAsync(int departmentId)
@@ -875,6 +922,22 @@ public async Task> GetMemberUserIdsInDepartmentAsync(int departm
return result;
}
+ public async Task> GetActiveMemberUserIdsAsync(int departmentId)
+ {
+ var result = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var members = await _departmentMembersRepository.GetAllDepartmentMembersUnlimitedAsync(departmentId);
+ if (members == null)
+ return result;
+
+ foreach (var member in members)
+ {
+ if (DepartmentMemberStateHelper.IsActiveMember(member, departmentId))
+ result.Add(member.UserId);
+ }
+
+ return result;
+ }
+
public async Task> GetAllDepartmentNamesAsync()
{
return (from d in await _departmentRepository.GetAllAsync()
diff --git a/Core/Resgrid.Services/InventoryAlertNotifications.cs b/Core/Resgrid.Services/InventoryAlertNotifications.cs
index 8a053d6a9..9850aeece 100644
--- a/Core/Resgrid.Services/InventoryAlertNotifications.cs
+++ b/Core/Resgrid.Services/InventoryAlertNotifications.cs
@@ -8,6 +8,7 @@
using Resgrid.Framework;
using Resgrid.Localization;
using Resgrid.Model;
+using Resgrid.Model.Helpers;
using Resgrid.Model.Inventories;
using Resgrid.Model.Repositories;
using Resgrid.Model.Services;
@@ -36,7 +37,8 @@ public async Task ProcessDepartmentAsync(int departmentId, CancellationToke
{
ct.ThrowIfCancellationRequested();
var members = await _departments.GetAllMembersForDepartmentUnlimitedAsync(departmentId, true);
- var users = members.Where(m => m.DepartmentId == departmentId && !m.IsDeleted && m.IsDisabled != true && !string.IsNullOrWhiteSpace(m.UserId))
+ // Alert pushes reach active members only; removed, disabled and hidden members are never claimed for.
+ var users = members.Where(m => DepartmentMemberStateHelper.IsActiveMember(m, departmentId))
.Select(m => m.UserId).Distinct(StringComparer.Ordinal).OrderBy(id => id, StringComparer.Ordinal).ToList();
var handedOff = 0; var failed = false;
foreach (var user in users)
diff --git a/Core/Resgrid.Services/InventoryAlerts.cs b/Core/Resgrid.Services/InventoryAlerts.cs
index 84219edc9..5a5fd4a17 100644
--- a/Core/Resgrid.Services/InventoryAlerts.cs
+++ b/Core/Resgrid.Services/InventoryAlerts.cs
@@ -63,7 +63,13 @@ private async Task SetAlertAsync(int departmentId, InventoryAlertType type, stri
row.AlertType = (int)type; row.DedupKey = key; row.ItemId = itemId; row.LocationId = locationId; row.LotId = lotId; row.AssetId = assetId; row.IssuanceId = issuanceId; row.OpenedOn = Now; row.DueOn = due; row.Quantity = quantity;
await _store.InsertAsync(row);
if (openAlerts != null) openAlerts[key] = row;
- var trigger = type == InventoryAlertType.LowStock ? WorkflowTriggerEventType.InventoryLowStock : type == InventoryAlertType.OverdueReturn ? WorkflowTriggerEventType.InventoryReturnOverdue : WorkflowTriggerEventType.InventoryExpiring;
+ var trigger = type switch
+ {
+ InventoryAlertType.LowStock => WorkflowTriggerEventType.InventoryLowStock,
+ InventoryAlertType.OverdueReturn => WorkflowTriggerEventType.InventoryReturnOverdue,
+ InventoryAlertType.DepartedHolder => WorkflowTriggerEventType.InventoryDepartedHolder,
+ _ => WorkflowTriggerEventType.InventoryExpiring
+ };
var entry = await _outbox.EnqueueAsync(departmentId, "Inventory", new DomainEventEnvelope { EventName = trigger.ToString(), Trigger = trigger, SchemaVersion = 1,
AggregateType = "InventoryAlert", AggregateId = row.Id, OccurredOn = Now,
Payload = new { InventoryEvent = true, AlertId = row.Id, row.AlertType, row.ItemId, row.LocationId, row.LotId, row.AssetId, row.IssuanceId, row.Quantity, row.DueOn, OccurredOn = Now } });
@@ -158,7 +164,9 @@ private async Task EvaluateDatedAlertsAsync(int departmentId, List events)
var desired = new HashSet();
var items = (await AllAsync(departmentId)).Where(i => !i.IsDeleted && i.IsActive).Select(i => i.Id).ToHashSet();
var lots = (await AllAsync(departmentId)).ToDictionary(l => l.Id);
- async Task Add(InventoryAlertType type, string item, string location, string lot, string asset, string issuance, DateTime due, decimal quantity)
+ var stocks = await AllAsync(departmentId);
+ var assets = await AllAsync(departmentId);
+ async Task Add(InventoryAlertType type, string item, string location, string lot, string asset, string issuance, DateTime? due, decimal quantity)
{
if (!items.Contains(item)) return;
if (location != null)
@@ -170,10 +178,10 @@ async Task Add(InventoryAlertType type, string item, string location, string lot
desired.Add(Fingerprint(new { type, itemId = item, locationId = location, lotId = lot, assetId = asset, issuanceId = issuance }));
await SetAlertAsync(departmentId, type, item, location, lot, asset, issuance, true, due, quantity, events);
}
- foreach (var stock in await AllAsync(departmentId))
+ foreach (var stock in stocks)
if (!stock.IsDeleted && stock.Quantity > 0 && stock.LotId != null && lots.TryGetValue(stock.LotId, out var lot) && !lot.IsDeleted && lot.ExpiresOn <= Now.AddDays(30))
await Add(lot.ExpiresOn <= Now ? InventoryAlertType.Expired : InventoryAlertType.ExpiringSoon, stock.ItemId, stock.LocationId, stock.LotId, null, null, lot.ExpiresOn.Value, stock.Quantity);
- foreach (var asset in await AllAsync(departmentId))
+ foreach (var asset in assets)
{
var expiry = asset.ExpiresOn;
if (asset.LotId != null && lots.TryGetValue(asset.LotId, out var lot) && lot.ExpiresOn.HasValue && (!expiry.HasValue || lot.ExpiresOn < expiry)) expiry = lot.ExpiresOn;
@@ -183,8 +191,68 @@ async Task Add(InventoryAlertType type, string item, string location, string lot
foreach (var issuance in await AllAsync(departmentId))
if (!issuance.IsDeleted && issuance.Status is 0 or 2 && issuance.Quantity > issuance.ReturnedQuantity && issuance.ExpectedReturnOn < Now)
await Add(InventoryAlertType.OverdueReturn, issuance.ItemId, issuance.LocationId, issuance.LotId, issuance.AssetId, issuance.Id, issuance.ExpectedReturnOn.Value, issuance.Quantity - issuance.ReturnedQuantity);
+
+ // Equipment still held by a member who was removed, disabled or hidden: one recovery alert per item per holding
+ // location (bulk stock plus serialized assets), with nothing due. It resolves once the gear is moved off that
+ // location or the member is active again. The expiry and overdue alerts above keep running for the same gear.
+ var departed = new DepartedHolders(this, departmentId);
+ var held = new Dictionary<(string Item, string Location), decimal>();
+ foreach (var stock in stocks)
+ if (!stock.IsDeleted && stock.Quantity > 0 && await departed.HolderAsync(stock.LocationId) != null)
+ held[(stock.ItemId, stock.LocationId)] = held.GetValueOrDefault((stock.ItemId, stock.LocationId)) + stock.Quantity;
+ foreach (var asset in assets)
+ if (IsHeldAsset(asset) && await departed.HolderAsync(asset.CurrentLocationId) != null)
+ held[(asset.ItemId, asset.CurrentLocationId)] = held.GetValueOrDefault((asset.ItemId, asset.CurrentLocationId)) + 1;
+ foreach (var holding in held)
+ await Add(InventoryAlertType.DepartedHolder, holding.Key.Item, holding.Key.Location, null, null, null, null, holding.Value);
+
foreach (var alert in await AllOpenAlertsAsync(departmentId))
+ {
+ // Membership could not be read this pass: leave departed-holder alerts as they are rather than resolve and reopen them.
+ if (alert.AlertType == (int)InventoryAlertType.DepartedHolder && departed.Unknown) continue;
if (alert.Status == 0 && (!items.Contains(alert.ItemId) || alert.AlertType != 0 && !desired.Contains(alert.DedupKey))) { alert.Status = 1; alert.ResolvedOn = Now; await UpdateAlertMetadataAsync(alert); }
+ }
+ }
+ private static bool IsHeldAsset(InventoryAsset asset) => !asset.IsDeleted && asset.Status is 0 or 1 or 2 or 3 && asset.CurrentLocationId != null;
+ ///
+ /// Resolves, per location, the member who holds it (its effective holder through containers and parents) when that
+ /// member is no longer active. The active-member set is read once and only when a personnel holder is met, so a
+ /// department without personnel locations never reads membership. A null set leaves the answer unknown: no holder
+ /// counts as departed and is raised.
+ ///
+ private sealed class DepartedHolders
+ {
+ private readonly InventoryModernizationService _service; private readonly int _departmentId;
+ private readonly Dictionary _holders = new(StringComparer.Ordinal);
+ private HashSet _active; private bool _loaded;
+ public bool Unknown { get; private set; }
+ public DepartedHolders(InventoryModernizationService service, int departmentId) { _service = service; _departmentId = departmentId; }
+ public async Task HolderAsync(string locationId)
+ {
+ if (locationId == null) return null;
+ if (_holders.TryGetValue(locationId, out var known)) return known;
+ string departed = null;
+ var location = await _service._store.GetAsync(_departmentId, locationId);
+ if (location != null && !location.IsDeleted)
+ {
+ InventoryLocation holder = null;
+ try { holder = await _service.EffectiveLocationAsync(_departmentId, location); }
+ catch (InventoryException ex) when (ex.Code is "LocationUnavailable" or "InvalidLocationHierarchy") { }
+ if (holder != null && !holder.IsDeleted && !string.IsNullOrWhiteSpace(holder.UserId))
+ {
+ if (!_loaded) { _active = await _service._auth.ActiveMemberIdsAsync(_departmentId); _loaded = true; Unknown = _active == null; }
+ if (_active != null && !_active.Contains(holder.UserId)) departed = holder.UserId;
+ }
+ }
+ return _holders[locationId] = departed;
+ }
+ }
+ /// How much of an item a departed member still holds at one location; zero when the holder is active or unknown.
+ private async Task DepartedHoldingAsync(int departmentId, string itemId, string locationId)
+ {
+ if (itemId == null || locationId == null || await new DepartedHolders(this, departmentId).HolderAsync(locationId) == null) return 0;
+ var quantity = (await _store.RelatedAsync(departmentId, "ItemId", itemId)).Where(s => !s.IsDeleted && s.LocationId == locationId && s.Quantity > 0).Sum(s => s.Quantity);
+ return quantity + (await _store.RelatedAsync(departmentId, "ItemId", itemId)).Count(a => IsHeldAsset(a) && a.CurrentLocationId == locationId);
}
public async Task CanReceiveAlertAsync(int departmentId, string userId, string alertId)
{
@@ -211,6 +279,7 @@ public async Task CanReceiveAlertAsync(int departmentId, string userId, st
else if (!(await _store.RelatedAsync(departmentId, "ItemId", alert.ItemId)).Any(s => !s.IsDeleted && s.LotId == alert.LotId && s.LocationId == alert.LocationId && s.Quantity > 0)) return false;
if (!expiry.HasValue || (alert.AlertType == 2 ? expiry > Now : expiry <= Now || expiry > Now.AddDays(30))) return false;
}
+ else if (alert.AlertType == (int)InventoryAlertType.DepartedHolder && await DepartedHoldingAsync(departmentId, alert.ItemId, alert.LocationId) <= 0) return false;
try { await RequireAlertAccessAsync(new InventoryActor { DepartmentId = departmentId, UserId = userId }, alert); return true; }
catch (InventoryException ex) when (ex.StatusCode is 403 or 404 || ex.Code == "LocationUnavailable") { return false; }
}
diff --git a/Core/Resgrid.Services/InventoryAuthorizationService.cs b/Core/Resgrid.Services/InventoryAuthorizationService.cs
index 9985914df..269868d10 100644
--- a/Core/Resgrid.Services/InventoryAuthorizationService.cs
+++ b/Core/Resgrid.Services/InventoryAuthorizationService.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Threading.Tasks;
using Resgrid.Model;
using Resgrid.Model.Inventories;
@@ -19,6 +20,8 @@ public InventoryAuthorizationService(IDepartmentsService departments, IDepartmen
IAuthorizationService resources, IPermissionsService permissions, IPersonnelRolesService roles, IDepartmentSettingsService settings)
{ _departments = departments; _groups = groups; _units = units; _resources = resources; _permissions = permissions; _roles = roles; _settings = settings; }
public async Task IsEnabledAsync(int departmentId) => departmentId > 0 && (await _settings.GetDepartmentModuleSettingsAsync(departmentId, true))?.InventoryDisabled != true;
+ public async Task> ActiveMemberIdsAsync(int departmentId)
+ => await _departments.GetActiveMemberUserIdsAsync(departmentId) ?? new HashSet(StringComparer.OrdinalIgnoreCase);
public async Task RequireAsync(InventoryActor actor, bool write = false, PermissionTypes? permission = null, int? groupId = null)
{
if (actor == null || actor.DepartmentId <= 0 || string.IsNullOrWhiteSpace(actor.UserId)) throw new InventoryException(403, "MembershipRequired");
diff --git a/Core/Resgrid.Services/InventoryCounts.cs b/Core/Resgrid.Services/InventoryCounts.cs
index b4928a7ca..098507e0c 100644
--- a/Core/Resgrid.Services/InventoryCounts.cs
+++ b/Core/Resgrid.Services/InventoryCounts.cs
@@ -18,6 +18,59 @@ private async Task RequireCountAccessAsync(InventoryActor actor, InventoryCount
foreach (var line in await _store.RelatedAsync(actor.DepartmentId, "CountId", count.Id))
await LocationAsync(actor, line.LocationId, false, PermissionTypes.AdjustInventory, historical: historical);
}
+ // A location-scoped count carries this marker: its fence covers only the positions it counts (stock rows at its locations,
+ // assets at or listed on it, the items, lots and locations those touch), so work elsewhere in the department does not void
+ // a unit's count. Fingerprints without the marker (department-wide counts, and counts started before the scoped fence)
+ // keep the department fence.
+ private const string ScopedCountFingerprint = "L1:";
+ private async Task ScopedCountFingerprintAsync(int departmentId, ICollection locations, IEnumerable lines)
+ {
+ var listed = lines.ToList();
+ var allLocations = (await AllAsync(departmentId)).ToDictionary(x => x.Id);
+ var allAssets = (await AllAsync(departmentId)).ToDictionary(x => x.Id);
+ // The holder chain above the counted locations (parent locations, the kit assets that are containers and where those
+ // sit) is fenced too: losing the bag a counted container lives in, or re-homing a compartment, voids the count.
+ var fencedLocations = new HashSet(locations, StringComparer.Ordinal);
+ var fencedAssets = listed.Where(l => l.AssetId != null).Select(l => l.AssetId).ToHashSet();
+ var pending = new Queue(locations);
+ while (pending.Count > 0 && fencedLocations.Count < 512)
+ {
+ if (!allLocations.TryGetValue(pending.Dequeue(), out var location)) continue;
+ if (location.ParentLocationId != null && fencedLocations.Add(location.ParentLocationId)) pending.Enqueue(location.ParentLocationId);
+ if (location.ContainerAssetId != null && fencedAssets.Add(location.ContainerAssetId) && allAssets.TryGetValue(location.ContainerAssetId, out var holder) && holder.CurrentLocationId != null && fencedLocations.Add(holder.CurrentLocationId))
+ pending.Enqueue(holder.CurrentLocationId);
+ }
+ var stocks = (await AllAsync(departmentId)).Where(x => x.LocationId != null && locations.Contains(x.LocationId)).OrderBy(x => x.Id).ToList();
+ var assets = allAssets.Values.Where(x => x.CurrentLocationId != null && locations.Contains(x.CurrentLocationId) || fencedAssets.Contains(x.Id)).OrderBy(x => x.Id).ToList();
+ var items = listed.Select(l => l.ItemId).Concat(stocks.Select(s => s.ItemId)).Concat(assets.Select(a => a.ItemId)).ToHashSet();
+ var lots = listed.Select(l => l.LotId).Concat(stocks.Select(s => s.LotId)).Concat(assets.Select(a => a.LotId)).Where(x => x != null).ToHashSet();
+ return ScopedCountFingerprint + Fingerprint(new {
+ Items = (await AllAsync(departmentId)).Where(x => items.Contains(x.Id)).OrderBy(x => x.Id).Select(x => new { x.Id, x.Revision }),
+ Stocks = stocks.Select(x => new { x.Id, x.Revision, x.Quantity }),
+ Assets = assets.Select(x => new { x.Id, x.Revision }),
+ Lots = (await AllAsync(departmentId)).Where(x => lots.Contains(x.Id)).OrderBy(x => x.Id).Select(x => new { x.Id, x.Revision }),
+ Locations = allLocations.Values.Where(x => fencedLocations.Contains(x.Id)).OrderBy(x => x.Id).Select(x => new { x.Id, x.Revision })
+ });
+ }
+ /// The root location plus, when asked, every compartment (child location) and kit container inside it, recursively.
+ private async Task> CountLocationsAsync(int departmentId, string rootId, bool includeChildren)
+ {
+ var set = new HashSet(StringComparer.Ordinal) { rootId };
+ if (!includeChildren) return set;
+ var locations = (await AllAsync(departmentId)).Where(l => !l.IsDeleted).ToList();
+ var assets = (await AllAsync(departmentId)).Where(a => !a.IsDeleted && a.Status is not (4 or 5 or 6)).ToDictionary(a => a.Id);
+ for (var grew = true; grew && set.Count < 256;)
+ {
+ grew = false;
+ foreach (var location in locations.Where(l => !set.Contains(l.Id)))
+ {
+ var inside = location.ParentLocationId != null && set.Contains(location.ParentLocationId)
+ || location.ContainerAssetId != null && assets.TryGetValue(location.ContainerAssetId, out var holder) && holder.CurrentLocationId != null && set.Contains(holder.CurrentLocationId);
+ if (inside) grew = set.Add(location.Id) || grew;
+ }
+ }
+ return set;
+ }
// Conservative department fence also catches new positions, catalogue edits and rebuilds.
// Counts and observations themselves are excluded so independent counts do not invalidate each other.
private async Task CountFingerprintAsync(int departmentId) => Fingerprint(new {
@@ -35,12 +88,13 @@ public Task StartCountAsync(InventoryActor actor, Inventor
var count = New(actor); count.Id = input.Id; count.LocationId = input.LocationId;
await RequireCountAccessAsync(actor, count, false);
if (await _store.GetAsync(actor.DepartmentId, count.Id) != null) throw new InventoryException(409, "CountAlreadyExists");
- count.SnapshotOn = Now; count.SnapshotFingerprint = await CountFingerprintAsync(actor.DepartmentId);
- count.Content = JsonConvert.SerializeObject(new InventoryCountContent { Name = input.Name.Trim(), Note = input.Note });
+ var scope = input.LocationId == null ? null : await CountLocationsAsync(actor.DepartmentId, input.LocationId, input.IncludeChildLocations);
+ count.SnapshotOn = Now;
+ count.Content = JsonConvert.SerializeObject(new InventoryCountContent { Name = input.Name.Trim(), Note = input.Note, ScopeLocationIds = scope?.OrderBy(x => x, StringComparer.Ordinal).ToList() });
var lines = new List();
async Task Add(string itemId, string locationId, string lotId, string assetId, decimal quantity)
{
- if (locationId == null || input.LocationId != null && locationId != input.LocationId) return;
+ if (locationId == null || scope != null && !scope.Contains(locationId)) return;
if (!await LiveAlertLocationAsync(actor.DepartmentId, locationId)) return;
await LocationAsync(actor, locationId, false, PermissionTypes.AdjustInventory);
var item = await GetAsync(actor, itemId);
@@ -55,9 +109,10 @@ async Task Add(string itemId, string locationId, string lotId, string assetId, d
}
foreach (var stock in await AllAsync(actor.DepartmentId)) if (!stock.IsDeleted) await Add(stock.ItemId, stock.LocationId, stock.LotId, null, stock.Quantity);
foreach (var asset in await AllAsync(actor.DepartmentId)) if (!asset.IsDeleted && asset.Status is 0 or 1 or 2 or 3) await Add(asset.ItemId, asset.CurrentLocationId, asset.LotId, asset.Id, 1);
- if (input.LocationId != null) foreach (var item in await AllAsync(actor.DepartmentId))
+ if (input.LocationId != null && (input.IncludeCatalogItems ?? true)) foreach (var item in await AllAsync(actor.DepartmentId))
if (!item.IsDeleted && item.IsActive && item.TrackingMode == 0 && !item.RequiresLotTracking && !lines.Any(l => l.ItemId == item.Id)) await Add(item.Id, input.LocationId, null, null, 0);
if (lines.Count == 0) throw new InventoryException(409, "CountEmpty");
+ count.SnapshotFingerprint = scope == null ? await CountFingerprintAsync(actor.DepartmentId) : await ScopedCountFingerprintAsync(actor.DepartmentId, scope, lines);
await SaveAsync(actor, count); foreach (var line in lines) await SaveAsync(actor, line);
await AuditAsync(actor, count, "InventoryCountStarted"); return await GetCountAsync(actor, count.Id);
});
@@ -82,6 +137,43 @@ public Task SaveCountAsync(InventoryActor actor, Inventory
}
await SaveAsync(actor, detail.Count, false); await AuditAsync(actor, detail.Count, "InventoryCountSaved"); return await GetCountAsync(actor, detail.Count.Id);
});
+ public async Task GetFieldAccessAsync(InventoryActor actor, int? unitId)
+ {
+ async Task Allowed(Func check)
+ {
+ try { await check(); return true; }
+ catch (InventoryException) { return false; }
+ catch (UnauthorizedAccessException) { return false; }
+ }
+ await _auth.RequireAsync(actor);
+ var access = new InventoryFieldAccess { Enabled = await _auth.IsEnabledAsync(actor.DepartmentId), Migrated = await _store.HasLegacyMigrationAsync(actor.DepartmentId) };
+ if (!unitId.HasValue)
+ {
+ access.CanCount = await Allowed(() => _auth.RequireAsync(actor, false, PermissionTypes.AdjustInventory));
+ access.CanIssue = await Allowed(() => _auth.RequireAsync(actor, false, PermissionTypes.IssueInventory));
+ access.CanTransfer = await Allowed(() => _auth.RequireAsync(actor, false, PermissionTypes.TransferInventory));
+ return access;
+ }
+ var all = (await AllAsync(actor.DepartmentId)).Where(l => !l.IsDeleted).ToList();
+ var root = all.Where(l => l.LocationType == (int)InventoryLocationType.Unit && l.UnitId == unitId && l.ParentLocationId == null && l.ContainerAssetId == null).OrderBy(l => l.CreatedOn).FirstOrDefault();
+ if (root == null) return access;
+ foreach (var id in await CountLocationsAsync(actor.DepartmentId, root.Id, true))
+ {
+ var location = all.FirstOrDefault(l => l.Id == id);
+ if (location == null) continue;
+ try
+ {
+ await LocationAsync(actor, location.Id);
+ var revealed = await RevealAsync(actor, location);
+ access.UnitLocations.Add(new InventoryFieldLocation { Id = location.Id, Name = Decode(revealed).Name, ParentLocationId = location.ParentLocationId, LocationType = location.LocationType, IsRoot = location.Id == root.Id });
+ }
+ catch (InventoryException ex) when (ex.StatusCode is 403 or 404 || ex.Code == "LocationUnavailable") { }
+ }
+ access.CanCount = await Allowed(() => LocationAsync(actor, root.Id, false, PermissionTypes.AdjustInventory));
+ access.CanIssue = await Allowed(() => LocationAsync(actor, root.Id, false, PermissionTypes.IssueInventory));
+ access.CanTransfer = await Allowed(() => LocationAsync(actor, root.Id, false, PermissionTypes.TransferInventory));
+ return access;
+ }
public Task CancelCountAsync(InventoryActor actor, string id, int revision) => TransactionAsync(actor, async events =>
{
var count = await GetAsync(actor, id);
@@ -91,7 +183,11 @@ public Task CancelCountAsync(InventoryActor actor, string id, int revision) => T
private async Task CountCommandAsync(InventoryActor actor, InventoryCountDetail detail, InventoryCountComplete input, bool witnessed)
{
if (detail.Count.Status != (witnessed ? 1 : 0) || detail.Count.Revision != input.Revision || detail.Lines.Any(l => !l.CountedQuantity.HasValue)) throw new InventoryException(409, "CountStateConflict");
- if (detail.Count.SnapshotFingerprint != await CountFingerprintAsync(actor.DepartmentId)) throw new InventoryException(409, "CountSnapshotChanged");
+ var fence = detail.Count.SnapshotFingerprint?.StartsWith(ScopedCountFingerprint, StringComparison.Ordinal) == true
+ ? await ScopedCountFingerprintAsync(actor.DepartmentId, Decode(detail.Count).ScopeLocationIds?.ToHashSet(StringComparer.Ordinal)
+ ?? detail.Lines.Select(l => l.LocationId).Append(detail.Count.LocationId).Where(x => x != null).ToHashSet(StringComparer.Ordinal), detail.Lines)
+ : await CountFingerprintAsync(actor.DepartmentId);
+ if (detail.Count.SnapshotFingerprint != fence) throw new InventoryException(409, "CountSnapshotChanged");
var command = new InventoryCommand { RequestId = input.RequestId };
foreach (var row in detail.Lines.Where(l => l.CountedQuantity != l.ExpectedQuantity))
{
diff --git a/Core/Resgrid.Services/Invoicing/ContractorBillingEngine.cs b/Core/Resgrid.Services/Invoicing/ContractorBillingEngine.cs
index b8120b176..2ae707c60 100644
--- a/Core/Resgrid.Services/Invoicing/ContractorBillingEngine.cs
+++ b/Core/Resgrid.Services/Invoicing/ContractorBillingEngine.cs
@@ -296,7 +296,7 @@ private async Task NotifyAdminsAsync(int departmentId, string message)
{
var department = await _departmentsService.GetDepartmentByIdAsync(departmentId, false);
var number = _departmentSettings?.Value == null ? null : await _departmentSettings.Value.GetTextToCallNumberForDepartmentAsync(departmentId);
- foreach (var admin in await _departmentsService.GetAllAdminsForDepartmentAsync(departmentId))
+ foreach (var admin in await _departmentsService.GetActiveAdminsForDepartmentAsync(departmentId))
await _communication.Value.SendNotificationAsync(admin.UserId, departmentId, message, number, department, "Deployment billing");
}
catch (Exception ex) { Logging.LogException(ex, $"Deployment billing reminder for department {departmentId} failed."); }
diff --git a/Core/Resgrid.Services/Invoicing/DeploymentService.cs b/Core/Resgrid.Services/Invoicing/DeploymentService.cs
index 659e535ff..347cc7779 100644
--- a/Core/Resgrid.Services/Invoicing/DeploymentService.cs
+++ b/Core/Resgrid.Services/Invoicing/DeploymentService.cs
@@ -130,13 +130,78 @@ public async Task> GetDeploymentsForContractAsync(string servic
public async Task> GetDeploymentsForUserAsync(int departmentId, string userId, bool openOnly)
{
var rows = (await _personnel.GetForUserAsync(departmentId, userId))?.ToList() ?? new List();
- if (rows.Count == 0) return new List();
- var ids = rows.Select(r => r.DeploymentId).Distinct().ToList();
- var deployments = (await _deployments.GetByIdsAsync(departmentId, ids))?.ToList() ?? new List();
+ var rostered = rows.Select(r => r.DeploymentId).ToHashSet(StringComparer.OrdinalIgnoreCase);
+ var ids = new HashSet(rostered, StringComparer.OrdinalIgnoreCase);
+ // The crew seated on an apparatus (active unit roles) works that unit's deployments too, whether or not the
+ // deployment roster names them: that is how the Unit app's tablet reaches its Crew Time Report.
+ var seated = await SeatedUnitIdsAsync(departmentId, userId);
+ if (seated.Count > 0)
+ foreach (var unit in (await _units.GetForUnitsAsync(departmentId, seated))?.Where(u => u.IsActive) ?? Enumerable.Empty())
+ ids.Add(unit.DeploymentId);
+ if (ids.Count == 0) return new List();
+ var deployments = (await _deployments.GetByIdsAsync(departmentId, ids.ToList()))?.ToList() ?? new List();
+ // A seat is today's apparatus, not a place in the deployment's history: it reaches only open deployments. The roster keeps closed ones.
+ deployments = deployments.Where(d => d.IsOpen || rostered.Contains(d.DeploymentId)).ToList();
await ResolveDeploymentsAsync(deployments, departmentId);
return openOnly ? deployments.Where(d => d.IsOpen).ToList() : deployments;
}
+ public async Task CanFieldMemberSeeAsync(string deploymentId, int departmentId, string userId)
+ {
+ if (string.IsNullOrWhiteSpace(deploymentId) || string.IsNullOrWhiteSpace(userId)) return false;
+ if (await IsRosteredAsync(deploymentId, departmentId, userId)) return true;
+ var deployment = await _deployments.GetByIdForDepartmentAsync(deploymentId, departmentId);
+ if (deployment == null || deployment.IsDeleted || !deployment.IsOpen) return false;
+ var seated = await SeatedUnitIdsAsync(departmentId, userId);
+ if (seated.Count == 0) return false;
+ var units = await _units.GetByDeploymentAsync(deploymentId);
+ return units != null && units.Any(u => u.DepartmentId == departmentId && u.IsActive && seated.Contains(u.UnitId));
+ }
+
+ public async Task GetTimeAccessAsync(Deployment deployment, string userId, bool canManage)
+ {
+ var access = new DeploymentTimeAccess { CanManage = canManage };
+ if (deployment == null || string.IsNullOrWhiteSpace(userId)) return access;
+ bool Mine(DeploymentPersonnel p) => string.Equals(p.UserId, userId, StringComparison.OrdinalIgnoreCase);
+
+ access.IsRostered = deployment.Personnel.Any(Mine);
+ var own = deployment.Personnel.Where(p => p.IsActive && Mine(p)).ToList();
+ access.PersonnelId = own.FirstOrDefault()?.DeploymentPersonnelId;
+ foreach (var row in own) access.WritableSubjectIds.Add(row.DeploymentPersonnelId);
+
+ var activeUnits = deployment.Units.Where(u => u.IsActive).ToList();
+ if (activeUnits.Count == 0) return access;
+ var crewed = own.Where(p => !string.IsNullOrWhiteSpace(p.DeploymentUnitId)).Select(p => p.DeploymentUnitId).ToHashSet(StringComparer.OrdinalIgnoreCase);
+ // Seat-derived crew access ends with the deployment; the roster rows above still write their own history.
+ var seated = deployment.IsOpen ? await SeatedUnitIdsAsync(deployment.DepartmentId, userId) : new HashSet();
+ foreach (var unit in activeUnits.Where(u => crewed.Contains(u.DeploymentUnitId) || seated.Contains(u.UnitId)))
+ {
+ access.CrewUnitIds.Add(unit.DeploymentUnitId);
+ access.WritableSubjectIds.Add(unit.DeploymentUnitId);
+ // Removed crew stay writable for the crew report: a member released mid-shift still worked the first half of it.
+ foreach (var member in deployment.Personnel.Where(p => string.Equals(p.DeploymentUnitId, unit.DeploymentUnitId, StringComparison.OrdinalIgnoreCase)))
+ access.WritableSubjectIds.Add(member.DeploymentPersonnelId);
+ foreach (var item in deployment.Equipment.Where(e => string.Equals(e.DeploymentUnitId, unit.DeploymentUnitId, StringComparison.OrdinalIgnoreCase)))
+ access.WritableSubjectIds.Add(item.DeploymentEquipmentId);
+ }
+ return access;
+ }
+
+ /// Units the member is seated on right now (active unit roles). A lookup failure seats them nowhere rather than failing the read.
+ private async Task> SeatedUnitIdsAsync(int departmentId, string userId)
+ {
+ try
+ {
+ var roles = await _unitsService.GetAllActiveRolesForUnitsByDepartmentIdAsync(departmentId);
+ return roles?.Where(r => r != null && string.Equals(r.UserId, userId, StringComparison.OrdinalIgnoreCase)).Select(r => r.UnitId).ToHashSet() ?? new HashSet();
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex);
+ return new HashSet();
+ }
+ }
+
public async Task