diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000..b4f7cf26 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,233 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +language: en-US +tone_instructions: '' +early_access: false +enable_free_tier: true +inheritance: false +reviews: + profile: chill + request_changes_workflow: false + high_level_summary: true + high_level_summary_instructions: '' + high_level_summary_placeholder: '@coderabbitai summary' + high_level_summary_in_walkthrough: false + auto_title_placeholder: '@coderabbitai' + auto_title_instructions: '' + review_status: true + review_details: false + commit_status: true + fail_commit_status: false + collapse_walkthrough: true + changed_files_summary: true + sequence_diagrams: true + estimate_code_review_effort: true + assess_linked_issues: true + related_issues: true + related_prs: true + suggested_labels: true + labeling_instructions: [] + auto_apply_labels: false + suggested_reviewers: true + auto_assign_reviewers: false + in_progress_fortune: true + poem: false + enable_prompt_for_ai_agents: true + path_filters: + - "!**/*.resx" + - "!**/*.Designer.cs" + - "!**/bin/**" + - "!**/obj/**" + path_instructions: [] + abort_on_close: true + disable_cache: false + auto_review: + enabled: true + description_keyword: '' + auto_incremental_review: true + auto_pause_after_reviewed_commits: 5 + ignore_title_keywords: [] + labels: [] + drafts: false + base_branches: [] + ignore_usernames: [] + finishing_touches: + docstrings: + enabled: true + unit_tests: + enabled: true + simplify: + enabled: false + custom: [] + pre_merge_checks: + override_requested_reviewers_only: false + docstrings: + mode: warning + threshold: 80 + title: + mode: warning + requirements: '' + description: + mode: warning + issue_assessment: + mode: warning + custom_checks: [] + tools: + ast-grep: + rule_dirs: [] + util_dirs: [] + essential_rules: true + packages: [] + shellcheck: + enabled: true + ruff: + enabled: true + markdownlint: + enabled: true + github-checks: + enabled: true + timeout_ms: 90000 + languagetool: + enabled: true + enabled_rules: [] + disabled_rules: [] + enabled_categories: [] + disabled_categories: [] + enabled_only: false + level: default + biome: + enabled: true + hadolint: + enabled: true + swiftlint: + enabled: true + phpstan: + enabled: true + level: default + phpmd: + enabled: true + phpcs: + enabled: true + golangci-lint: + enabled: true + yamllint: + enabled: true + gitleaks: + enabled: true + trufflehog: + enabled: true + checkov: + enabled: true + tflint: + enabled: true + detekt: + enabled: true + eslint: + enabled: true + flake8: + enabled: true + fortitudeLint: + enabled: true + rubocop: + enabled: true + buf: + enabled: true + regal: + enabled: true + actionlint: + enabled: true + pmd: + enabled: true + clang: + enabled: true + cppcheck: + enabled: true + opengrep: + enabled: true + semgrep: + enabled: true + circleci: + enabled: true + clippy: + enabled: true + sqlfluff: + enabled: true + trivy: + enabled: true + prismaLint: + enabled: true + pylint: + enabled: true + oxc: + enabled: true + shopifyThemeCheck: + enabled: true + luacheck: + enabled: true + brakeman: + enabled: true + dotenvLint: + enabled: true + htmlhint: + enabled: true + stylelint: + enabled: true + checkmake: + enabled: true + osvScanner: + enabled: true + blinter: + enabled: true + smartyLint: + enabled: true + emberTemplateLint: + enabled: true + psscriptanalyzer: + enabled: true +chat: + art: true + auto_reply: true + integrations: + jira: + usage: auto + linear: + usage: auto +knowledge_base: + opt_out: false + web_search: + enabled: true + code_guidelines: + enabled: true + filePatterns: [] + learnings: + scope: auto + issues: + scope: auto + jira: + usage: auto + project_keys: [] + linear: + usage: auto + team_keys: [] + pull_requests: + scope: auto + mcp: + usage: auto + disabled_servers: [] + linked_repositories: [] +code_generation: + docstrings: + language: en-US + path_instructions: [] + unit_tests: + path_instructions: [] +issue_enrichment: + auto_enrich: + enabled: false + planning: + enabled: true + auto_planning: + enabled: true + labels: [] + labeling: + labeling_instructions: [] + auto_apply_labels: false \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.cs b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.cs new file mode 100644 index 00000000..553e13e5 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Resgrid.Localization.Areas.User.Inventory +{ + public class Inventory + { + } +} + diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.de.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.de.resx new file mode 100644 index 00000000..7823251f --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.de.resx @@ -0,0 +1,155 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Aktiv + + + Hinzugefügt von + + + Hinzugefügt am + + + Inventartyp hinzufügen + + + Inventartyp hinzufügen + + + Inventar anpassen + + + Inventar anpassen + + + Menge + + + Charge + + + Chargen-/Seriennummer + + + Charge/Serie + + + Typ hinzufügen + + + Erstellt von + + + Bearbeiten + + + Inventartyp bearbeiten + + + Inventartyp bearbeiten + + + Für Artikel ohne Ablaufdatum 0 eingeben + + + Gruppe + + + Inventarverlauf + + + Inventartypen + + + Ablauftage des Artikels + + + Typen verwalten + + + Keine Gruppe + + + Keine Einheit + + + Station + + + Typbezeichnung + + + Typen + + + Einheit + + + Maßeinheit + + + Verlauf anzeigen + + + Inventareintrag anzeigen + + + diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.en.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.en.resx new file mode 100644 index 00000000..9e24daa7 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.en.resx @@ -0,0 +1,214 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Active + + + Added By + + + Added On + + + Add Inventory Type + + + Add Inventory Type + + + Adjust Inventory + + + Adjust Inventory + + + Amount + + + Batch + + + Batch\Serial Number + + + Batch\Serial + + + Add Type + + + Created by + + + Edit + + + Edit Inventory Type + + + Edit Inventory Type + + + For items that don't expire enter 0 + + + Group + + + Inventory History + + + Inventory Types + + + Item Expires Days + + + Manage Types + + + No Group + + + No Unit + + + Station + + + Type Name + + + Types + + + Unit + + + Unit of Measure + + + View History + + + View Inventory Entry + + + diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.es.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.es.resx new file mode 100644 index 00000000..21d93aad --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.es.resx @@ -0,0 +1,155 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Activo + + + Añadido por + + + Añadido el + + + Añadir tipo de inventario + + + Añadir tipo de inventario + + + Ajustar inventario + + + Ajustar inventario + + + Cantidad + + + Lote + + + Número de lote/serie + + + Lote/Serie + + + Añadir tipo + + + Creado por + + + Editar + + + Editar tipo de inventario + + + Editar tipo de inventario + + + Para artículos que no caducan, introduzca 0 + + + Grupo + + + Historial de inventario + + + Tipos de inventario + + + Días de caducidad del artículo + + + Gestionar tipos + + + Sin grupo + + + Sin unidad + + + Estación + + + Nombre del tipo + + + Tipos + + + Unidad + + + Unidad de medida + + + Ver historial + + + Ver entrada de inventario + + + diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.fr.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.fr.resx new file mode 100644 index 00000000..4a775861 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.fr.resx @@ -0,0 +1,155 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Actif + + + Ajouté par + + + Ajouté le + + + Ajouter un type d'inventaire + + + Ajouter un type d'inventaire + + + Ajuster l'inventaire + + + Ajuster l'inventaire + + + Quantité + + + Lot + + + Numéro de lot/série + + + Lot/Série + + + Ajouter un type + + + Créé par + + + Modifier + + + Modifier le type d'inventaire + + + Modifier le type d'inventaire + + + Pour les articles sans date d'expiration, entrez 0 + + + Groupe + + + Historique de l'inventaire + + + Types d'inventaire + + + Jours d'expiration de l'article + + + Gérer les types + + + Aucun groupe + + + Aucune unité + + + Station + + + Nom du type + + + Types + + + Unité + + + Unité de mesure + + + Voir l'historique + + + Voir l'entrée d'inventaire + + + diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.it.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.it.resx new file mode 100644 index 00000000..e958e702 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.it.resx @@ -0,0 +1,155 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Attivo + + + Aggiunto da + + + Aggiunto il + + + Aggiungi tipo di inventario + + + Aggiungi tipo di inventario + + + Rettifica inventario + + + Rettifica inventario + + + Quantità + + + Lotto + + + Numero lotto/seriale + + + Lotto/Seriale + + + Aggiungi tipo + + + Creato da + + + Modifica + + + Modifica tipo di inventario + + + Modifica tipo di inventario + + + Per gli articoli che non scadono inserire 0 + + + Gruppo + + + Cronologia inventario + + + Tipi di inventario + + + Giorni di scadenza articolo + + + Gestisci tipi + + + Nessun gruppo + + + Nessuna unità + + + Stazione + + + Nome tipo + + + Tipi + + + Unità + + + Unità di misura + + + Visualizza cronologia + + + Visualizza voce di inventario + + + diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.pl.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.pl.resx new file mode 100644 index 00000000..23744ce8 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.pl.resx @@ -0,0 +1,155 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Aktywny + + + Dodane przez + + + Dodane dnia + + + Dodaj typ inwentarza + + + Dodaj typ inwentarza + + + Dostosuj inwentarz + + + Dostosuj inwentarz + + + Ilość + + + Partia + + + Numer partii/serii + + + Partia/Seria + + + Dodaj typ + + + Utworzone przez + + + Edytuj + + + Edytuj typ inwentarza + + + Edytuj typ inwentarza + + + Dla artykułów bez daty ważności wprowadź 0 + + + Grupa + + + Historia inwentarza + + + Typy inwentarza + + + Dni ważności artykułu + + + Zarządzaj typami + + + Brak grupy + + + Brak jednostki + + + Stacja + + + Nazwa typu + + + Typy + + + Jednostka + + + Jednostka miary + + + Zobacz historię + + + Zobacz wpis inwentarza + + + diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.sv.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.sv.resx new file mode 100644 index 00000000..8a60609a --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.sv.resx @@ -0,0 +1,155 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Aktiv + + + Tillagd av + + + Tillagd den + + + Lägg till lagertyp + + + Lägg till lagertyp + + + Justera lager + + + Justera lager + + + Antal + + + Batch + + + Batch-/serienummer + + + Batch/Serie + + + Lägg till typ + + + Skapad av + + + Redigera + + + Redigera lagertyp + + + Redigera lagertyp + + + För artiklar som inte löper ut, ange 0 + + + Grupp + + + Lagerhistorik + + + Lagertyper + + + Artikelns utgångsdagar + + + Hantera typer + + + Ingen grupp + + + Ingen enhet + + + Station + + + Typnamn + + + Typer + + + Enhet + + + Måttenhet + + + Visa historik + + + Visa lagerpost + + + diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.uk.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.uk.resx new file mode 100644 index 00000000..1d4362a0 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.uk.resx @@ -0,0 +1,155 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Активний + + + Додано користувачем + + + Додано + + + Додати тип інвентарю + + + Додати тип інвентарю + + + Коригувати інвентар + + + Коригувати інвентар + + + Кількість + + + Партія + + + Номер партії/серії + + + Партія/Серія + + + Додати тип + + + Створено + + + Редагувати + + + Редагувати тип інвентарю + + + Редагувати тип інвентарю + + + Для товарів без строку придатності введіть 0 + + + Група + + + Історія інвентарю + + + Типи інвентарю + + + Дні придатності товару + + + Керувати типами + + + Без групи + + + Без підрозділу + + + Станція + + + Назва типу + + + Типи + + + Підрозділ + + + Одиниця виміру + + + Переглянути історію + + + Переглянути запис інвентарю + + + diff --git a/Core/Resgrid.Localization/Areas/User/Logs/Logs.de.resx b/Core/Resgrid.Localization/Areas/User/Logs/Logs.de.resx index 8a06629e..7b9a2806 100644 --- a/Core/Resgrid.Localization/Areas/User/Logs/Logs.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Logs/Logs.de.resx @@ -1,4 +1,4 @@ - + @@ -218,4 +218,91 @@ Work start time is required. - + + View Log + + + Back to Logs + + + General Information + + + Log Summary + + + Log ID + + + Log Type + + + Logged On + + + Logged By + + + Duration + + + Call Information + + + Call Number + + + View Full Call + + + Training Information + + + Meeting Information + + + Coroner Information + + + Log Details + + + Condition / Initial Report + + + Not Assigned to a Unit + + + Print / Export + + + Print (Export) View + + + Delete Log + + + Are you sure you want to delete this log? This action cannot be undone. + + + Log Report + + + Generated + + + External ID + + + No Type + + + Not Supplied + + + Presiding + + + Work Log Information + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Logs/Logs.en.resx b/Core/Resgrid.Localization/Areas/User/Logs/Logs.en.resx index a8ee1424..b1c7b768 100644 --- a/Core/Resgrid.Localization/Areas/User/Logs/Logs.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Logs/Logs.en.resx @@ -267,4 +267,91 @@ Work start time is required. - \ No newline at end of file + + View Log + + + Back to Logs + + + General Information + + + Log Summary + + + Log ID + + + Log Type + + + Logged On + + + Logged By + + + Duration + + + Call Information + + + Call Number + + + View Full Call + + + Training Information + + + Meeting Information + + + Coroner Information + + + Log Details + + + Condition / Initial Report + + + Not Assigned to a Unit + + + Print / Export + + + Print (Export) View + + + Delete Log + + + Are you sure you want to delete this log? This action cannot be undone. + + + Log Report + + + Generated + + + External ID + + + No Type + + + Not Supplied + + + Presiding + + + Work Log Information + + diff --git a/Core/Resgrid.Localization/Areas/User/Logs/Logs.es.resx b/Core/Resgrid.Localization/Areas/User/Logs/Logs.es.resx index eb637cf4..ce93fc30 100644 --- a/Core/Resgrid.Localization/Areas/User/Logs/Logs.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Logs/Logs.es.resx @@ -258,4 +258,91 @@ Se requiere la hora de inicio del trabajo. + + View Log + + + Back to Logs + + + General Information + + + Log Summary + + + Log ID + + + Log Type + + + Logged On + + + Logged By + + + Duration + + + Call Information + + + Call Number + + + View Full Call + + + Training Information + + + Meeting Information + + + Coroner Information + + + Log Details + + + Condition / Initial Report + + + Not Assigned to a Unit + + + Print / Export + + + Print (Export) View + + + Delete Log + + + Are you sure you want to delete this log? This action cannot be undone. + + + Log Report + + + Generated + + + External ID + + + No Type + + + Not Supplied + + + Presiding + + + Work Log Information + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Logs/Logs.fr.resx b/Core/Resgrid.Localization/Areas/User/Logs/Logs.fr.resx index 7a7ed6fc..dbaca96e 100644 --- a/Core/Resgrid.Localization/Areas/User/Logs/Logs.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Logs/Logs.fr.resx @@ -1,4 +1,4 @@ - + @@ -218,4 +218,91 @@ Work start time is required. - + + View Log + + + Back to Logs + + + General Information + + + Log Summary + + + Log ID + + + Log Type + + + Logged On + + + Logged By + + + Duration + + + Call Information + + + Call Number + + + View Full Call + + + Training Information + + + Meeting Information + + + Coroner Information + + + Log Details + + + Condition / Initial Report + + + Not Assigned to a Unit + + + Print / Export + + + Print (Export) View + + + Delete Log + + + Are you sure you want to delete this log? This action cannot be undone. + + + Log Report + + + Generated + + + External ID + + + No Type + + + Not Supplied + + + Presiding + + + Work Log Information + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Logs/Logs.it.resx b/Core/Resgrid.Localization/Areas/User/Logs/Logs.it.resx index f6d793b9..9a7a6c7c 100644 --- a/Core/Resgrid.Localization/Areas/User/Logs/Logs.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Logs/Logs.it.resx @@ -1,4 +1,4 @@ - + @@ -218,4 +218,91 @@ Work start time is required. - + + View Log + + + Back to Logs + + + General Information + + + Log Summary + + + Log ID + + + Log Type + + + Logged On + + + Logged By + + + Duration + + + Call Information + + + Call Number + + + View Full Call + + + Training Information + + + Meeting Information + + + Coroner Information + + + Log Details + + + Condition / Initial Report + + + Not Assigned to a Unit + + + Print / Export + + + Print (Export) View + + + Delete Log + + + Are you sure you want to delete this log? This action cannot be undone. + + + Log Report + + + Generated + + + External ID + + + No Type + + + Not Supplied + + + Presiding + + + Work Log Information + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Logs/Logs.pl.resx b/Core/Resgrid.Localization/Areas/User/Logs/Logs.pl.resx index 19adadf3..989f0ff3 100644 --- a/Core/Resgrid.Localization/Areas/User/Logs/Logs.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Logs/Logs.pl.resx @@ -1,4 +1,4 @@ - + @@ -218,4 +218,91 @@ Work start time is required. - + + View Log + + + Back to Logs + + + General Information + + + Log Summary + + + Log ID + + + Log Type + + + Logged On + + + Logged By + + + Duration + + + Call Information + + + Call Number + + + View Full Call + + + Training Information + + + Meeting Information + + + Coroner Information + + + Log Details + + + Condition / Initial Report + + + Not Assigned to a Unit + + + Print / Export + + + Print (Export) View + + + Delete Log + + + Are you sure you want to delete this log? This action cannot be undone. + + + Log Report + + + Generated + + + External ID + + + No Type + + + Not Supplied + + + Presiding + + + Work Log Information + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Logs/Logs.sv.resx b/Core/Resgrid.Localization/Areas/User/Logs/Logs.sv.resx index 51b65a9b..a12c4cf3 100644 --- a/Core/Resgrid.Localization/Areas/User/Logs/Logs.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Logs/Logs.sv.resx @@ -1,4 +1,4 @@ - + @@ -218,4 +218,91 @@ Work start time is required. - + + View Log + + + Back to Logs + + + General Information + + + Log Summary + + + Log ID + + + Log Type + + + Logged On + + + Logged By + + + Duration + + + Call Information + + + Call Number + + + View Full Call + + + Training Information + + + Meeting Information + + + Coroner Information + + + Log Details + + + Condition / Initial Report + + + Not Assigned to a Unit + + + Print / Export + + + Print (Export) View + + + Delete Log + + + Are you sure you want to delete this log? This action cannot be undone. + + + Log Report + + + Generated + + + External ID + + + No Type + + + Not Supplied + + + Presiding + + + Work Log Information + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Logs/Logs.uk.resx b/Core/Resgrid.Localization/Areas/User/Logs/Logs.uk.resx index 8fce245e..fe06aff4 100644 --- a/Core/Resgrid.Localization/Areas/User/Logs/Logs.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Logs/Logs.uk.resx @@ -1,4 +1,4 @@ - + @@ -218,4 +218,91 @@ Work start time is required. - + + View Log + + + Back to Logs + + + General Information + + + Log Summary + + + Log ID + + + Log Type + + + Logged On + + + Logged By + + + Duration + + + Call Information + + + Call Number + + + View Full Call + + + Training Information + + + Meeting Information + + + Coroner Information + + + Log Details + + + Condition / Initial Report + + + Not Assigned to a Unit + + + Print / Export + + + Print (Export) View + + + Delete Log + + + Are you sure you want to delete this log? This action cannot be undone. + + + Log Report + + + Generated + + + External ID + + + No Type + + + Not Supplied + + + Presiding + + + Work Log Information + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.de.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.de.resx index 0e8fe336..d78eb9c4 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.de.resx @@ -314,6 +314,30 @@ View Person + + Bericht erstellen + + + View Person Events + + + View Events + + + Events for + + + Are you sure you want to permanently delete all statuses for this person? + + + Clear out all Statuses For Person + + + Delete All + + + Yes I'm sure + View Role diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.en.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.en.resx index 4f05a61d..304e92d7 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.en.resx @@ -363,7 +363,31 @@ View Person + + Generate Report + + + View Person Events + + + View Events + + + Events for + + + Are you sure you want to permanently delete all statuses for this person? + + + Clear out all Statuses For Person + + + Delete All + + + Yes I'm sure + View Role - \ No newline at end of file + diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.es.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.es.resx index b898f13c..a473ca2f 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.es.resx @@ -363,7 +363,31 @@ Ver persona + + Generar informe + + + Ver Eventos de Persona + + + Ver Eventos + + + Eventos para + + + Are you sure you want to permanently delete all statuses for this person? + + + Borrar todos los estados + + + Eliminar todo + + + Sí, estoy seguro + Ver rol - \ No newline at end of file + diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.fr.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.fr.resx index 3ca810e2..bc24ae37 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.fr.resx @@ -314,6 +314,30 @@ View Person + + Générer un rapport + + + Voir les événements + + + Voir les événements + + + Événements pour + + + Are you sure you want to permanently delete all statuses for this person? + + + Effacer tous les statuts + + + Tout supprimer + + + Oui, je suis sûr + View Role diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.it.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.it.resx index 8eb22497..b7415213 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.it.resx @@ -314,6 +314,30 @@ View Person + + Genera report + + + Visualizza eventi persona + + + Visualizza eventi + + + Eventi per + + + Are you sure you want to permanently delete all statuses for this person? + + + Cancella tutti gli stati + + + Elimina tutto + + + Sì, sono sicuro + View Role diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.pl.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.pl.resx index 7012e871..04f5f4bd 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.pl.resx @@ -314,6 +314,30 @@ View Person + + Wygeneruj raport + + + Wyświetl zdarzenia osoby + + + Wyświetl zdarzenia + + + Zdarzenia dla + + + Are you sure you want to permanently delete all statuses for this person? + + + Wyczyść wszystkie statusy + + + Usuń wszystko + + + Tak, jestem pewien + View Role diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.sv.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.sv.resx index e0ff2063..c2ab1eee 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.sv.resx @@ -314,6 +314,30 @@ View Person + + Generera rapport + + + Visa personhändelser + + + Visa händelser + + + Händelser för + + + Are you sure you want to permanently delete all statuses for this person? + + + Rensa alla statusar + + + Ta bort alla + + + Ja, jag är säker + View Role diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.uk.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.uk.resx index 6fe12788..35aca75b 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.uk.resx @@ -314,6 +314,30 @@ View Person + + Згенерувати звіт + + + Переглянути події особи + + + Переглянути події + + + Події для + + + Are you sure you want to permanently delete all statuses for this person? + + + Очистити всі статуси + + + Видалити все + + + Так, я впевнений + View Role diff --git a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.de.resx b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.de.resx index 821b0a1a..ebce5081 100644 --- a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.de.resx @@ -69,30 +69,504 @@ [base64 mime encoded string representing a byte array form of the .NET Framework object] - Delete Static Shift + Statische Schicht löschen - Editing a static shift will clear out all assignments or sign-ups for this shift and reassign them based on the new settings. If you have any assignments or sign-ups for this shift they will be removed and the new settings will be applied. + Das Bearbeiten einer statischen Schicht löscht alle Zuweisungen oder Anmeldungen für diese Schicht und weist sie basierend auf den neuen Einstellungen neu zu. Wenn Sie Zuweisungen oder Anmeldungen für diese Schicht haben, werden diese entfernt und die neuen Einstellungen werden angewendet. - Edit Static Shift + Statische Schicht bearbeiten - End Date/Time + Enddatum/-uhrzeit - Note the text on the Calendar is black, take that into account when picking a shift color as the color will be used as the background + Beachten Sie, dass der Text im Kalender schwarz ist. Berücksichtigen Sie dies bei der Auswahl einer Schichtfarbe, da die Farbe als Hintergrund verwendet wird. - The date and time that the shift ends (i.e. back in quarters) where entities under it are considered available. + Das Datum und die Uhrzeit, zu der die Schicht endet (d.h. zurück in der Unterkunft), wo Entitäten darunter als verfügbar gelten. - The date and time that the shift starts (i.e. start of travel) where entities under it are considered committed. + Das Datum und die Uhrzeit, zu der die Schicht beginnt (d.h. Beginn der Fahrt), wo Entitäten darunter als verpflichtet gelten. - Start Date/Time + Startdatum/-uhrzeit - View Static Shift Day + Statischen Schichttag anzeigen + + + Schichten + + + Ihre Schichten + + + Schichtbesetzung + + + Wiederkehrende Gruppenschichten + + + Wiederkehrende Gruppenschicht hinzufügen + + + Name + + + Typ + + + Zeitplan + + + Gruppen + + + Personal + + + Zugewiesen + + + Anmeldung + + + Manuell + + + Benutzerdefiniert + + + 48 ein 96 aus + + + 24 ein 48 aus + + + 24 ein 72 aus + + + Kalender anzeigen + + + Details bearbeiten + + + Kalender bearbeiten + + + Gruppen bearbeiten + + + Löschen + + + WARNUNG: Diese Schicht wird dauerhaft gelöscht. Sind Sie sicher, dass Sie die Schicht löschen möchten? + + + Kalender + + + Statische Schicht hinzufügen + + + Neue Schicht + + + Willkommen beim Assistenten für neue Schichten. Dieser Assistent führt Sie durch die Erstellung einer neuen Schicht für Ihre Abteilung. Geben Sie unten den Namen, den Code und die optionale Schichtfarbe an. Um fortzufahren, klicken Sie auf die Schaltfläche "Weiter" in der unteren rechten Ecke. + + + Name + + + Name der Schicht + + + Zum Beispiel ("Schicht A", "Schicht B", "Schicht C") + + + Code + + + Beispiel ("A", "B", "C") + + + Zuweisungstyp + + + Bei "Zugewiesen" legen Sie das Personal für die Schicht fest; bei "Anmeldung" weist sich das Personal selbst zu. + + + Farbe + + + Hier legen Sie fest, an welchen Tagen die Schicht Dienst hat und die Startzeit der Schicht für den ersten Tag. Wenn Ihre Schicht zusammenhängend ist, gilt die Startzeit für den ersten Schichttag. + + + Startzeit + + + Wann beginnt die Schicht am ersten Tag (oder einzigen Tag) + + + Endzeit + + + Wann endet die Schicht am letzten Tag (oder einzigen Tag) + + + Hier können Sie die erforderlichen Rollen für jede Gruppe in Ihrer Abteilung für die Schicht konfigurieren. + + + Schichtgruppe + + + Schichtrollen + + + Gruppe zur Schicht hinzufügen + + + Wählen Sie nun das Personal aus, das in dieser Schicht für Ihre Abteilung arbeitet. + + + Personal ohne Gruppe + + + Personal, das der Schicht zugewiesen ist, aber nicht direkt einer Station oder Organisationsgruppe zugeordnet ist (z.B. Vertretungen) + + + Klicken Sie auf die Schaltfläche "Fertig stellen" unten, um Ihre neue Schicht zu erstellen. + + + Start > + + + Zeitplan > + + + Slots > + + + Personal > + + + Fertig stellen + + + Erste + + + Zurück + + + Weiter + + + Schichtname ist erforderlich + + + Schichtcode ist erforderlich + + + Schicht-Startzeit ist erforderlich. + + + Schichtdetails bearbeiten + + + Schichtcode + + + Personal, das Teil einer Schicht ist, aber keiner Gruppe zugeordnet ist + + + Abbrechen + + + Schicht aktualisieren + + + Schichtkalender bearbeiten + + + Schichttage aktualisieren + + + Schichtgruppen bearbeiten + + + Schichtgruppen speichern + + + Kalender + + + Schichtbesetzung + + + Schicht + + + Wählen Sie die Schicht aus, für die Sie die Besetzung bearbeiten möchten. + + + Schichttag + + + Wählen Sie den Tag aus, für den die Besetzung bearbeitet werden soll. + + + Hinweis + + + Geben Sie einen Freitext-Hinweis für die Schicht ein + + + Zuweisungen + + + Einheiten + + + Schichttag-Besetzung festlegen + + + Personal ohne Gruppe auswählen... + + + Personal für Gruppe auswählen... + + + Person auswählen... + + + Personal ohne Gruppe + + + Ihre Schichten + + + Schicht + + + Gruppe + + + Tag + + + Genehmigt + + + Status + + + Zeitstempel + + + Nein + + + Ja + + + Tausch abgeschlossen + + + Tausch in Bearbeitung + + + Normal + + + für + + + WARNUNG: Dies entfernt Sie aus dieser Schicht. Sind Sie sicher, dass Sie die Schicht ablehnen möchten? + + + Schichttag ablehnen + + + Tausch anfragen + + + Tausch abschließen + + + Ihre ausstehenden Tausche + + + Zeit + + + Tauschantrag bearbeiten + + + Abgelehnt + + + Akzeptiert + + + Besetzt + + + Tausch angeboten + + + Schicht anzeigen + + + Schicht Beginn + + + Schichttag Detail + + + Typ: + + + Status: + + + Beginn: + + + Ende: + + + Bevorstehend + + + Abgeschlossen + + + Keine Endzeit + + + Gruppen + + + Rolle + + + Erforderlich + + + Optional + + + Benötigt + + + Anmeldungen + + + Rollen + + + Tausche + + + Tausch + + + {0} hat mit {1} getauscht + + + Schicht-Anmeldung + + + Schicht-Anmeldung löschen + + + Für Schicht anmelden mit + + + Schicht-Anmeldung erfolgreich + + + Sie haben sich erfolgreich für diesen Schichttag angemeldet. Wenn Sie sich für weitere Tage anmelden möchten, müssen Sie sich für jeden Tag einzeln anmelden. + + + Beginnt um {0} und endet um {1} + + + Beginnt um {0} + + + Tausch anfragen + + + Benutzer anfragen + + + Klicken Sie in das Eingabefeld, um den Tausch anzufragen (nur Benutzer, die die Schichtrollenanforderungen erfüllen, werden angezeigt) + + + Tauschantrag absenden + + + Tauschantrag bearbeiten + + + Tausch-Daten + + + Wählen Sie die Schichten aus, für die Sie bereits eingeteilt sind, um den Tausch abzuschließen. + + + Ein Hinweis oder Info für diesen Tausch + + + Tauschantrag ablehnen + + + Sind Sie sicher, dass Sie diesen Tauschantrag ablehnen möchten? + + + Tausch vorschlagen + + + Beginnt um + + + Tausch abschließen + + + Datum + + + Keine + + + Tausch abschließen + + + Schichtrolle + + + Rollenanzahl + + + Rolle zur Gruppe hinzufügen + + + Schichtrollen zur Gruppe hinzufügen + + + Gruppe entfernen + + + Rolle entfernen + + + Diese Rolle aus der Gruppe entfernen + + + Rollenanzahl ist erforderlich diff --git a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.en.resx b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.en.resx index 4d18f426..8716e849 100644 --- a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.en.resx @@ -144,4 +144,493 @@ View Static Shift Day - \ No newline at end of file + + + Shifts + + + Your Shifts + + + Shift Staffing + + + Recurring Group Shifts + + + Add Recurring Group Shift + + + Name + + + Type + + + Schedule + + + Groups + + + Personnel + + + Assigned + + + Signup + + + Manual + + + Custom + + + 48 on 96 off + + + 24 on 48 off + + + 24 on 72 off + + + View Calendar + + + Edit Details + + + Edit Calendar + + + Edit Groups + + + Delete + + + WARNING: This will permanently delete this shift. Are you sure you want to delete the shift + + + Calendar + + + Add Static Shift + + + + New Shift + + + Welcome to the new Shift wizard. This wizard will guide you through creating a new shift for your department. Below specify your shifts name, code and optional shift color. To continue click the "Next" button in the lower right hand corner. + + + Name + + + Name of the Shift + + + For example ("A Shift", "B Shift", "C Shift") + + + Code + + + Example ("A", "B", "C") + + + Assignment Type + + + For assigned you specify the personnel for the shift, for signup the personnel will assign themselves + + + Color + + + Below is where you specify which days the shift will on duty and the start time of shift for the first day. If your shift is contiguous the start time will be for the first shift day (i.e. Day 1 of a 48hour block). + + + Start Time + + + When does the shift start for the first day (or only day) + + + End Time + + + When does the shift end for the last day (or only day) + + + Here you can configure the required roles for each group in your department for the shift. Click the "Add group to shift" button to add a group to the shift, your shift can have many groups both station and orginizational. After you select the group (with the left hand drop down) you can click the "Add role to Group" button to add the required roles for the group. + + + Shift Group + + + Shift Roles + + + Add Group to Shift + + + Now select the personnel that are working in this shift for your department. These personnel will dynamically fill the group role slot based upon what group they are in if shift group roles were supplied. + + + Non-Group Personnel + + + Personnel assigned to the shift but not directly assigned to a station or organizational group (i.e. floaters or stand-bys) + + + Click the finish button below to create your new shift. If you need to add more days, or change any details you can always edit the shift later from the Shifts list page. + + + Start > + + + Schedule > + + + Slots > + + + Personnel > + + + Finish + + + First + + + Previous + + + Next + + + Shift Name is required + + + Shift Code is required + + + Shift start time is required. + + + + Edit Shift Details + + + Shift Code + + + Personnel that are part of a shift but are not assigned to a group (i.e. floaters or roaming personnel) + + + Cancel + + + Update Shift + + + + Edit Shift Calendar + + + Update Shift Days + + + + Edit Shift Groups + + + Save Shift Groups + + + + Calendar + + + + Shift Staffing + + + Shift + + + Select the shift to process staffing for. You will only see shifts you have access to (are the Group Admin of, or Department Admin) + + + Shift Day + + + Select the day to process the staffing for. + + + Note + + + Enter a freehand note for the shift (i.e. personnel staffing, switches mid-shift, etc) + + + Assignments + + + Units + + + Set Shift Day Staffing + + + Select Non-Group Personnel... + + + Select Personnel for Group... + + + Select Person... + + + Non - Group Personnel + + + + Your Shifts + + + Shift + + + Group + + + Day + + + Approved + + + Status + + + Timestamp + + + No + + + Yes + + + Trade Complete + + + Trade In Progress + + + Normal + + + for + + + WARNING: This will remove yourself from this shift. Are you sure you want to decline the shift + + + Decline Shift Day + + + Request Trade + + + Finish Trade + + + Your Pending Trades + + + Time + + + Process Trade Request + + + Declined + + + Accepted + + + Filled + + + Trade Offered + + + + View Shift + + + Shift Start + + + Shift Day Detail + + + Type: + + + Status: + + + Start: + + + End: + + + Upcoming + + + Completed + + + No End Time + + + Groups + + + Role + + + Required + + + Optional + + + Needed + + + Signups + + + Roles + + + Trades + + + Trade + + + {0} traded with {1} + + + + Shift Signup + + + Delete Shift Signup + + + Signup for Shift with + + + + Shift Signup Success + + + You have successfully signed up for this shift day. If you want to signup for more days of the shift you need to signup for each individual day. + + + Starts at {0} and Ends at {1} + + + Starts at {0} + + + + Request Trade + + + Users to Request + + + Click inside the input to select to request trade for (only users who meet the shift role needs are shown) + + + Submit Trade Request + + + + Process Trade Request + + + Dates to Trade + + + Click inside the input to select the shifts you are already on to complete the trade for. If you don't specify any day it's an unbalanced trade. + + + A note or FYI for this trade + + + Reject Trade Request + + + You sure you want to decline this trade request? + + + Propose Trade Request + + + Starts at + + + + Finalize Trade + + + Date + + + None + + + Finalize Trade + + + + Shift Role + + + Roles Count + + + Add Role to Group + + + Add Shift Roles to Group + + + Remove Group + + + Remove Role + + + Remove this role from the group + + + Role count is required + + diff --git a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.es.resx b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.es.resx index 4fdb1b6a..868e0a97 100644 --- a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.es.resx @@ -98,4 +98,505 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - \ No newline at end of file + + Eliminar turno estático + + + Editar un turno estático borrará todas las asignaciones o inscripciones para este turno y las reasignará según la nueva configuración. + + + Editar turno estático + + + Fecha/Hora de fin + + + El texto en el calendario es negro, téngalo en cuenta al elegir el color del turno, ya que se usará como fondo. + + + La fecha y hora en que termina el turno, donde las entidades se consideran disponibles. + + + La fecha y hora en que comienza el turno, donde las entidades se consideran comprometidas. + + + Fecha/Hora de inicio + + + Ver día de turno estático + + + Turnos + + + Sus turnos + + + Dotación de turno + + + Turnos de grupo recurrentes + + + Agregar turno de grupo recurrente + + + Nombre + + + Tipo + + + Horario + + + Grupos + + + Personal + + + Asignado + + + Inscripción + + + Manual + + + Personalizado + + + 48 dentro 96 fuera + + + 24 dentro 48 fuera + + + 24 dentro 72 fuera + + + Ver calendario + + + Editar detalles + + + Editar calendario + + + Editar grupos + + + Eliminar + + + ADVERTENCIA: Esto eliminará permanentemente este turno. ¿Está seguro de que desea eliminar el turno? + + + Calendario + + + Agregar turno estático + + + Nuevo turno + + + Bienvenido al asistente de nuevo turno. Este asistente le guiará en la creación de un nuevo turno para su departamento. + + + Nombre + + + Nombre del turno + + + Por ejemplo ("Turno A", "Turno B", "Turno C") + + + Código + + + Ejemplo ("A", "B", "C") + + + Tipo de asignación + + + Para asignado, usted especifica el personal; para inscripción, el personal se asignará a sí mismo. + + + Color + + + Aquí especifica qué días estará el turno de servicio y la hora de inicio del turno para el primer día. + + + Hora de inicio + + + ¿Cuándo comienza el turno el primer día (o único día)? + + + Hora de fin + + + ¿Cuándo termina el turno el último día (o único día)? + + + Aquí puede configurar los roles requeridos para cada grupo en su departamento para el turno. + + + Grupo de turno + + + Roles de turno + + + Agregar grupo al turno + + + Seleccione el personal que trabajará en este turno para su departamento. + + + Personal sin grupo + + + Personal asignado al turno pero no directamente a una estación o grupo organizacional + + + Haga clic en el botón Finalizar para crear su nuevo turno. + + + Inicio > + + + Horario > + + + Slots > + + + Personal > + + + Finalizar + + + Primero + + + Anterior + + + Siguiente + + + El nombre del turno es obligatorio + + + El código del turno es obligatorio + + + La hora de inicio del turno es obligatoria. + + + Editar detalles del turno + + + Código del turno + + + Personal que forma parte de un turno pero no está asignado a un grupo + + + Cancelar + + + Actualizar turno + + + Editar calendario de turno + + + Actualizar días de turno + + + Editar grupos de turno + + + Guardar grupos de turno + + + Calendario + + + Dotación de turno + + + Turno + + + Seleccione el turno para procesar la dotación. + + + Día de turno + + + Seleccione el día para procesar la dotación. + + + Nota + + + Ingrese una nota de texto libre para el turno + + + Asignaciones + + + Unidades + + + Establecer dotación del día de turno + + + Seleccionar personal sin grupo... + + + Seleccionar personal para grupo... + + + Seleccionar persona... + + + Personal sin grupo + + + Sus turnos + + + Turno + + + Grupo + + + Día + + + Aprobado + + + Estado + + + Marca de tiempo + + + No + + + + + + Intercambio completado + + + Intercambio en progreso + + + Normal + + + para + + + ADVERTENCIA: Esto le eliminará de este turno. ¿Está seguro de que desea rechazar el turno? + + + Rechazar día de turno + + + Solicitar intercambio + + + Finalizar intercambio + + + Sus intercambios pendientes + + + Hora + + + Procesar solicitud de intercambio + + + Rechazado + + + Aceptado + + + Cubierto + + + Intercambio ofrecido + + + Ver turno + + + Inicio del turno + + + Detalle del día de turno + + + Tipo: + + + Estado: + + + Inicio: + + + Fin: + + + Próximo + + + Completado + + + Sin hora de fin + + + Grupos + + + Rol + + + Requerido + + + Opcional + + + Necesario + + + Inscripciones + + + Roles + + + Intercambios + + + Intercambio + + + {0} intercambió con {1} + + + Inscripción de turno + + + Eliminar inscripción de turno + + + Inscribirse en el turno con + + + Inscripción de turno exitosa + + + Se ha inscrito exitosamente para este día de turno. Si desea inscribirse para más días, debe hacerlo para cada día individualmente. + + + Comienza a las {0} y termina a las {1} + + + Comienza a las {0} + + + Solicitar intercambio + + + Usuarios a solicitar + + + Haga clic en el campo para seleccionar a quién solicitar el intercambio + + + Enviar solicitud de intercambio + + + Procesar solicitud de intercambio + + + Fechas para intercambiar + + + Seleccione los turnos en los que ya está inscrito para completar el intercambio. + + + Una nota o aviso para este intercambio + + + Rechazar solicitud de intercambio + + + ¿Está seguro de que desea rechazar esta solicitud de intercambio? + + + Proponer intercambio + + + Comienza a las + + + Finalizar intercambio + + + Fecha + + + Ninguno + + + Finalizar intercambio + + + Rol de turno + + + Cantidad de roles + + + Agregar rol al grupo + + + Agregar roles de turno al grupo + + + Eliminar grupo + + + Eliminar rol + + + Eliminar este rol del grupo + + + La cantidad de roles es obligatoria + + diff --git a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.fr.resx b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.fr.resx index 821b0a1a..b7564e22 100644 --- a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.fr.resx @@ -69,30 +69,504 @@ [base64 mime encoded string representing a byte array form of the .NET Framework object] - Delete Static Shift + Supprimer le quart statique - Editing a static shift will clear out all assignments or sign-ups for this shift and reassign them based on the new settings. If you have any assignments or sign-ups for this shift they will be removed and the new settings will be applied. + La modification d'un quart statique effacera toutes les affectations ou inscriptions pour ce quart et les réaffectera selon les nouveaux paramètres. - Edit Static Shift + Modifier le quart statique - End Date/Time + Date/Heure de fin - Note the text on the Calendar is black, take that into account when picking a shift color as the color will be used as the background + Le texte du calendrier est noir, tenez-en compte lors du choix de la couleur du quart car elle sera utilisée comme arrière-plan. - The date and time that the shift ends (i.e. back in quarters) where entities under it are considered available. + La date et l'heure de fin du quart où les entités sont considérées disponibles. - The date and time that the shift starts (i.e. start of travel) where entities under it are considered committed. + La date et l'heure de début du quart où les entités sont considérées engagées. - Start Date/Time + Date/Heure de début - View Static Shift Day + Voir le jour de quart statique + + + Quarts + + + Vos quarts + + + Effectif du quart + + + Quarts de groupe récurrents + + + Ajouter un quart de groupe récurrent + + + Nom + + + Type + + + Horaire + + + Groupes + + + Personnel + + + Assigné + + + Inscription + + + Manuel + + + Personnalisé + + + 48 dans 96 dehors + + + 24 dans 48 dehors + + + 24 dans 72 dehors + + + Voir le calendrier + + + Modifier les détails + + + Modifier le calendrier + + + Modifier les groupes + + + Supprimer + + + AVERTISSEMENT : Ceci supprimera définitivement ce quart. Êtes-vous sûr de vouloir supprimer le quart ? + + + Calendrier + + + Ajouter un quart statique + + + Nouveau quart + + + Bienvenue dans l'assistant de nouveau quart. Cet assistant vous guidera dans la création d'un nouveau quart pour votre département. + + + Nom + + + Nom du quart + + + Par exemple ("Quart A", "Quart B", "Quart C") + + + Code + + + Exemple ("A", "B", "C") + + + Type d'affectation + + + Pour affecté, vous spécifiez le personnel ; pour inscription, le personnel s'affectera lui-même. + + + Couleur + + + Ici, vous spécifiez quels jours le quart sera en service et l'heure de début du quart pour le premier jour. + + + Heure de début + + + Quand le quart commence-t-il le premier jour (ou jour unique) ? + + + Heure de fin + + + Quand le quart se termine-t-il le dernier jour (ou jour unique) ? + + + Ici, vous pouvez configurer les rôles requis pour chaque groupe de votre département pour le quart. + + + Groupe de quart + + + Rôles de quart + + + Ajouter un groupe au quart + + + Sélectionnez maintenant le personnel qui travaille dans ce quart pour votre département. + + + Personnel hors groupe + + + Personnel affecté au quart mais non directement assigné à une station ou à un groupe organisationnel + + + Cliquez sur le bouton Terminer pour créer votre nouveau quart. + + + Début > + + + Horaire > + + + Créneaux > + + + Personnel > + + + Terminer + + + Premier + + + Précédent + + + Suivant + + + Le nom du quart est obligatoire + + + Le code du quart est obligatoire + + + L'heure de début du quart est obligatoire. + + + Modifier les détails du quart + + + Code du quart + + + Personnel faisant partie d'un quart mais non assigné à un groupe + + + Annuler + + + Mettre à jour le quart + + + Modifier le calendrier du quart + + + Mettre à jour les jours de quart + + + Modifier les groupes de quart + + + Enregistrer les groupes de quart + + + Calendrier + + + Effectif du quart + + + Quart + + + Sélectionnez le quart pour traiter l'effectif. + + + Jour de quart + + + Sélectionnez le jour pour traiter l'effectif. + + + Note + + + Saisissez une note libre pour le quart + + + Affectations + + + Unités + + + Définir l'effectif du jour de quart + + + Sélectionner le personnel hors groupe... + + + Sélectionner le personnel pour le groupe... + + + Sélectionner une personne... + + + Personnel hors groupe + + + Vos quarts + + + Quart + + + Groupe + + + Jour + + + Approuvé + + + Statut + + + Horodatage + + + Non + + + Oui + + + Échange terminé + + + Échange en cours + + + Normal + + + pour + + + AVERTISSEMENT : Cela vous supprimera de ce quart. Êtes-vous sûr de vouloir refuser le quart ? + + + Refuser le jour de quart + + + Demander un échange + + + Finaliser l'échange + + + Vos échanges en attente + + + Heure + + + Traiter la demande d'échange + + + Refusé + + + Accepté + + + Pourvu + + + Échange proposé + + + Voir le quart + + + Début du quart + + + Détail du jour de quart + + + Type : + + + Statut : + + + Début : + + + Fin : + + + À venir + + + Terminé + + + Pas d'heure de fin + + + Groupes + + + Rôle + + + Requis + + + Optionnel + + + Nécessaire + + + Inscriptions + + + Rôles + + + Échanges + + + Échange + + + {0} a échangé avec {1} + + + Inscription au quart + + + Supprimer l'inscription au quart + + + S'inscrire au quart avec + + + Inscription au quart réussie + + + Vous vous êtes inscrit avec succès pour ce jour de quart. Si vous souhaitez vous inscrire pour d'autres jours, vous devez vous inscrire pour chaque jour individuellement. + + + Commence à {0} et se termine à {1} + + + Commence à {0} + + + Demander un échange + + + Utilisateurs à demander + + + Cliquez dans le champ pour sélectionner à qui demander l'échange + + + Soumettre la demande d'échange + + + Traiter la demande d'échange + + + Dates à échanger + + + Sélectionnez les quarts auxquels vous êtes déjà inscrit pour compléter l'échange. + + + Une note ou info pour cet échange + + + Rejeter la demande d'échange + + + Êtes-vous sûr de vouloir refuser cette demande d'échange ? + + + Proposer un échange + + + Commence à + + + Finaliser l'échange + + + Date + + + Aucun + + + Finaliser l'échange + + + Rôle de quart + + + Nombre de rôles + + + Ajouter un rôle au groupe + + + Ajouter des rôles de quart au groupe + + + Supprimer le groupe + + + Supprimer le rôle + + + Supprimer ce rôle du groupe + + + Le nombre de rôles est obligatoire diff --git a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.it.resx b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.it.resx index 821b0a1a..fffd1384 100644 --- a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.it.resx @@ -69,30 +69,504 @@ [base64 mime encoded string representing a byte array form of the .NET Framework object] - Delete Static Shift + Elimina turno statico - Editing a static shift will clear out all assignments or sign-ups for this shift and reassign them based on the new settings. If you have any assignments or sign-ups for this shift they will be removed and the new settings will be applied. + La modifica di un turno statico cancellerà tutte le assegnazioni o le iscrizioni per questo turno e le riassegnerà in base alle nuove impostazioni. - Edit Static Shift + Modifica turno statico - End Date/Time + Data/Ora di fine - Note the text on the Calendar is black, take that into account when picking a shift color as the color will be used as the background + Il testo nel calendario è nero, tienilo presente quando scegli il colore del turno poiché verrà usato come sfondo. - The date and time that the shift ends (i.e. back in quarters) where entities under it are considered available. + La data e l'ora in cui il turno termina, dove le entità sono considerate disponibili. - The date and time that the shift starts (i.e. start of travel) where entities under it are considered committed. + La data e l'ora in cui il turno inizia, dove le entità sono considerate impegnate. - Start Date/Time + Data/Ora di inizio - View Static Shift Day + Visualizza giorno turno statico + + + Turni + + + I tuoi turni + + + Personale del turno + + + Turni di gruppo ricorrenti + + + Aggiungi turno di gruppo ricorrente + + + Nome + + + Tipo + + + Programma + + + Gruppi + + + Personale + + + Assegnato + + + Iscrizione + + + Manuale + + + Personalizzato + + + 48 dentro 96 fuori + + + 24 dentro 48 fuori + + + 24 dentro 72 fuori + + + Visualizza calendario + + + Modifica dettagli + + + Modifica calendario + + + Modifica gruppi + + + Elimina + + + ATTENZIONE: Questo eliminerà definitivamente questo turno. Sei sicuro di voler eliminare il turno? + + + Calendario + + + Aggiungi turno statico + + + Nuovo turno + + + Benvenuto nella procedura guidata per il nuovo turno. Questa procedura ti guiderà nella creazione di un nuovo turno per il tuo dipartimento. + + + Nome + + + Nome del turno + + + Ad esempio ("Turno A", "Turno B", "Turno C") + + + Codice + + + Esempio ("A", "B", "C") + + + Tipo di assegnazione + + + Per assegnato specifichi il personale; per iscrizione il personale si assegnerà da solo. + + + Colore + + + Qui specifichi quali giorni il turno sarà in servizio e l'orario di inizio del turno per il primo giorno. + + + Ora di inizio + + + Quando inizia il turno il primo giorno (o unico giorno)? + + + Ora di fine + + + Quando termina il turno l'ultimo giorno (o unico giorno)? + + + Qui puoi configurare i ruoli richiesti per ogni gruppo nel tuo dipartimento per il turno. + + + Gruppo turno + + + Ruoli turno + + + Aggiungi gruppo al turno + + + Ora seleziona il personale che lavorerà in questo turno per il tuo dipartimento. + + + Personale senza gruppo + + + Personale assegnato al turno ma non direttamente a una stazione o gruppo organizzativo + + + Fai clic sul pulsante Fine per creare il tuo nuovo turno. + + + Inizio > + + + Programma > + + + Slot > + + + Personale > + + + Fine + + + Primo + + + Precedente + + + Avanti + + + Il nome del turno è obbligatorio + + + Il codice del turno è obbligatorio + + + L'ora di inizio del turno è obbligatoria. + + + Modifica dettagli turno + + + Codice turno + + + Personale che fa parte di un turno ma non è assegnato a un gruppo + + + Annulla + + + Aggiorna turno + + + Modifica calendario turno + + + Aggiorna giorni turno + + + Modifica gruppi turno + + + Salva gruppi turno + + + Calendario + + + Personale del turno + + + Turno + + + Seleziona il turno per elaborare il personale. + + + Giorno turno + + + Seleziona il giorno per elaborare il personale. + + + Nota + + + Inserisci una nota libera per il turno + + + Assegnazioni + + + Unità + + + Imposta personale del giorno turno + + + Seleziona personale senza gruppo... + + + Seleziona personale per il gruppo... + + + Seleziona persona... + + + Personale senza gruppo + + + I tuoi turni + + + Turno + + + Gruppo + + + Giorno + + + Approvato + + + Stato + + + Timestamp + + + No + + + + + + Scambio completato + + + Scambio in corso + + + Normale + + + per + + + ATTENZIONE: Questo ti rimuoverà da questo turno. Sei sicuro di voler rifiutare il turno? + + + Rifiuta giorno turno + + + Richiedi scambio + + + Finalizza scambio + + + I tuoi scambi in sospeso + + + Ora + + + Elabora richiesta di scambio + + + Rifiutato + + + Accettato + + + Completato + + + Scambio proposto + + + Visualizza turno + + + Inizio turno + + + Dettaglio giorno turno + + + Tipo: + + + Stato: + + + Inizio: + + + Fine: + + + Prossimo + + + Completato + + + Nessun orario di fine + + + Gruppi + + + Ruolo + + + Richiesto + + + Opzionale + + + Necessario + + + Iscrizioni + + + Ruoli + + + Scambi + + + Scambio + + + {0} ha scambiato con {1} + + + Iscrizione turno + + + Elimina iscrizione turno + + + Iscriviti al turno con + + + Iscrizione turno riuscita + + + Ti sei iscritto con successo per questo giorno di turno. Se vuoi iscriverti per altri giorni, devi farlo per ogni giorno individualmente. + + + Inizia alle {0} e termina alle {1} + + + Inizia alle {0} + + + Richiedi scambio + + + Utenti da richiedere + + + Clicca nel campo per selezionare a chi richiedere lo scambio + + + Invia richiesta di scambio + + + Elabora richiesta di scambio + + + Date da scambiare + + + Seleziona i turni in cui sei già iscritto per completare lo scambio. + + + Una nota o informazione per questo scambio + + + Rifiuta richiesta di scambio + + + Sei sicuro di voler rifiutare questa richiesta di scambio? + + + Proponi scambio + + + Inizia alle + + + Finalizza scambio + + + Data + + + Nessuno + + + Finalizza scambio + + + Ruolo turno + + + Numero ruoli + + + Aggiungi ruolo al gruppo + + + Aggiungi ruoli turno al gruppo + + + Rimuovi gruppo + + + Rimuovi ruolo + + + Rimuovi questo ruolo dal gruppo + + + Il numero di ruoli è obbligatorio diff --git a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.pl.resx b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.pl.resx index 821b0a1a..b64185e7 100644 --- a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.pl.resx @@ -69,30 +69,504 @@ [base64 mime encoded string representing a byte array form of the .NET Framework object] - Delete Static Shift + Usuń statyczną zmianę - Editing a static shift will clear out all assignments or sign-ups for this shift and reassign them based on the new settings. If you have any assignments or sign-ups for this shift they will be removed and the new settings will be applied. + Edycja statycznej zmiany usunie wszystkie przypisania lub rejestracje dla tej zmiany i przypisze je ponownie na podstawie nowych ustawień. - Edit Static Shift + Edytuj statyczną zmianę - End Date/Time + Data/godzina zakończenia - Note the text on the Calendar is black, take that into account when picking a shift color as the color will be used as the background + Tekst w kalendarzu jest czarny, pamiętaj o tym wybierając kolor zmiany, ponieważ będzie używany jako tło. - The date and time that the shift ends (i.e. back in quarters) where entities under it are considered available. + Data i godzina zakończenia zmiany, kiedy jednostki są uznawane za dostępne. - The date and time that the shift starts (i.e. start of travel) where entities under it are considered committed. + Data i godzina rozpoczęcia zmiany, kiedy jednostki są uznawane za zaangażowane. - Start Date/Time + Data/godzina rozpoczęcia - View Static Shift Day + Wyświetl dzień statycznej zmiany + + + Zmiany + + + Twoje zmiany + + + Obsada zmiany + + + Cykliczne zmiany grupowe + + + Dodaj cykliczną zmianę grupową + + + Nazwa + + + Typ + + + Harmonogram + + + Grupy + + + Personel + + + Przypisany + + + Rejestracja + + + Ręczny + + + Niestandardowy + + + 48 w 96 poza + + + 24 w 48 poza + + + 24 w 72 poza + + + Wyświetl kalendarz + + + Edytuj szczegóły + + + Edytuj kalendarz + + + Edytuj grupy + + + Usuń + + + OSTRZEŻENIE: Spowoduje to trwałe usunięcie tej zmiany. Czy na pewno chcesz usunąć zmianę? + + + Kalendarz + + + Dodaj statyczną zmianę + + + Nowa zmiana + + + Witamy w kreatorze nowej zmiany. Ten kreator przeprowadzi Cię przez tworzenie nowej zmiany dla Twojego działu. + + + Nazwa + + + Nazwa zmiany + + + Na przykład ("Zmiana A", "Zmiana B", "Zmiana C") + + + Kod + + + Przykład ("A", "B", "C") + + + Typ przypisania + + + Dla przypisanego określasz personel; dla rejestracji personel przypisze się samodzielnie. + + + Kolor + + + Tutaj określasz dni, w których zmiana będzie na służbie, i godzinę rozpoczęcia zmiany pierwszego dnia. + + + Godzina rozpoczęcia + + + Kiedy zmiana rozpoczyna się pierwszego dnia (lub jedynego dnia)? + + + Godzina zakończenia + + + Kiedy zmiana kończy się ostatniego dnia (lub jedynego dnia)? + + + Tutaj możesz skonfigurować wymagane role dla każdej grupy w Twoim dziale dla tej zmiany. + + + Grupa zmiany + + + Role zmiany + + + Dodaj grupę do zmiany + + + Teraz wybierz personel pracujący w tej zmianie dla Twojego działu. + + + Personel bez grupy + + + Personel przypisany do zmiany, ale nie bezpośrednio do stacji lub grupy organizacyjnej + + + Kliknij przycisk Zakończ, aby utworzyć nową zmianę. + + + Start > + + + Harmonogram > + + + Sloty > + + + Personel > + + + Zakończ + + + Pierwszy + + + Poprzedni + + + Następny + + + Nazwa zmiany jest wymagana + + + Kod zmiany jest wymagany + + + Godzina rozpoczęcia zmiany jest wymagana. + + + Edytuj szczegóły zmiany + + + Kod zmiany + + + Personel będący częścią zmiany, ale nieprzypisany do grupy + + + Anuluj + + + Zaktualizuj zmianę + + + Edytuj kalendarz zmiany + + + Zaktualizuj dni zmiany + + + Edytuj grupy zmiany + + + Zapisz grupy zmiany + + + Kalendarz + + + Obsada zmiany + + + Zmiana + + + Wybierz zmianę, dla której chcesz przetworzyć obsadę. + + + Dzień zmiany + + + Wybierz dzień, dla którego chcesz przetworzyć obsadę. + + + Notatka + + + Wprowadź dowolną notatkę dla zmiany + + + Przypisania + + + Jednostki + + + Ustaw obsadę dnia zmiany + + + Wybierz personel bez grupy... + + + Wybierz personel dla grupy... + + + Wybierz osobę... + + + Personel bez grupy + + + Twoje zmiany + + + Zmiana + + + Grupa + + + Dzień + + + Zatwierdzone + + + Status + + + Znacznik czasu + + + Nie + + + Tak + + + Wymiana zakończona + + + Wymiana w toku + + + Normalny + + + dla + + + OSTRZEŻENIE: Spowoduje to usunięcie Cię z tej zmiany. Czy na pewno chcesz odrzucić zmianę? + + + Odrzuć dzień zmiany + + + Poproś o wymianę + + + Zakończ wymianę + + + Twoje oczekujące wymiany + + + Czas + + + Przetwórz wniosek o wymianę + + + Odrzucony + + + Zaakceptowany + + + Wypełniony + + + Wymiana zaproponowana + + + Wyświetl zmianę + + + Początek zmiany + + + Szczegóły dnia zmiany + + + Typ: + + + Status: + + + Początek: + + + Koniec: + + + Nadchodzący + + + Zakończony + + + Brak godziny zakończenia + + + Grupy + + + Rola + + + Wymagane + + + Opcjonalne + + + Potrzebne + + + Rejestracje + + + Role + + + Wymiany + + + Wymiana + + + {0} wymienił się z {1} + + + Rejestracja na zmianę + + + Usuń rejestrację na zmianę + + + Zarejestruj się na zmianę z + + + Rejestracja na zmianę powiodła się + + + Pomyślnie zarejestrowałeś się na ten dzień zmiany. Jeśli chcesz zapisać się na więcej dni, musisz zarejestrować się na każdy dzień osobno. + + + Rozpoczyna się o {0} i kończy o {1} + + + Rozpoczyna się o {0} + + + Poproś o wymianę + + + Użytkownicy do poproszenia + + + Kliknij w pole, aby wybrać komu złożyć wniosek o wymianę + + + Wyślij wniosek o wymianę + + + Przetwórz wniosek o wymianę + + + Daty do wymiany + + + Wybierz zmiany, na które jesteś już zapisany, aby dokończyć wymianę. + + + Notatka lub informacja dla tej wymiany + + + Odrzuć wniosek o wymianę + + + Czy na pewno chcesz odrzucić ten wniosek o wymianę? + + + Zaproponuj wymianę + + + Rozpoczyna się o + + + Sfinalizuj wymianę + + + Data + + + Brak + + + Sfinalizuj wymianę + + + Rola zmiany + + + Liczba ról + + + Dodaj rolę do grupy + + + Dodaj role zmiany do grupy + + + Usuń grupę + + + Usuń rolę + + + Usuń tę rolę z grupy + + + Liczba ról jest wymagana diff --git a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.sv.resx b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.sv.resx index 821b0a1a..1ab30068 100644 --- a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.sv.resx @@ -69,30 +69,504 @@ [base64 mime encoded string representing a byte array form of the .NET Framework object] - Delete Static Shift + Ta bort statiskt skift - Editing a static shift will clear out all assignments or sign-ups for this shift and reassign them based on the new settings. If you have any assignments or sign-ups for this shift they will be removed and the new settings will be applied. + Att redigera ett statiskt skift kommer att rensa alla tilldelningar eller anmälningar för detta skift och tilldela om dem baserat på de nya inställningarna. - Edit Static Shift + Redigera statiskt skift - End Date/Time + Slutdatum/-tid - Note the text on the Calendar is black, take that into account when picking a shift color as the color will be used as the background + Texten i kalendern är svart, ta hänsyn till det när du väljer skiftfärg eftersom färgen används som bakgrund. - The date and time that the shift ends (i.e. back in quarters) where entities under it are considered available. + Datum och tid när skiftet slutar, där enheter under det anses tillgängliga. - The date and time that the shift starts (i.e. start of travel) where entities under it are considered committed. + Datum och tid när skiftet börjar, där enheter under det anses bundna. - Start Date/Time + Startdatum/-tid - View Static Shift Day + Visa statisk skiftdag + + + Skift + + + Dina skift + + + Skiftbemanning + + + Återkommande gruppskift + + + Lägg till återkommande gruppskift + + + Namn + + + Typ + + + Schema + + + Grupper + + + Personal + + + Tilldelad + + + Anmälan + + + Manuell + + + Anpassad + + + 48 på 96 av + + + 24 på 48 av + + + 24 på 72 av + + + Visa kalender + + + Redigera detaljer + + + Redigera kalender + + + Redigera grupper + + + Ta bort + + + VARNING: Detta kommer permanent ta bort detta skift. Är du säker på att du vill ta bort skiftet? + + + Kalender + + + Lägg till statiskt skift + + + Nytt skift + + + Välkommen till guiden för nytt skift. Denna guide leder dig genom att skapa ett nytt skift för din avdelning. + + + Namn + + + Skiftets namn + + + Till exempel ("Skift A", "Skift B", "Skift C") + + + Kod + + + Exempel ("A", "B", "C") + + + Tilldelningstyp + + + För tilldelad anger du personalen; för anmälan tilldelar sig personalen själv. + + + Färg + + + Här anger du vilka dagar skiftet är i tjänst och starttiden för skiftet den första dagen. + + + Starttid + + + När börjar skiftet den första dagen (eller enda dagen)? + + + Sluttid + + + När slutar skiftet den sista dagen (eller enda dagen)? + + + Här kan du konfigurera de roller som krävs för varje grupp i din avdelning för skiftet. + + + Skiftgrupp + + + Skiftroller + + + Lägg till grupp i skift + + + Välj nu den personal som arbetar i detta skift för din avdelning. + + + Personal utan grupp + + + Personal tilldelad skiftet men inte direkt tilldelad en station eller organisationsgrupp + + + Klicka på Slutför-knappen nedan för att skapa ditt nya skift. + + + Start > + + + Schema > + + + Slots > + + + Personal > + + + Slutför + + + Första + + + Föregående + + + Nästa + + + Skiftnamn krävs + + + Skiftkod krävs + + + Skiftets starttid krävs. + + + Redigera skiftdetaljer + + + Skiftkod + + + Personal som är del av ett skift men inte tilldelad en grupp + + + Avbryt + + + Uppdatera skift + + + Redigera skiftkalender + + + Uppdatera skiftdagar + + + Redigera skiftgrupper + + + Spara skiftgrupper + + + Kalender + + + Skiftbemanning + + + Skift + + + Välj skiftet för att bearbeta bemanningen. + + + Skiftdag + + + Välj den dag för vilken bemanningen ska bearbetas. + + + Notat + + + Ange en frihandsnotering för skiftet + + + Tilldelningar + + + Enheter + + + Ange skiftdagsbemanning + + + Välj personal utan grupp... + + + Välj personal för grupp... + + + Välj person... + + + Personal utan grupp + + + Dina skift + + + Skift + + + Grupp + + + Dag + + + Godkänd + + + Status + + + Tidsstämpel + + + Nej + + + Ja + + + Byte slutfört + + + Byte pågår + + + Normal + + + för + + + VARNING: Detta tar bort dig från detta skift. Är du säker på att du vill avböja skiftet? + + + Avböj skiftdag + + + Begär byte + + + Slutför byte + + + Dina väntande byten + + + Tid + + + Behandla bytebegäran + + + Avböjd + + + Accepterad + + + Fylld + + + Byte erbjudet + + + Visa skift + + + Skiftstart + + + Skiftdagsdetalj + + + Typ: + + + Status: + + + Start: + + + Slut: + + + Kommande + + + Slutförd + + + Ingen sluttid + + + Grupper + + + Roll + + + Krävs + + + Valfri + + + Behövs + + + Anmälningar + + + Roller + + + Byten + + + Byte + + + {0} bytte med {1} + + + Skiftanmälan + + + Ta bort skiftanmälan + + + Anmäl dig till skiftet med + + + Skiftanmälan lyckades + + + Du har framgångsrikt anmält dig för denna skiftdag. Om du vill anmäla dig för fler dagar behöver du anmäla dig för varje dag individuellt. + + + Börjar kl {0} och slutar kl {1} + + + Börjar kl {0} + + + Begär byte + + + Användare att begära + + + Klicka i fältet för att välja vem du vill begära byte med + + + Skicka bytebegäran + + + Behandla bytebegäran + + + Datum att byta + + + Välj de skift du redan är inbokad på för att slutföra bytet. + + + En notering eller info för detta byte + + + Avvisa bytebegäran + + + Är du säker på att du vill avvisa denna bytebegäran? + + + Föreslå byte + + + Börjar kl + + + Slutför byte + + + Datum + + + Ingen + + + Slutför byte + + + Skiftroll + + + Antal roller + + + Lägg till roll i grupp + + + Lägg till skiftroller i grupp + + + Ta bort grupp + + + Ta bort roll + + + Ta bort denna roll från gruppen + + + Antal roller krävs diff --git a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.uk.resx b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.uk.resx index 821b0a1a..120745f9 100644 --- a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.uk.resx @@ -69,30 +69,504 @@ [base64 mime encoded string representing a byte array form of the .NET Framework object] - Delete Static Shift + Видалити статичну зміну - Editing a static shift will clear out all assignments or sign-ups for this shift and reassign them based on the new settings. If you have any assignments or sign-ups for this shift they will be removed and the new settings will be applied. + Редагування статичної зміни видалить усі призначення або реєстрації для цієї зміни та переназначить їх відповідно до нових налаштувань. - Edit Static Shift + Редагувати статичну зміну - End Date/Time + Дата/час завершення - Note the text on the Calendar is black, take that into account when picking a shift color as the color will be used as the background + Текст у календарі чорний, враховуйте це при виборі кольору зміни, оскільки він використовуватиметься як фон. - The date and time that the shift ends (i.e. back in quarters) where entities under it are considered available. + Дата та час завершення зміни, коли підпорядковані об'єкти вважаються доступними. - The date and time that the shift starts (i.e. start of travel) where entities under it are considered committed. + Дата та час початку зміни, коли підпорядковані об'єкти вважаються зайнятими. - Start Date/Time + Дата/час початку - View Static Shift Day + Переглянути день статичної зміни + + + Зміни + + + Ваші зміни + + + Укомплектування зміни + + + Повторювані групові зміни + + + Додати повторювану групову зміну + + + Назва + + + Тип + + + Розклад + + + Групи + + + Персонал + + + Призначений + + + Реєстрація + + + Ручний + + + Користувацький + + + 48 роботи 96 відпочинку + + + 24 роботи 48 відпочинку + + + 24 роботи 72 відпочинку + + + Переглянути календар + + + Редагувати деталі + + + Редагувати календар + + + Редагувати групи + + + Видалити + + + ПОПЕРЕДЖЕННЯ: Це назавжди видалить цю зміну. Ви впевнені, що хочете видалити зміну? + + + Календар + + + Додати статичну зміну + + + Нова зміна + + + Ласкаво просимо до майстра нової зміни. Цей майстер допоможе вам створити нову зміну для вашого підрозділу. + + + Назва + + + Назва зміни + + + Наприклад ("Зміна А", "Зміна Б", "Зміна В") + + + Код + + + Приклад ("А", "Б", "В") + + + Тип призначення + + + Для призначеного ви визначаєте персонал; для реєстрації персонал призначається самостійно. + + + Колір + + + Тут ви вказуєте, в які дні зміна буде на службі, та час початку зміни для першого дня. + + + Час початку + + + Коли починається зміна в перший день (або єдиний день)? + + + Час завершення + + + Коли закінчується зміна в останній день (або єдиний день)? + + + Тут ви можете налаштувати необхідні ролі для кожної групи у вашому підрозділі для зміни. + + + Група зміни + + + Ролі зміни + + + Додати групу до зміни + + + Тепер виберіть персонал, який працює в цій зміні для вашого підрозділу. + + + Персонал без групи + + + Персонал, призначений до зміни, але не безпосередньо до станції або організаційної групи + + + Натисніть кнопку Завершити, щоб створити нову зміну. + + + Початок > + + + Розклад > + + + Слоти > + + + Персонал > + + + Завершити + + + Перший + + + Попередній + + + Далі + + + Назва зміни є обов'язковою + + + Код зміни є обов'язковим + + + Час початку зміни є обов'язковим. + + + Редагувати деталі зміни + + + Код зміни + + + Персонал, який є частиною зміни, але не призначений до групи + + + Скасувати + + + Оновити зміну + + + Редагувати календар зміни + + + Оновити дні зміни + + + Редагувати групи зміни + + + Зберегти групи зміни + + + Календар + + + Укомплектування зміни + + + Зміна + + + Виберіть зміну для обробки укомплектування. + + + День зміни + + + Виберіть день для обробки укомплектування. + + + Примітка + + + Введіть довільну примітку для зміни + + + Призначення + + + Підрозділи + + + Встановити укомплектування дня зміни + + + Вибрати персонал без групи... + + + Вибрати персонал для групи... + + + Вибрати особу... + + + Персонал без групи + + + Ваші зміни + + + Зміна + + + Група + + + День + + + Затверджено + + + Статус + + + Часова мітка + + + Ні + + + Так + + + Обмін завершено + + + Обмін в процесі + + + Нормальний + + + для + + + ПОПЕРЕДЖЕННЯ: Це видалить вас із цієї зміни. Ви впевнені, що хочете відхилити зміну? + + + Відхилити день зміни + + + Запросити обмін + + + Завершити обмін + + + Ваші очікувані обміни + + + Час + + + Обробити запит на обмін + + + Відхилено + + + Прийнято + + + Виконано + + + Обмін запропоновано + + + Переглянути зміну + + + Початок зміни + + + Деталі дня зміни + + + Тип: + + + Статус: + + + Початок: + + + Кінець: + + + Майбутній + + + Завершений + + + Немає часу завершення + + + Групи + + + Роль + + + Обов'язково + + + Необов'язково + + + Потрібно + + + Реєстрації + + + Ролі + + + Обміни + + + Обмін + + + {0} обмінявся з {1} + + + Реєстрація на зміну + + + Видалити реєстрацію на зміну + + + Зареєструватися на зміну з + + + Реєстрація на зміну успішна + + + Ви успішно зареєструвалися на цей день зміни. Якщо ви хочете зареєструватися на більше днів, вам потрібно реєструватися на кожен день окремо. + + + Починається о {0} і завершується о {1} + + + Починається о {0} + + + Запросити обмін + + + Користувачі для запиту + + + Клацніть у полі, щоб вибрати кому надіслати запит на обмін + + + Надіслати запит на обмін + + + Обробити запит на обмін + + + Дати для обміну + + + Виберіть зміни, на які ви вже зареєстровані, щоб завершити обмін. + + + Примітка або інформація для цього обміну + + + Відхилити запит на обмін + + + Ви впевнені, що хочете відхилити цей запит на обмін? + + + Запропонувати обмін + + + Починається о + + + Завершити обмін + + + Дата + + + Немає + + + Завершити обмін + + + Роль зміни + + + Кількість ролей + + + Додати роль до групи + + + Додати ролі зміни до групи + + + Видалити групу + + + Видалити роль + + + Видалити цю роль із групи + + + Кількість ролей є обов'язковою diff --git a/Core/Resgrid.Model/Repositories/IWorkflowRunLogRepository.cs b/Core/Resgrid.Model/Repositories/IWorkflowRunLogRepository.cs index c418bde1..5282630d 100644 --- a/Core/Resgrid.Model/Repositories/IWorkflowRunLogRepository.cs +++ b/Core/Resgrid.Model/Repositories/IWorkflowRunLogRepository.cs @@ -6,5 +6,7 @@ namespace Resgrid.Model.Repositories public interface IWorkflowRunLogRepository : IRepository { Task> GetByWorkflowRunIdAsync(string workflowRunId); + Task DeleteAllByWorkflowRunIdAsync(string workflowRunId); + Task DeleteAllByWorkflowIdAsync(string workflowId); } } diff --git a/Core/Resgrid.Model/Repositories/IWorkflowRunRepository.cs b/Core/Resgrid.Model/Repositories/IWorkflowRunRepository.cs index 08b19900..5e935614 100644 --- a/Core/Resgrid.Model/Repositories/IWorkflowRunRepository.cs +++ b/Core/Resgrid.Model/Repositories/IWorkflowRunRepository.cs @@ -9,5 +9,6 @@ public interface IWorkflowRunRepository : IRepository Task> GetPendingAndRunningByDepartmentIdAsync(int departmentId); Task> GetRunsByWorkflowIdAsync(string workflowId, int page, int pageSize); Task> GetRunsByDepartmentInMinuteAsync(int departmentId); + Task DeleteAllByWorkflowIdAsync(string workflowId); } } diff --git a/Core/Resgrid.Model/Repositories/IWorkflowStepRepository.cs b/Core/Resgrid.Model/Repositories/IWorkflowStepRepository.cs index 8284441a..db7a8870 100644 --- a/Core/Resgrid.Model/Repositories/IWorkflowStepRepository.cs +++ b/Core/Resgrid.Model/Repositories/IWorkflowStepRepository.cs @@ -6,6 +6,7 @@ namespace Resgrid.Model.Repositories public interface IWorkflowStepRepository : IRepository { Task> GetAllByWorkflowIdAsync(string workflowId); + Task DeleteAllByWorkflowIdAsync(string workflowId); } } diff --git a/Core/Resgrid.Model/TwoFactor/TwoFactorEnforcementContext.cs b/Core/Resgrid.Model/TwoFactor/TwoFactorEnforcementContext.cs new file mode 100644 index 00000000..e754163f --- /dev/null +++ b/Core/Resgrid.Model/TwoFactor/TwoFactorEnforcementContext.cs @@ -0,0 +1,41 @@ +using System; + +namespace Resgrid.Model.TwoFactor +{ + /// + /// Input context for . + /// All values are plain data — no ASP.NET or Identity dependencies. + /// + public sealed record TwoFactorEnforcementContext( + /// Whether the user currently has 2FA enrolled and enabled. + bool UserHas2FaEnabled, + + /// + /// The department's configured enforcement scope. + /// + /// 0 — disabled, no department-driven enforcement. + /// 1 — department admins and the managing user. + /// 2 — department admins, the managing user, and group admins. + /// + /// + int DepartmentScope, + + /// Whether the user is a department admin or the managing user. + bool IsAdminOrManagingUser, + + /// Whether the user is a group admin (only relevant when == 2). + bool IsGroupAdmin, + + /// + /// The UTC timestamp at which the user last completed a successful step-up 2FA verification, + /// or if no proof exists in the current session. + /// + DateTime? LastStepUpVerifiedAtUtc, + + /// + /// The number of minutes a step-up proof remains valid. + /// Sourced from TwoFactorConfig.StepUpVerificationWindowMinutes. + /// + int StepUpWindowMinutes); +} + diff --git a/Core/Resgrid.Model/TwoFactor/TwoFactorEnforcementDecision.cs b/Core/Resgrid.Model/TwoFactor/TwoFactorEnforcementDecision.cs new file mode 100644 index 00000000..a92ba0a2 --- /dev/null +++ b/Core/Resgrid.Model/TwoFactor/TwoFactorEnforcementDecision.cs @@ -0,0 +1,33 @@ +namespace Resgrid.Model.TwoFactor +{ + /// + /// Represents the outcome of evaluating whether a 2FA step-up should be enforced for a + /// given user against a department's configured scope. + /// + public enum TwoFactorEnforcementOutcome + { + /// + /// The user is not subject to 2FA enforcement — pass through. + /// Applies when the user has no 2FA enrolled and the department scope does not cover them. + /// + NotRequired, + + /// + /// The user is in scope (department mandate or voluntary enrollment) and has 2FA enabled. + /// A valid step-up proof must be presented. + /// + StepUpRequired, + + /// + /// The department scope covers this user but they have not yet enrolled in 2FA. + /// Redirect them to the enrollment page. + /// + EnrollmentRequired, + } + + /// + /// The result of . + /// + public sealed record TwoFactorEnforcementDecision(TwoFactorEnforcementOutcome Outcome); +} + diff --git a/Core/Resgrid.Model/TwoFactor/TwoFactorEnforcementEvaluator.cs b/Core/Resgrid.Model/TwoFactor/TwoFactorEnforcementEvaluator.cs new file mode 100644 index 00000000..df79ea82 --- /dev/null +++ b/Core/Resgrid.Model/TwoFactor/TwoFactorEnforcementEvaluator.cs @@ -0,0 +1,73 @@ +using System; + +namespace Resgrid.Model.TwoFactor +{ + /// + /// Pure, stateless evaluator that decides what 2FA enforcement action is required for a user. + /// All inputs are plain values so the method is trivially unit-testable with no mocks. + /// + /// + /// Decision table: + /// + /// + /// ScopeUser has 2FAIn scopeStep-up validOutcome + /// + /// anynonoNotRequired + /// 1 or 2noyesEnrollmentRequired + /// anyyesyesNotRequired (pass through) + /// anyyesno / expiredStepUpRequired + /// + /// + public static class TwoFactorEnforcementEvaluator + { + /// + /// Evaluates the enforcement decision for the supplied context. + /// + /// All inputs required to make the decision. + /// + /// The current UTC time used to test step-up window expiry. + /// Pass in production; inject a fixed value in tests. + /// + public static TwoFactorEnforcementDecision Evaluate(TwoFactorEnforcementContext context, DateTime nowUtc) + { + bool departmentCoversUser = IsCoveredByDepartmentScope(context); + + // User has no 2FA and is not covered by the department mandate — nothing to enforce + if (!context.UserHas2FaEnabled && !departmentCoversUser) + return new TwoFactorEnforcementDecision(TwoFactorEnforcementOutcome.NotRequired); + + // User is covered by the department mandate but has not yet enrolled + if (!context.UserHas2FaEnabled && departmentCoversUser) + return new TwoFactorEnforcementDecision(TwoFactorEnforcementOutcome.EnrollmentRequired); + + // User has 2FA enrolled — check whether the step-up proof is still valid + if (context.LastStepUpVerifiedAtUtc.HasValue) + { + var windowExpiry = context.LastStepUpVerifiedAtUtc.Value.AddMinutes(context.StepUpWindowMinutes); + if (nowUtc <= windowExpiry) + return new TwoFactorEnforcementDecision(TwoFactorEnforcementOutcome.NotRequired); + } + + return new TwoFactorEnforcementDecision(TwoFactorEnforcementOutcome.StepUpRequired); + } + + // ── private helpers ────────────────────────────────────────────────────────── + + /// + /// Returns when the department scope mandates 2FA for this user. + /// + private static bool IsCoveredByDepartmentScope(TwoFactorEnforcementContext context) => + context.DepartmentScope switch + { + // Scope 1: department admins + managing user + 1 => context.IsAdminOrManagingUser, + + // Scope 2: department admins + managing user + group admins + 2 => context.IsAdminOrManagingUser || context.IsGroupAdmin, + + // Scope 0 or any unrecognised value: no department-driven enforcement + _ => false, + }; + } +} + diff --git a/Core/Resgrid.Services/DepartmentGroupsService.cs b/Core/Resgrid.Services/DepartmentGroupsService.cs index 27c5edc7..5d673d48 100644 --- a/Core/Resgrid.Services/DepartmentGroupsService.cs +++ b/Core/Resgrid.Services/DepartmentGroupsService.cs @@ -70,7 +70,17 @@ public async Task> GetAllAsync() } var saved = await _departmentGroupsRepository.SaveOrUpdateAsync(departmentGroup, cancellationToken); - await InvalidateGroupInCache(departmentGroup.DepartmentGroupId); + await InvalidateGroupInCache(saved.DepartmentGroupId); + + // Save members separately — Members is in IgnoredProperties so the ORM cascade skips it + if (departmentGroup.Members != null && departmentGroup.Members.Any()) + { + foreach (var member in departmentGroup.Members) + { + member.DepartmentGroupId = saved.DepartmentGroupId; + await _departmentGroupMembersRepository.SaveOrUpdateAsync(member, cancellationToken); + } + } return saved; } @@ -205,25 +215,34 @@ async Task getDepartmentGroup() public async Task UpdateAsync(DepartmentGroup departmentGroup, CancellationToken cancellationToken = default(CancellationToken)) { - var members = - await _departmentGroupMembersRepository.GetAllGroupMembersByGroupIdAsync(departmentGroup - .DepartmentGroupId); + // Materialize to List immediately to avoid multiple Dapper enumeration re-executions + var members = (await _departmentGroupMembersRepository.GetAllGroupMembersByGroupIdAsync(departmentGroup.DepartmentGroupId)).ToList(); if (departmentGroup.Members != null && departmentGroup.Members.Any()) { - var membersNoLongerInGroup = members.Where(p => !departmentGroup.Members.Any(p2 => p2.DepartmentGroupMemberId == p.DepartmentGroupMemberId)); + // Delete members no longer in the group — match by UserId, not DepartmentGroupMemberId + // (new members passed in from the controller have DepartmentGroupMemberId = 0) + var membersNoLongerInGroup = members.Where(p => !departmentGroup.Members.Any(p2 => p2.UserId == p.UserId)).ToList(); foreach (var departmentGroupMember in membersNoLongerInGroup) { await _departmentGroupMembersRepository.DeleteAsync(departmentGroupMember, cancellationToken); } - foreach (var newMember in departmentGroup.Members) + foreach (var updatedMember in departmentGroup.Members) { - if (!members.Any(x => x.UserId == newMember.UserId)) + var existingMember = members.FirstOrDefault(x => x.UserId == updatedMember.UserId); + if (existingMember == null) + { + // New member — insert + updatedMember.DepartmentGroupId = departmentGroup.DepartmentGroupId; + await _departmentGroupMembersRepository.SaveOrUpdateAsync(updatedMember, cancellationToken); + } + else if (existingMember.IsAdmin != updatedMember.IsAdmin) { - newMember.DepartmentGroupId = departmentGroup.DepartmentGroupId; - await _departmentGroupMembersRepository.SaveOrUpdateAsync(newMember, cancellationToken); + // Existing member whose admin flag changed — update + existingMember.IsAdmin = updatedMember.IsAdmin; + await _departmentGroupMembersRepository.SaveOrUpdateAsync(existingMember, cancellationToken); } } } diff --git a/Core/Resgrid.Services/WorkflowService.cs b/Core/Resgrid.Services/WorkflowService.cs index 41fe3f28..78079b88 100644 --- a/Core/Resgrid.Services/WorkflowService.cs +++ b/Core/Resgrid.Services/WorkflowService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -87,7 +87,17 @@ public async Task DeleteWorkflowAsync(string workflowId, CancellationToken { var workflow = await _workflowRepository.GetByIdAsync(workflowId); if (workflow == null) return false; + + // Delete child records in dependency order to avoid FK constraint violations: + // 1. WorkflowRunLogs (references WorkflowRuns) + await _runLogRepository.DeleteAllByWorkflowIdAsync(workflowId); + // 2. WorkflowRuns (references Workflows) + await _runRepository.DeleteAllByWorkflowIdAsync(workflowId); + // 3. WorkflowSteps (references Workflows) + await _stepRepository.DeleteAllByWorkflowIdAsync(workflowId); + // 4. Workflow itself await _workflowRepository.DeleteAsync(workflow, cancellationToken); + return true; } diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Workflows/DeleteWorkflowRunLogsByWorkflowIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Workflows/DeleteWorkflowRunLogsByWorkflowIdQuery.cs new file mode 100644 index 00000000..e1a72613 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Workflows/DeleteWorkflowRunLogsByWorkflowIdQuery.cs @@ -0,0 +1,25 @@ +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository.Queries.Workflows +{ + /// Deletes all run-log rows for every run belonging to a workflow. + public class DeleteWorkflowRunLogsByWorkflowIdQuery : IDeleteQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public DeleteWorkflowRunLogsByWorkflowIdQuery(SqlConfiguration sqlConfiguration) => _sqlConfiguration = sqlConfiguration; + + public string GetQuery() + { + if (DataConfig.DatabaseType == DatabaseTypes.Postgres) + return $"DELETE FROM {_sqlConfiguration.SchemaName}.workflowrunlogs WHERE workflowrunid IN (SELECT workflowrunid FROM {_sqlConfiguration.SchemaName}.workflowruns WHERE workflowid = {_sqlConfiguration.ParameterNotation}WorkflowId)"; + + return $"DELETE FROM {_sqlConfiguration.SchemaName}.[WorkflowRunLogs] WHERE [WorkflowRunId] IN (SELECT [WorkflowRunId] FROM {_sqlConfiguration.SchemaName}.[WorkflowRuns] WHERE [WorkflowId] = {_sqlConfiguration.ParameterNotation}WorkflowId)"; + } + + public string GetQuery(TEntity entity) where TEntity : class, IEntity => GetQuery(); + } +} + diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Workflows/DeleteWorkflowRunLogsByWorkflowRunIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Workflows/DeleteWorkflowRunLogsByWorkflowRunIdQuery.cs new file mode 100644 index 00000000..5f1913f4 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Workflows/DeleteWorkflowRunLogsByWorkflowRunIdQuery.cs @@ -0,0 +1,27 @@ +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository.Queries.Workflows +{ + /// Deletes all run-log rows for a single workflow run. + public class DeleteWorkflowRunLogsByWorkflowRunIdQuery : IDeleteQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public DeleteWorkflowRunLogsByWorkflowRunIdQuery(SqlConfiguration sqlConfiguration) => _sqlConfiguration = sqlConfiguration; + + public string GetQuery() + { + if (DataConfig.DatabaseType == DatabaseTypes.Postgres) + return $"DELETE FROM {_sqlConfiguration.SchemaName}.workflowrunlogs WHERE workflowrunid = {_sqlConfiguration.ParameterNotation}WorkflowRunId"; + + return $"DELETE FROM {_sqlConfiguration.SchemaName}.[WorkflowRunLogs] WHERE [WorkflowRunId] = {_sqlConfiguration.ParameterNotation}WorkflowRunId"; + } + + public string GetQuery(TEntity entity) where TEntity : class, IEntity => GetQuery(); + } +} + + + diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Workflows/DeleteWorkflowRunsByWorkflowIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Workflows/DeleteWorkflowRunsByWorkflowIdQuery.cs new file mode 100644 index 00000000..f82ef4b6 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Workflows/DeleteWorkflowRunsByWorkflowIdQuery.cs @@ -0,0 +1,24 @@ +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository.Queries.Workflows +{ + public class DeleteWorkflowRunsByWorkflowIdQuery : IDeleteQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public DeleteWorkflowRunsByWorkflowIdQuery(SqlConfiguration sqlConfiguration) => _sqlConfiguration = sqlConfiguration; + + public string GetQuery() + { + if (DataConfig.DatabaseType == DatabaseTypes.Postgres) + return $"DELETE FROM {_sqlConfiguration.SchemaName}.workflowruns WHERE workflowid = {_sqlConfiguration.ParameterNotation}WorkflowId"; + + return $"DELETE FROM {_sqlConfiguration.SchemaName}.[WorkflowRuns] WHERE [WorkflowId] = {_sqlConfiguration.ParameterNotation}WorkflowId"; + } + + public string GetQuery(TEntity entity) where TEntity : class, IEntity => GetQuery(); + } +} + diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Workflows/DeleteWorkflowStepsByWorkflowIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Workflows/DeleteWorkflowStepsByWorkflowIdQuery.cs new file mode 100644 index 00000000..46acfbd5 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Workflows/DeleteWorkflowStepsByWorkflowIdQuery.cs @@ -0,0 +1,24 @@ +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository.Queries.Workflows +{ + public class DeleteWorkflowStepsByWorkflowIdQuery : IDeleteQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public DeleteWorkflowStepsByWorkflowIdQuery(SqlConfiguration sqlConfiguration) => _sqlConfiguration = sqlConfiguration; + + public string GetQuery() + { + if (DataConfig.DatabaseType == DatabaseTypes.Postgres) + return $"DELETE FROM {_sqlConfiguration.SchemaName}.workflowsteps WHERE workflowid = {_sqlConfiguration.ParameterNotation}WorkflowId"; + + return $"DELETE FROM {_sqlConfiguration.SchemaName}.[WorkflowSteps] WHERE [WorkflowId] = {_sqlConfiguration.ParameterNotation}WorkflowId"; + } + + public string GetQuery(TEntity entity) where TEntity : class, IEntity => GetQuery(); + } +} + diff --git a/Repositories/Resgrid.Repositories.DataRepository/WorkflowRunLogRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/WorkflowRunLogRepository.cs index 851f7c82..71f97a6b 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/WorkflowRunLogRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/WorkflowRunLogRepository.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Data.Common; using System.Threading.Tasks; @@ -51,6 +51,52 @@ public async Task> GetByWorkflowRunIdAsync(string wo } catch (Exception ex) { Logging.LogException(ex); throw; } } + + public async Task DeleteAllByWorkflowRunIdAsync(string workflowRunId) + { + try + { + var deleteFunction = new Func(async x => + { + var dp = new DynamicParametersExtension(); + dp.Add("WorkflowRunId", workflowRunId); + var query = _queryFactory.GetDeleteQuery(); + await x.ExecuteAsync(sql: query, param: dp, transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) { await conn.OpenAsync(); await deleteFunction(conn); return; } + } + conn = _unitOfWork.CreateOrGetConnection(); + await deleteFunction(conn); + } + catch (Exception ex) { Logging.LogException(ex); throw; } + } + + public async Task DeleteAllByWorkflowIdAsync(string workflowId) + { + try + { + var deleteFunction = new Func(async x => + { + var dp = new DynamicParametersExtension(); + dp.Add("WorkflowId", workflowId); + var query = _queryFactory.GetDeleteQuery(); + await x.ExecuteAsync(sql: query, param: dp, transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) { await conn.OpenAsync(); await deleteFunction(conn); return; } + } + conn = _unitOfWork.CreateOrGetConnection(); + await deleteFunction(conn); + } + catch (Exception ex) { Logging.LogException(ex); throw; } + } } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/WorkflowRunRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/WorkflowRunRepository.cs index ef99120c..1a0ffe14 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/WorkflowRunRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/WorkflowRunRepository.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Data.Common; using System.Threading.Tasks; @@ -125,6 +125,29 @@ public async Task> GetRunsByDepartmentInMinuteAsync(int } catch (Exception ex) { Logging.LogException(ex); throw; } } + + public async Task DeleteAllByWorkflowIdAsync(string workflowId) + { + try + { + var deleteFunction = new Func(async x => + { + var dp = new DynamicParametersExtension(); + dp.Add("WorkflowId", workflowId); + var query = _queryFactory.GetDeleteQuery(); + await x.ExecuteAsync(sql: query, param: dp, transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) { await conn.OpenAsync(); await deleteFunction(conn); return; } + } + conn = _unitOfWork.CreateOrGetConnection(); + await deleteFunction(conn); + } + catch (Exception ex) { Logging.LogException(ex); throw; } + } } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/WorkflowStepRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/WorkflowStepRepository.cs index 6f275a82..11eca246 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/WorkflowStepRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/WorkflowStepRepository.cs @@ -55,6 +55,34 @@ public async Task> GetAllByWorkflowIdAsync(string work } catch (Exception ex) { Logging.LogException(ex); throw; } } + + public async Task DeleteAllByWorkflowIdAsync(string workflowId) + { + try + { + var deleteFunction = new Func(async x => + { + var dp = new DynamicParametersExtension(); + dp.Add("WorkflowId", workflowId); + var query = _queryFactory.GetDeleteQuery(); + await x.ExecuteAsync(sql: query, param: dp, transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + await deleteFunction(conn); + return; + } + } + conn = _unitOfWork.CreateOrGetConnection(); + await deleteFunction(conn); + } + catch (Exception ex) { Logging.LogException(ex); throw; } + } } } diff --git a/Tests/Resgrid.Tests/Web/TwoFactorEnforcementEvaluatorTests.cs b/Tests/Resgrid.Tests/Web/TwoFactorEnforcementEvaluatorTests.cs new file mode 100644 index 00000000..aa10b486 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/TwoFactorEnforcementEvaluatorTests.cs @@ -0,0 +1,327 @@ +using System; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model.TwoFactor; + +namespace Resgrid.Tests.Web +{ + /// + /// Unit tests for . + /// No mocks, no database, no HTTP — pure value-based assertions. + /// + namespace TwoFactorEnforcementEvaluatorTests + { + // ── shared fixtures ────────────────────────────────────────────────────────── + + /// Fixed "now" used across all tests so window calculations are deterministic. + internal static class Clock + { + internal static readonly DateTime Now = new DateTime(2026, 3, 5, 12, 0, 0, DateTimeKind.Utc); + } + + internal static class Contexts + { + internal const int WindowMinutes = 15; + + /// Returns a context with no 2FA and scope 0 — the baseline no-op case. + internal static TwoFactorEnforcementContext NoTwoFaNoScope( + bool isAdmin = false, bool isGroupAdmin = false) => + new TwoFactorEnforcementContext( + UserHas2FaEnabled: false, + DepartmentScope: 0, + IsAdminOrManagingUser: isAdmin, + IsGroupAdmin: isGroupAdmin, + LastStepUpVerifiedAtUtc: null, + StepUpWindowMinutes: WindowMinutes); + + internal static TwoFactorEnforcementContext Build( + bool userHas2Fa, + int scope, + bool isAdmin = false, + bool isGroupAdmin = false, + DateTime? lastVerified = null) => + new TwoFactorEnforcementContext( + UserHas2FaEnabled: userHas2Fa, + DepartmentScope: scope, + IsAdminOrManagingUser: isAdmin, + IsGroupAdmin: isGroupAdmin, + LastStepUpVerifiedAtUtc: lastVerified, + StepUpWindowMinutes: WindowMinutes); + } + + // ── Scope 0 ────────────────────────────────────────────────────────────────── + + [TestFixture] + public class when_scope_is_0 + { + [Test] + public void and_user_has_no_2fa_should_not_require_anything() + { + var ctx = Contexts.Build(userHas2Fa: false, scope: 0, isAdmin: true); + + var result = TwoFactorEnforcementEvaluator.Evaluate(ctx, Clock.Now); + + result.Outcome.Should().Be(TwoFactorEnforcementOutcome.NotRequired); + } + + [Test] + public void and_user_has_2fa_but_no_step_up_proof_should_require_step_up() + { + var ctx = Contexts.Build(userHas2Fa: true, scope: 0, lastVerified: null); + + var result = TwoFactorEnforcementEvaluator.Evaluate(ctx, Clock.Now); + + result.Outcome.Should().Be(TwoFactorEnforcementOutcome.StepUpRequired); + } + + [Test] + public void and_user_has_2fa_with_valid_step_up_proof_should_pass_through() + { + var recentVerification = Clock.Now.AddMinutes(-5); + var ctx = Contexts.Build(userHas2Fa: true, scope: 0, lastVerified: recentVerification); + + var result = TwoFactorEnforcementEvaluator.Evaluate(ctx, Clock.Now); + + result.Outcome.Should().Be(TwoFactorEnforcementOutcome.NotRequired); + } + + [Test] + public void and_user_has_2fa_with_expired_step_up_proof_should_require_step_up() + { + var expiredVerification = Clock.Now.AddMinutes(-(Contexts.WindowMinutes + 1)); + var ctx = Contexts.Build(userHas2Fa: true, scope: 0, lastVerified: expiredVerification); + + var result = TwoFactorEnforcementEvaluator.Evaluate(ctx, Clock.Now); + + result.Outcome.Should().Be(TwoFactorEnforcementOutcome.StepUpRequired); + } + } + + // ── Scope 1 ────────────────────────────────────────────────────────────────── + + [TestFixture] + public class when_scope_is_1 + { + [Test] + public void and_user_is_not_admin_and_has_no_2fa_should_not_require_anything() + { + var ctx = Contexts.Build(userHas2Fa: false, scope: 1, isAdmin: false, isGroupAdmin: false); + + var result = TwoFactorEnforcementEvaluator.Evaluate(ctx, Clock.Now); + + result.Outcome.Should().Be(TwoFactorEnforcementOutcome.NotRequired); + } + + [Test] + public void and_user_is_not_admin_but_has_2fa_should_require_step_up_when_no_proof() + { + // Voluntarily enrolled users always require step-up regardless of scope + var ctx = Contexts.Build(userHas2Fa: true, scope: 1, isAdmin: false, isGroupAdmin: false, lastVerified: null); + + var result = TwoFactorEnforcementEvaluator.Evaluate(ctx, Clock.Now); + + result.Outcome.Should().Be(TwoFactorEnforcementOutcome.StepUpRequired); + } + + [Test] + public void and_user_is_admin_with_no_2fa_should_require_enrollment() + { + var ctx = Contexts.Build(userHas2Fa: false, scope: 1, isAdmin: true); + + var result = TwoFactorEnforcementEvaluator.Evaluate(ctx, Clock.Now); + + result.Outcome.Should().Be(TwoFactorEnforcementOutcome.EnrollmentRequired); + } + + [Test] + public void and_user_is_managing_user_with_no_2fa_should_require_enrollment() + { + // IsAdminOrManagingUser covers the managing user — same flag + var ctx = Contexts.Build(userHas2Fa: false, scope: 1, isAdmin: true); + + var result = TwoFactorEnforcementEvaluator.Evaluate(ctx, Clock.Now); + + result.Outcome.Should().Be(TwoFactorEnforcementOutcome.EnrollmentRequired); + } + + [Test] + public void and_user_is_admin_with_2fa_but_no_proof_should_require_step_up() + { + var ctx = Contexts.Build(userHas2Fa: true, scope: 1, isAdmin: true, lastVerified: null); + + var result = TwoFactorEnforcementEvaluator.Evaluate(ctx, Clock.Now); + + result.Outcome.Should().Be(TwoFactorEnforcementOutcome.StepUpRequired); + } + + [Test] + public void and_user_is_admin_with_2fa_and_valid_proof_should_pass_through() + { + var recentVerification = Clock.Now.AddMinutes(-10); + var ctx = Contexts.Build(userHas2Fa: true, scope: 1, isAdmin: true, lastVerified: recentVerification); + + var result = TwoFactorEnforcementEvaluator.Evaluate(ctx, Clock.Now); + + result.Outcome.Should().Be(TwoFactorEnforcementOutcome.NotRequired); + } + + [Test] + public void and_user_is_admin_with_2fa_and_proof_exactly_at_boundary_should_pass_through() + { + // Proof timestamp exactly at the edge of the window must still be accepted + var boundaryVerification = Clock.Now.AddMinutes(-Contexts.WindowMinutes); + var ctx = Contexts.Build(userHas2Fa: true, scope: 1, isAdmin: true, lastVerified: boundaryVerification); + + var result = TwoFactorEnforcementEvaluator.Evaluate(ctx, Clock.Now); + + result.Outcome.Should().Be(TwoFactorEnforcementOutcome.NotRequired); + } + + [Test] + public void and_user_is_admin_with_2fa_and_expired_proof_should_require_step_up() + { + var expiredVerification = Clock.Now.AddMinutes(-(Contexts.WindowMinutes + 1)); + var ctx = Contexts.Build(userHas2Fa: true, scope: 1, isAdmin: true, lastVerified: expiredVerification); + + var result = TwoFactorEnforcementEvaluator.Evaluate(ctx, Clock.Now); + + result.Outcome.Should().Be(TwoFactorEnforcementOutcome.StepUpRequired); + } + + [Test] + public void and_user_is_only_group_admin_with_no_2fa_should_not_require_anything() + { + // Scope 1 does not cover group admins + var ctx = Contexts.Build(userHas2Fa: false, scope: 1, isAdmin: false, isGroupAdmin: true); + + var result = TwoFactorEnforcementEvaluator.Evaluate(ctx, Clock.Now); + + result.Outcome.Should().Be(TwoFactorEnforcementOutcome.NotRequired); + } + } + + // ── Scope 2 ────────────────────────────────────────────────────────────────── + + [TestFixture] + public class when_scope_is_2 + { + [Test] + public void and_user_is_not_in_any_admin_role_and_has_no_2fa_should_not_require_anything() + { + var ctx = Contexts.Build(userHas2Fa: false, scope: 2, isAdmin: false, isGroupAdmin: false); + + var result = TwoFactorEnforcementEvaluator.Evaluate(ctx, Clock.Now); + + result.Outcome.Should().Be(TwoFactorEnforcementOutcome.NotRequired); + } + + [Test] + public void and_user_is_dept_admin_with_no_2fa_should_require_enrollment() + { + var ctx = Contexts.Build(userHas2Fa: false, scope: 2, isAdmin: true); + + var result = TwoFactorEnforcementEvaluator.Evaluate(ctx, Clock.Now); + + result.Outcome.Should().Be(TwoFactorEnforcementOutcome.EnrollmentRequired); + } + + [Test] + public void and_user_is_group_admin_with_no_2fa_should_require_enrollment() + { + var ctx = Contexts.Build(userHas2Fa: false, scope: 2, isAdmin: false, isGroupAdmin: true); + + var result = TwoFactorEnforcementEvaluator.Evaluate(ctx, Clock.Now); + + result.Outcome.Should().Be(TwoFactorEnforcementOutcome.EnrollmentRequired); + } + + [Test] + public void and_user_is_group_admin_with_2fa_but_no_proof_should_require_step_up() + { + var ctx = Contexts.Build(userHas2Fa: true, scope: 2, isAdmin: false, isGroupAdmin: true, lastVerified: null); + + var result = TwoFactorEnforcementEvaluator.Evaluate(ctx, Clock.Now); + + result.Outcome.Should().Be(TwoFactorEnforcementOutcome.StepUpRequired); + } + + [Test] + public void and_user_is_group_admin_with_2fa_and_valid_proof_should_pass_through() + { + var recentVerification = Clock.Now.AddMinutes(-7); + var ctx = Contexts.Build(userHas2Fa: true, scope: 2, isAdmin: false, isGroupAdmin: true, lastVerified: recentVerification); + + var result = TwoFactorEnforcementEvaluator.Evaluate(ctx, Clock.Now); + + result.Outcome.Should().Be(TwoFactorEnforcementOutcome.NotRequired); + } + + [Test] + public void and_user_is_group_admin_with_2fa_and_expired_proof_should_require_step_up() + { + var expiredVerification = Clock.Now.AddMinutes(-(Contexts.WindowMinutes + 1)); + var ctx = Contexts.Build(userHas2Fa: true, scope: 2, isAdmin: false, isGroupAdmin: true, lastVerified: expiredVerification); + + var result = TwoFactorEnforcementEvaluator.Evaluate(ctx, Clock.Now); + + result.Outcome.Should().Be(TwoFactorEnforcementOutcome.StepUpRequired); + } + + [Test] + public void and_user_is_dept_admin_with_2fa_and_valid_proof_should_pass_through() + { + var recentVerification = Clock.Now.AddMinutes(-1); + var ctx = Contexts.Build(userHas2Fa: true, scope: 2, isAdmin: true, lastVerified: recentVerification); + + var result = TwoFactorEnforcementEvaluator.Evaluate(ctx, Clock.Now); + + result.Outcome.Should().Be(TwoFactorEnforcementOutcome.NotRequired); + } + + [Test] + public void and_user_is_group_admin_only_would_not_be_covered_by_scope_1() + { + // Confirm scope 1 does NOT cover group admins (cross-scope boundary test) + var ctx = Contexts.Build(userHas2Fa: false, scope: 1, isAdmin: false, isGroupAdmin: true); + + var result = TwoFactorEnforcementEvaluator.Evaluate(ctx, Clock.Now); + + result.Outcome.Should().Be(TwoFactorEnforcementOutcome.NotRequired, + because: "scope 1 only covers dept admins and managing user, not group admins"); + } + } + + // ── Unrecognised scope ──────────────────────────────────────────────────────── + + [TestFixture] + public class when_scope_is_unrecognised + { + [TestCase(3)] + [TestCase(99)] + [TestCase(-1)] + public void and_user_has_no_2fa_should_not_require_anything(int scope) + { + var ctx = Contexts.Build(userHas2Fa: false, scope: scope, isAdmin: true, isGroupAdmin: true); + + var result = TwoFactorEnforcementEvaluator.Evaluate(ctx, Clock.Now); + + result.Outcome.Should().Be(TwoFactorEnforcementOutcome.NotRequired, + because: "unrecognised scope values should not mandate enrollment"); + } + + [TestCase(3)] + [TestCase(99)] + public void and_user_has_2fa_should_still_require_step_up(int scope) + { + // Voluntarily enrolled users always require step-up regardless of scope + var ctx = Contexts.Build(userHas2Fa: true, scope: scope, lastVerified: null); + + var result = TwoFactorEnforcementEvaluator.Evaluate(ctx, Clock.Now); + + result.Outcome.Should().Be(TwoFactorEnforcementOutcome.StepUpRequired, + because: "a user who opted into 2FA must always present a step-up proof"); + } + } + } +} + diff --git a/Web/Resgrid.Web.Services/Helpers/ClaimsAuthorizationHelper.cs b/Web/Resgrid.Web.Services/Helpers/ClaimsAuthorizationHelper.cs index b69c9866..fb2db5b6 100644 --- a/Web/Resgrid.Web.Services/Helpers/ClaimsAuthorizationHelper.cs +++ b/Web/Resgrid.Web.Services/Helpers/ClaimsAuthorizationHelper.cs @@ -169,6 +169,11 @@ public static bool CanCreateLog() return GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Log, ResgridClaimTypes.Actions.Create); } + public static bool CanDeleteLog() + { + return GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Log, ResgridClaimTypes.Actions.Delete); + } + public static bool CanCreateShift() { return GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Shift, ResgridClaimTypes.Actions.Create); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs index be5e1154..516662ef 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs @@ -1663,7 +1663,7 @@ public async Task GetAllDispatchesForCall(int callId) foreach (var roleDispatch in call.RoleDispatches) { CallDispatchJson dispatch = new CallDispatchJson(); - dispatch.DisptachCode = $"#dispatchRole_{roleDispatch.CallDispatchRoleId}"; + dispatch.DisptachCode = $"#dispatchRole_{roleDispatch.RoleId}"; dispatchJson.Add(dispatch); } @@ -1836,6 +1836,7 @@ public async Task GetActiveCallsList() callJson.State = _callsService.CallStateToString((CallStates)call.State); callJson.StateColor = _callsService.CallStateToColor((CallStates)call.State); callJson.Timestamp = call.LoggedOn.TimeConverterToString(department); + callJson.LoggedOn = new DateTimeOffset(DateTime.SpecifyKind(call.LoggedOn, DateTimeKind.Utc)).ToUnixTimeSeconds(); callJson.Priority = await _callsService.CallPriorityToStringAsync(call.Priority, DepartmentId); callJson.Color = await _callsService.CallPriorityToColorAsync(call.Priority, DepartmentId); callJson.CanDeleteCall = await _authorizationService.CanUserDeleteCallAsync(UserId, call.CallId, DepartmentId); @@ -2186,6 +2187,14 @@ private async Task FillUpdateCallView(UpdateCallView model) types.AddRange(await _callsService.GetCallTypesForDepartmentAsync(DepartmentId)); model.CallTypes = new SelectList(types, "Type", "Type"); + var templates = await _templatesService.GetAllCallQuickTemplatesForDepartmentAsync(DepartmentId); + if (templates != null) + model.CallTemplates = new SelectList(templates, "CallQuickTemplateId", "Name"); + + var form = await _formsService.GetNewCallFormByDepartmentIdAsync(DepartmentId); + if (form != null) + model.NewCallFormData = form.Data; + var allUsers = await _departmentsService.GetAllUsersForDepartmentAsync(model.Department.DepartmentId); List groupedUserIds = new List(); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DistributionListsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DistributionListsController.cs index eaf50c31..eea9d3af 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DistributionListsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DistributionListsController.cs @@ -12,6 +12,7 @@ using Resgrid.Providers.Claims; using Resgrid.Web.Areas.User.Models.DistributionLists; using Resgrid.Web.Areas.User.Models.Personnel; +using Resgrid.Web.Helpers; using Microsoft.AspNetCore.Authorization; namespace Resgrid.Web.Areas.User.Controllers @@ -231,11 +232,13 @@ public async Task GetMembersForList(int id) Unauthorized(); var members = await _distributionListsService.GetAllListMembersByListIdAsync(id); + var personnelNames = await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(DepartmentId); foreach (var member in members) { var person = new PersonnelForJson(); person.UserId = member.UserId; + person.Name = await UserHelper.GetFullNameForUser(personnelNames, null, member.UserId); personnelJson.Add(person); } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/GroupsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/GroupsController.cs index 8bc494a0..98bece7e 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/GroupsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/GroupsController.cs @@ -115,13 +115,21 @@ public async Task NewGroup(NewGroupView model, IFormCollection co allUsers.AddRange(groupAdmins); allUsers.AddRange(groupUsers); - foreach (var groupUser in allUsers) + // Check for users appearing in both admin and member lists + var duplicateUsers = groupAdmins.Intersect(groupUsers).ToList(); + foreach (var duplicateUser in duplicateUsers) + { + var profile = await _userProfileService.GetProfileByUserIdAsync(duplicateUser); + ModelState.AddModelError("", string.Format("{0} cannot be both a Group Admin and a Group Member. Please add them to only one list.", profile.FullName.AsFirstNameLastName)); + } + + foreach (var groupUser in allUsers.Distinct()) { if (await _departmentGroupsService.IsUserInAGroupAsync(groupUser, DepartmentId)) { var profile = await _userProfileService.GetProfileByUserIdAsync(groupUser); - ModelState.AddModelError("", string.Format("{0} Is already in a group. Cannot add to another.", profile.FullName.AsFirstNameLastName)); + ModelState.AddModelError("", string.Format("{0} is already in a group. Cannot add to another.", profile.FullName.AsFirstNameLastName)); } } @@ -450,6 +458,24 @@ public async Task EditGroup(EditGroupView model, IFormCollection allUsers.AddRange(groupAdmins); allUsers.AddRange(groupUsers); + // Check for users appearing in both admin and member lists + var duplicateUsers = groupAdmins.Intersect(groupUsers).ToList(); + foreach (var duplicateUser in duplicateUsers) + { + var profile = await _userProfileService.GetProfileByUserIdAsync(duplicateUser); + ModelState.AddModelError("", string.Format("{0} cannot be both a Group Admin and a Group Member. Please add them to only one list.", profile.FullName.AsFirstNameLastName)); + } + + // Check newly added users are not in a *different* group — exclude the group being edited + foreach (var groupUser in allUsers.Distinct()) + { + if (await _departmentGroupsService.IsUserInAGroupAsync(groupUser, model.EditGroup.DepartmentGroupId, DepartmentId)) + { + var profile = await _userProfileService.GetProfileByUserIdAsync(groupUser); + ModelState.AddModelError("", string.Format("{0} is already in another group. Cannot add to this group.", profile.FullName.AsFirstNameLastName)); + } + } + if (model.EditGroup.Type.HasValue && model.EditGroup.Type.Value == (int)DepartmentGroupTypes.Station) { if (String.IsNullOrWhiteSpace(model.What3Word)) @@ -491,35 +517,15 @@ public async Task EditGroup(EditGroupView model, IFormCollection { model.EditGroup.DepartmentId = DepartmentId; - foreach (var user in allUsers) + // Build desired member list from submitted values — UpdateAsync handles insert/delete/update against the DB + var desiredMembers = allUsers.Distinct().Select(user => new DepartmentGroupMember { - if (group.Members.All(x => x.UserId != user)) - { - var dgm = new DepartmentGroupMember(); - dgm.DepartmentId = DepartmentId; - dgm.UserId = user; - - if (groupAdmins.Contains(user)) - dgm.IsAdmin = true; - else - dgm.IsAdmin = false; + DepartmentId = DepartmentId, + UserId = user, + IsAdmin = groupAdmins.Contains(user) + }).ToList(); - group.Members.Add(dgm); - } - } - - if (allUsers.Count > 0) - { - var usersToRemove = group.Members.Where(x => !allUsers.Contains(x.UserId)).ToList(); - foreach (var user in usersToRemove) - { - group.Members.Remove(user); - } - } - else - { - group.Members.Clear(); - } + group.Members = desiredMembers; if (model.EditGroup.Type.HasValue && model.EditGroup.Type.Value == (int)DepartmentGroupTypes.Station) { @@ -628,7 +634,7 @@ public async Task SaveGeofence([FromBody]SaveGeofenceModel model, [HttpGet] [Authorize(Policy = ResgridResources.GenericGroup_View)] - + public async Task GetMembersForGroup(int groupId, bool includeAdmins = true, bool includeNormal = true) { var groupsJson = new List(); @@ -676,7 +682,7 @@ public async Task GetAllGroups() [HttpGet] [Authorize(Policy = ResgridResources.GenericGroup_View)] - + public async Task GetGroupsForCallGrid() { List groupsJson = new List(); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/LogsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/LogsController.cs index e4c806d8..acc3a6f6 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/LogsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/LogsController.cs @@ -38,10 +38,12 @@ public class LogsController : SecureBaseController private readonly Model.Services.IAuthorizationService _authorizationService; private readonly IWorkLogsService _workLogsService; private readonly IEventAggregator _eventAggregator; + private readonly IUnitsService _unitsService; public LogsController(IDepartmentsService departmentsService, IUsersService usersService, ICallsService callsService, IDepartmentGroupsService departmentGroupsService, ICommunicationService communicationService, IQueueService queueService, - Model.Services.IAuthorizationService authorizationService, IWorkLogsService workLogsService, IEventAggregator eventAggregator) + Model.Services.IAuthorizationService authorizationService, IWorkLogsService workLogsService, IEventAggregator eventAggregator, + IUnitsService unitsService) { _departmentsService = departmentsService; _usersService = usersService; @@ -52,6 +54,7 @@ public LogsController(IDepartmentsService departmentsService, IUsersService user _authorizationService = authorizationService; _workLogsService = workLogsService; _eventAggregator = eventAggregator; + _unitsService = unitsService; } #endregion Private Members and Constructors @@ -366,7 +369,9 @@ public async Task GetLogsList(string year) logJson.LoggedBy = await UserHelper.GetFullNameForUser(log.LoggedByUserId); logJson.LoggedOn = log.LoggedOn.TimeConverterToString(department); - if (ClaimsAuthorizationHelper.IsUserDepartmentAdmin() || log.LoggedByUserId == UserId || (log.StationGroupId.HasValue && ClaimsAuthorizationHelper.IsUserGroupAdmin(log.StationGroupId.Value))) + if (ClaimsAuthorizationHelper.CanDeleteLog() && + (ClaimsAuthorizationHelper.IsUserDepartmentAdmin() || log.LoggedByUserId == UserId || + (log.StationGroupId.HasValue && ClaimsAuthorizationHelper.IsUserGroupAdmin(log.StationGroupId.Value)))) logJson.CanDelete = true; else logJson.CanDelete = false; @@ -389,6 +394,79 @@ public async Task DeleteWorkLog(int logId, CancellationToken canc return RedirectToAction("Index"); } + [HttpGet] + [Authorize(Policy = ResgridResources.Log_View)] + public async Task View(int logId) + { + var model = new ViewLogsView(); + model.WorkLog = await _workLogsService.GetWorkLogByIdAsync(logId); + + if (model.WorkLog == null) + return RedirectToAction("Index"); + + model.Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); + model.Attachments = await _workLogsService.GetAttachmentsForLogAsync(logId); + model.Groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(DepartmentId); + model.Units = await _unitsService.GetUnitsForDepartmentAsync(DepartmentId); + + if (model.WorkLog.Users != null) + { + foreach (var logUser in model.WorkLog.Users) + { + if (!model.PersonnelNames.ContainsKey(logUser.UserId)) + model.PersonnelNames[logUser.UserId] = await UserHelper.GetFullNameForUser(logUser.UserId); + } + } + + if (!String.IsNullOrWhiteSpace(model.WorkLog.LoggedByUserId) && !model.PersonnelNames.ContainsKey(model.WorkLog.LoggedByUserId)) + model.PersonnelNames[model.WorkLog.LoggedByUserId] = await UserHelper.GetFullNameForUser(model.WorkLog.LoggedByUserId); + + if (!String.IsNullOrWhiteSpace(model.WorkLog.InvestigatedByUserId) && !model.PersonnelNames.ContainsKey(model.WorkLog.InvestigatedByUserId)) + model.PersonnelNames[model.WorkLog.InvestigatedByUserId] = await UserHelper.GetFullNameForUser(model.WorkLog.InvestigatedByUserId); + + if (ClaimsAuthorizationHelper.CanDeleteLog()) + { + if (ClaimsAuthorizationHelper.IsUserDepartmentAdmin() || model.WorkLog.LoggedByUserId == UserId || + (model.WorkLog.StationGroupId.HasValue && ClaimsAuthorizationHelper.IsUserGroupAdmin(model.WorkLog.StationGroupId.Value))) + model.CanDelete = true; + } + + return View("ViewLog", model); + } + + [HttpGet] + [Authorize(Policy = ResgridResources.Log_View)] + public async Task LogExport(int logId) + { + var model = new LogExportView(); + model.WorkLog = await _workLogsService.GetWorkLogByIdAsync(logId); + + if (model.WorkLog == null) + return RedirectToAction("Index"); + + model.Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); + model.Attachments = await _workLogsService.GetAttachmentsForLogAsync(logId); + model.Groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(DepartmentId); + model.Units = await _unitsService.GetUnitsForDepartmentAsync(DepartmentId); + + if (model.WorkLog.Users != null) + { + foreach (var logUser in model.WorkLog.Users) + { + if (!model.PersonnelNames.ContainsKey(logUser.UserId)) + model.PersonnelNames[logUser.UserId] = await UserHelper.GetFullNameForUser(logUser.UserId); + } + } + + if (!String.IsNullOrWhiteSpace(model.WorkLog.LoggedByUserId) && !model.PersonnelNames.ContainsKey(model.WorkLog.LoggedByUserId)) + model.PersonnelNames[model.WorkLog.LoggedByUserId] = await UserHelper.GetFullNameForUser(model.WorkLog.LoggedByUserId); + + if (!String.IsNullOrWhiteSpace(model.WorkLog.InvestigatedByUserId) && !model.PersonnelNames.ContainsKey(model.WorkLog.InvestigatedByUserId)) + model.PersonnelNames[model.WorkLog.InvestigatedByUserId] = await UserHelper.GetFullNameForUser(model.WorkLog.InvestigatedByUserId); + + return View(model); + } + [HttpGet] [Authorize(Policy = ResgridResources.Log_View)] public async Task TrainingPerMonth() diff --git a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs index 918534cf..459b5b18 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs @@ -16,6 +16,7 @@ using Resgrid.Providers.Claims; using Resgrid.Web.Areas.User.Models; using Resgrid.Web.Areas.User.Models.Personnel; +using Resgrid.Web.Areas.User.Models.Reports.Personnel; using Resgrid.Web.Helpers; using Microsoft.AspNetCore.Authorization; using System.Threading.Tasks; @@ -52,12 +53,14 @@ public class PersonnelController : SecureBaseController private readonly UserManager _userManager; private readonly IDepartmentSettingsService _departmentSettingsService; private readonly ICallsService _callsService; + private readonly IGeoLocationProvider _geoLocationProvider; public PersonnelController(IDepartmentsService departmentsService, IUsersService usersService, IActionLogsService actionLogsService, IEmailService emailService, IUserProfileService userProfileService, IDeleteService deleteService, Model.Services.IAuthorizationService authorizationService, ILimitsService limitsService, IPersonnelRolesService personnelRolesService, IDepartmentGroupsService departmentGroupsService, IUserStateService userStateService, IEventAggregator eventAggregator, IEmailMarketingProvider emailMarketingProvider, ICertificationService certificationService, ICustomStateService customStateService, - IGeoService geoService, UserManager userManager, IDepartmentSettingsService departmentSettingsService, ICallsService callsService) + IGeoService geoService, UserManager userManager, IDepartmentSettingsService departmentSettingsService, ICallsService callsService, + IGeoLocationProvider geoLocationProvider) { _departmentsService = departmentsService; _usersService = usersService; @@ -78,6 +81,7 @@ public PersonnelController(IDepartmentsService departmentsService, IUsersService _userManager = userManager; _departmentSettingsService = departmentSettingsService; _callsService = callsService; + _geoLocationProvider = geoLocationProvider; } #endregion Private Members and Constructors @@ -1913,5 +1917,228 @@ public async Task GetRolesForUser(string userId) return Json(rolesJson); } #endregion Roles + + #region Personnel Events + + [HttpGet] + [Authorize(Policy = ResgridResources.Personnel_View)] + public async Task ViewEvents(string userId) + { + if (!await _authorizationService.CanUserViewUserAsync(UserId, userId)) + Unauthorized(); + + var model = new ViewPersonEventsView(); + model.UserId = userId; + + var profile = await _userProfileService.GetProfileByUserIdAsync(userId); + model.PersonName = profile != null ? profile.FullName.AsFirstNameLastName : userId; + + model.OSMKey = Config.MappingConfig.OSMKey; + + var address = await _departmentSettingsService.GetBigBoardCenterAddressDepartmentAsync(DepartmentId); + var gpsCoordinates = await _departmentSettingsService.GetBigBoardCenterGpsCoordinatesDepartmentAsync(DepartmentId); + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); + + double? centerLat = null; + double? centerLon = null; + + if (!String.IsNullOrWhiteSpace(gpsCoordinates)) + { + var coordinates = gpsCoordinates.Split(char.Parse(",")); + if (coordinates.Length == 2 && + double.TryParse(coordinates[0], out double parsedLat) && + double.TryParse(coordinates[1], out double parsedLon)) + { + centerLat = parsedLat; + centerLon = parsedLon; + } + } + + if (!centerLat.HasValue && !centerLon.HasValue && address != null) + { + string coordinates = await _geoLocationProvider.GetLatLonFromAddress(string.Format("{0} {1} {2} {3}", + address.Address1, address.City, address.State, address.PostalCode)); + + if (!String.IsNullOrEmpty(coordinates)) + { + var coordinatesArr = coordinates.Split(char.Parse(",")); + if (double.TryParse(coordinatesArr[0], out double parsedLat) && + double.TryParse(coordinatesArr[1], out double parsedLon)) + { + centerLat = parsedLat; + centerLon = parsedLon; + } + } + } + + if (!centerLat.HasValue && !centerLon.HasValue && department.Address != null) + { + string coordinates = await _geoLocationProvider.GetLatLonFromAddress(string.Format("{0} {1} {2} {3}", + department.Address.Address1, department.Address.City, + department.Address.State, department.Address.PostalCode)); + + if (!String.IsNullOrEmpty(coordinates)) + { + var coordinatesArr = coordinates.Split(char.Parse(",")); + if (double.TryParse(coordinatesArr[0], out double parsedLat) && + double.TryParse(coordinatesArr[1], out double parsedLon)) + { + centerLat = parsedLat; + centerLon = parsedLon; + } + } + } + + if (!centerLat.HasValue || !centerLon.HasValue) + { + centerLat = 39.14086268299356; + centerLon = -119.7583809782715; + } + + model.CenterLat = centerLat.Value; + model.CenterLon = centerLon.Value; + + return View(model); + } + + [HttpPost] + [Authorize(Policy = ResgridResources.Personnel_Delete)] + public async Task ClearAllPersonnelEvents(ViewPersonEventsView model, CancellationToken cancellationToken) + { + if (model.ConfirmClearAll) + await _actionLogsService.DeleteActionLogsForUserAsync(model.UserId, cancellationToken); + + return RedirectToAction("ViewEvents", new { userId = model.UserId }); + } + + [HttpPost] + [Authorize(Policy = ResgridResources.Personnel_View)] + public async Task GeneratePersonnelEventsReport(IFormCollection form) + { + var eventIds = new List(); + foreach (var key in form.Keys) + { + if (key.ToString().StartsWith("selectEvent_")) + { + var eventId = int.Parse(key.ToString().Replace("selectEvent_", "")); + eventIds.Add(eventId); + } + } + + var model = new PersonnelEventsReportView(); + model.Rows = new List(); + model.Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); + model.RunOn = DateTime.UtcNow.TimeConverter(model.Department); + + foreach (var eventId in eventIds) + { + var actionLog = await _actionLogsService.GetActionLogByIdAsync(eventId); + if (actionLog == null) + continue; + + var personnelEvent = new PersonnelEventJson(); + personnelEvent.EventId = actionLog.ActionLogId; + personnelEvent.UserId = actionLog.UserId; + + var profile = await _userProfileService.GetProfileByUserIdAsync(actionLog.UserId); + personnelEvent.PersonName = profile != null ? profile.FullName.AsFirstNameLastName : actionLog.UserId; + + var statusDetail = await CustomStatesHelper.GetCustomPersonnelStatus(DepartmentId, actionLog); + personnelEvent.State = statusDetail?.ButtonText; + personnelEvent.Timestamp = actionLog.Timestamp.TimeConverterToString(model.Department); + personnelEvent.Note = actionLog.Note; + + if (actionLog.DestinationId.HasValue && actionLog.DestinationType.HasValue) + { + if (actionLog.DestinationType.Value == 1) + { + var station = await _departmentGroupsService.GetGroupByIdAsync(actionLog.DestinationId.Value, false); + personnelEvent.DestinationName = station?.Name ?? "Station Not Found"; + } + else if (actionLog.DestinationType.Value == 2) + { + var call = await _callsService.GetCallByIdAsync(actionLog.DestinationId.Value, false); + personnelEvent.DestinationName = call?.Name ?? "Call Not Found"; + } + } + + var coordinates = actionLog.GetCoordinates(); + if (coordinates != null) + { + if (coordinates.Latitude.HasValue) + personnelEvent.Latitude = coordinates.Latitude.Value.ToString(System.Globalization.CultureInfo.InvariantCulture); + + if (coordinates.Longitude.HasValue) + personnelEvent.Longitude = coordinates.Longitude.Value.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + + model.Rows.Add(personnelEvent); + } + + return View("~/Areas/User/Views/Reports/PersonnelEventsReport.cshtml", model); + } + + [HttpGet] + [Authorize(Policy = ResgridResources.Personnel_View)] + public async Task GetPersonnelEvents(string userId) + { + if (!await _authorizationService.CanUserViewUserAsync(UserId, userId)) + Unauthorized(); + + var personnelEvents = new List(); + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); + var profile = await _userProfileService.GetProfileByUserIdAsync(userId); + var personName = profile != null ? profile.FullName.AsFirstNameLastName : userId; + var events = await _actionLogsService.GetAllActionLogsForUser(userId); + + foreach (var actionLog in events) + { + var personnelEvent = new PersonnelEventJson(); + personnelEvent.EventId = actionLog.ActionLogId; + personnelEvent.UserId = actionLog.UserId; + personnelEvent.PersonName = personName; + + var statusDetail = await CustomStatesHelper.GetCustomPersonnelStatus(DepartmentId, actionLog); + personnelEvent.State = statusDetail?.ButtonText; + personnelEvent.Timestamp = actionLog.Timestamp.TimeConverterToString(department); + personnelEvent.Note = actionLog.Note; + + if (actionLog.DestinationId.HasValue && actionLog.DestinationType.HasValue) + { + if (actionLog.DestinationType.Value == 1) // Station / Group + { + var station = await _departmentGroupsService.GetGroupByIdAsync(actionLog.DestinationId.Value, false); + if (station != null) + personnelEvent.DestinationName = station.Name; + else + personnelEvent.DestinationName = "Station Not Found"; + } + else if (actionLog.DestinationType.Value == 2) // Call + { + var call = await _callsService.GetCallByIdAsync(actionLog.DestinationId.Value, false); + if (call != null) + personnelEvent.DestinationName = call.Name; + else + personnelEvent.DestinationName = "Call Not Found"; + } + } + + var coordinates = actionLog.GetCoordinates(); + if (coordinates != null) + { + if (coordinates.Latitude.HasValue) + personnelEvent.Latitude = coordinates.Latitude.Value.ToString(System.Globalization.CultureInfo.InvariantCulture); + + if (coordinates.Longitude.HasValue) + personnelEvent.Longitude = coordinates.Longitude.Value.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + + personnelEvents.Add(personnelEvent); + } + + return Json(personnelEvents); + } + + #endregion Personnel Events } } diff --git a/Web/Resgrid.Web/Areas/User/Models/Calls/UpdateCallView.cs b/Web/Resgrid.Web/Areas/User/Models/Calls/UpdateCallView.cs index 115e1cfb..68269666 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Calls/UpdateCallView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Calls/UpdateCallView.cs @@ -30,5 +30,8 @@ public class UpdateCallView: BaseUserModel public SelectList ContactsList { get; set; } public string PrimaryContact { get; set; } public List AdditionalContacts { get; set; } + public SelectList CallTemplates { get; set; } + public int CallTemplateId { get; set; } + public string NewCallFormData { get; set; } } } diff --git a/Web/Resgrid.Web/Areas/User/Models/Dispatch/CallListJson.cs b/Web/Resgrid.Web/Areas/User/Models/Dispatch/CallListJson.cs index 57bdff58..36c22415 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Dispatch/CallListJson.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Dispatch/CallListJson.cs @@ -17,6 +17,7 @@ public class CallListJson public string StateColor { get; set; } public string Address { get; set; } public string Timestamp { get; set; } + public long LoggedOn { get; set; } public bool CanDeleteCall { get; set; } public bool CanCloseCall { get; set; } public bool CanUpdateCall { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Models/Logs/LogExportView.cs b/Web/Resgrid.Web/Areas/User/Models/Logs/LogExportView.cs new file mode 100644 index 00000000..ae9c339c --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/Logs/LogExportView.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using Resgrid.Model; +using Resgrid.Model.Identity; + +namespace Resgrid.Web.Areas.User.Models.Logs +{ + public class LogExportView : BaseUserModel + { + public Department Department { get; set; } + public IdentityUser User { get; set; } + public Log WorkLog { get; set; } + public List Attachments { get; set; } + public List Groups { get; set; } + public List Units { get; set; } + public Dictionary PersonnelNames { get; set; } + + public LogExportView() + { + Attachments = new List(); + Groups = new List(); + Units = new List(); + PersonnelNames = new Dictionary(); + } + } +} + diff --git a/Web/Resgrid.Web/Areas/User/Models/Logs/ViewLogsView.cs b/Web/Resgrid.Web/Areas/User/Models/Logs/ViewLogsView.cs index 4a6fb693..28647208 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Logs/ViewLogsView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Logs/ViewLogsView.cs @@ -11,10 +11,17 @@ public class ViewLogsView : BaseUserModel public CallLog CallLog { get; set; } public Log WorkLog { get; set; } public List Attachments { get; set; } + public List Groups { get; set; } + public List Units { get; set; } + public Dictionary PersonnelNames { get; set; } + public bool CanDelete { get; set; } public ViewLogsView() { Attachments = new List(); + Groups = new List(); + Units = new List(); + PersonnelNames = new Dictionary(); } } } diff --git a/Web/Resgrid.Web/Areas/User/Models/Personnel/PersonnelEventJson.cs b/Web/Resgrid.Web/Areas/User/Models/Personnel/PersonnelEventJson.cs new file mode 100644 index 00000000..ce70e8a4 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/Personnel/PersonnelEventJson.cs @@ -0,0 +1,17 @@ +namespace Resgrid.Web.Areas.User.Models.Personnel +{ + public class PersonnelEventJson + { + public int EventId { get; set; } + public string UserId { get; set; } + public string PersonName { get; set; } + public string State { get; set; } + public string Timestamp { get; set; } + public string LocalTimestamp { get; set; } + public string DestinationName { get; set; } + public string Latitude { get; set; } + public string Longitude { get; set; } + public string Note { get; set; } + } +} + diff --git a/Web/Resgrid.Web/Areas/User/Models/Personnel/ViewPersonEventsView.cs b/Web/Resgrid.Web/Areas/User/Models/Personnel/ViewPersonEventsView.cs new file mode 100644 index 00000000..b6a9ce23 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/Personnel/ViewPersonEventsView.cs @@ -0,0 +1,17 @@ +using Resgrid.Model; +using Resgrid.Web.Areas.User.Models; + +namespace Resgrid.Web.Areas.User.Models.Personnel +{ + public class ViewPersonEventsView : BaseUserModel + { + public string UserId { get; set; } + public string PersonName { get; set; } + public bool ConfirmClearAll { get; set; } + public string Message { get; set; } + public string OSMKey { get; set; } + public double CenterLat { get; set; } + public double CenterLon { get; set; } + } +} + diff --git a/Web/Resgrid.Web/Areas/User/Models/Reports/Personnel/PersonnelEventsReportView.cs b/Web/Resgrid.Web/Areas/User/Models/Reports/Personnel/PersonnelEventsReportView.cs new file mode 100644 index 00000000..d125d77e --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/Reports/Personnel/PersonnelEventsReportView.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using Resgrid.Model; +using Resgrid.Web.Areas.User.Models.Personnel; + +namespace Resgrid.Web.Areas.User.Models.Reports.Personnel +{ + public class PersonnelEventsReportView + { + public Department Department { get; set; } + public DateTime RunOn { get; set; } + public List Rows { get; set; } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Connect/Posts.cshtml b/Web/Resgrid.Web/Areas/User/Views/Connect/Posts.cshtml index 682a4e40..97210cc4 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Connect/Posts.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Connect/Posts.cshtml @@ -43,13 +43,9 @@ @section Scripts { - -} \ No newline at end of file +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Department/CallSettings.cshtml b/Web/Resgrid.Web/Areas/User/Views/Department/CallSettings.cshtml index d4ad9c44..0652b653 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Department/CallSettings.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Department/CallSettings.cshtml @@ -234,6 +234,57 @@ max-width: 130px; text-transform: uppercase; } + + .call-email-type-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 0; + border: none; + } + + .call-email-type-item { + display: flex; + align-items: center; + width: 280px; + border: 1px solid #e7eaec; + border-radius: 6px; + padding: 8px 10px; + transition: background-color 0.15s, border-color 0.15s; + } + + .call-email-type-item.active { + border-color: #1ab394; + background-color: #f0faf7; + } + + .call-email-type-label { + display: flex; + align-items: center; + gap: 10px; + margin: 0; + font-weight: normal; + cursor: pointer; + width: 100%; + } + + .call-email-type-img { + width: 160px; + height: 45px; + object-fit: contain; + border-radius: 4px; + border: 1px solid #ddd; + background: #fff; + flex-shrink: 0; + } + + .call-email-type-name { + font-size: 0.85em; + text-transform: uppercase; + font-weight: 600; + color: #444; + flex: 1; + } /*.k-state-selected h3 { color: #fff; diff --git a/Web/Resgrid.Web/Areas/User/Views/Department/TextSettings.cshtml b/Web/Resgrid.Web/Areas/User/Views/Department/TextSettings.cshtml index 361b4392..668aa6de 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Department/TextSettings.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Department/TextSettings.cshtml @@ -209,12 +209,6 @@ @section Scripts { - } @@ -37,6 +45,17 @@ +
+
+
+ @localizer["Template"] + @if (!String.IsNullOrWhiteSpace(Model.NewCallFormData)) + { + @localizer["CallForm"] + } +
+
+
@@ -52,6 +71,7 @@ @Html.HiddenFor(m => m.Latitude) @Html.HiddenFor(m => m.Longitude) @Html.HiddenFor(m => m.Call.ReportingUserId) + @Html.HiddenFor(m => m.Call.CallFormData)
@if (!String.IsNullOrEmpty(Model.Message)) @@ -87,6 +107,24 @@
+
+ +
+ + + + +
+
+
+ +
+ + + + +
+
@@ -104,7 +142,7 @@
-
+
@Html.Raw(Model.Call.NatureOfCall)
@@ -114,7 +152,7 @@
-
+
@Html.Raw(Model.Call.Notes)
@@ -148,6 +186,21 @@
+
+ +
+ + + + + + + + + +
@commonLocalizer["Code"]@commonLocalizer["Name"]@commonLocalizer["Status"]
+
+
@@ -164,33 +217,34 @@
- +
+
+ +
+
+
+
@Html.DropDownListFor(m => m.CallState, Model.CallStates, new { @style = "width: 120px;" })
@@ -219,6 +273,66 @@
+ + + + + + + + + @section Scripts { + + @@ -269,4 +411,90 @@ } + + } diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/ArchivedCalls.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/ArchivedCalls.cshtml index fe8cb0b2..3b055df5 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/ArchivedCalls.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/ArchivedCalls.cshtml @@ -1,5 +1,4 @@ - -@using Resgrid.Model +@using Resgrid.Model @using Resgrid.Web.Helpers @model Resgrid.Web.Areas.User.Models.CallsDashboardModel @inject IStringLocalizer localizer @@ -52,7 +51,7 @@
-
+
@@ -61,23 +60,6 @@ @section Scripts { - - - - - } diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExport.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExport.cshtml index 42c86b58..2e7b8a4a 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExport.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExport.cshtml @@ -23,10 +23,7 @@ asp-fallback-test-class="hidden" asp-fallback-test-property="visibility" asp-fallback-test-value="hidden" /> - - - - + + + +
+ + @* Header *@ +
+
+ Resgrid +
+
+

@localizer["LogReport"]

+
+
+ + @* Print button — hidden on print *@ +
+
+ +
+
+ + @* General Information *@ +
+
+
+ @localizer["GeneralInformation"] + @logType.ToString() +
+ + + + + + + + + + + @if (!String.IsNullOrWhiteSpace(Model.WorkLog.ExternalId) && logType != LogTypes.Coroner) + { + + } + else + { + + } + @if (Model.WorkLog.StartedOn.HasValue) + { + + } + else + { + + } + @if (Model.WorkLog.EndedOn.HasValue) + { + + } + else if (Model.WorkLog.StartedOn.HasValue && Model.WorkLog.EndedOn.HasValue) + { + var duration = Model.WorkLog.EndedOn.Value - Model.WorkLog.StartedOn.Value; + + } + else + { + + } + + +
+ @localizer["LogId"] + #@Model.WorkLog.LogId + + @localizer["LogType"] + @logType.ToString() + + @localizer["LoggedOn"] + @Model.WorkLog.LoggedOn.TimeConverterToString(Model.Department) + + @localizer["LoggedBy"] + + @if (!String.IsNullOrWhiteSpace(Model.WorkLog.LoggedByUserId) && Model.PersonnelNames.ContainsKey(Model.WorkLog.LoggedByUserId)) + { + @Model.PersonnelNames[Model.WorkLog.LoggedByUserId] + } + else + { + N/A + } + +
+ @commonLocalizer["Station"] + + @if (Model.WorkLog.StationGroup != null) + { + @Model.WorkLog.StationGroup.Name + } + else if (Model.WorkLog.StationGroupId.HasValue) + { + var grp = Model.Groups.FirstOrDefault(g => g.DepartmentGroupId == Model.WorkLog.StationGroupId.Value); + @(grp != null ? grp.Name : commonLocalizer["Unknown"].Value) + } + else + { + @Model.Department.Name + } + + + @localizer["ExternalId"] + @Model.WorkLog.ExternalId + + @commonLocalizer["Start"] + @Model.WorkLog.StartedOn.Value.TimeConverterToString(Model.Department) + + @commonLocalizer["End"] + @Model.WorkLog.EndedOn.Value.TimeConverterToString(Model.Department) + + @localizer["Duration"] + @($"{(int)duration.TotalHours}h {duration.Minutes}m") +
+
+
+ + @* Call Information (Run / Callback) *@ + @if ((logType == LogTypes.Run || logType == LogTypes.Callback) && Model.WorkLog.Call != null) + { +
+
+
@localizer["CallInformation"]
+ + + + + + + + + + + + + @if (!String.IsNullOrWhiteSpace(Model.WorkLog.Call.NatureOfCall)) + { + + + + } + +
+ @localizer["CallName"] + @Model.WorkLog.Call.Name + + @localizer["CallNumber"] + @Model.WorkLog.Call.Number + + @commonLocalizer["Type"] + + @if (!String.IsNullOrWhiteSpace(Model.WorkLog.Call.Type)) + { + @Model.WorkLog.Call.Type + } + else + { + @localizer["NoType"] + } + + + @localizer["CallPriority"] + @(((CallPriority)Model.WorkLog.Call.Priority).ToString()) +
+ @localizer["LoggedOn"] + @Model.WorkLog.Call.LoggedOn.TimeConverterToString(Model.Department) + + @localizer["CallAddress"] + + @if (!String.IsNullOrWhiteSpace(Model.WorkLog.Call.Address)) + { + @Model.WorkLog.Call.Address + } + else + { + @localizer["NotSupplied"] + } + +
+ @localizer["NatureOfCall"] +
@Html.Raw(Model.WorkLog.Call.NatureOfCall)
+
+
+
+ } + + @* Training Information *@ + @if (logType == LogTypes.Training) + { +
+
+
@localizer["TrainingInformation"]
+ + + + + + + + + + + @if (Model.WorkLog.StartedOn.HasValue && Model.WorkLog.EndedOn.HasValue) + { + var dur = Model.WorkLog.EndedOn.Value - Model.WorkLog.StartedOn.Value; + + } + else + { + + } + + +
+ @commonLocalizer["TrainingOrCourse"] + @(String.IsNullOrWhiteSpace(Model.WorkLog.Course) ? "N/A" : Model.WorkLog.Course) + + @commonLocalizer["TrainingCode"] + @(String.IsNullOrWhiteSpace(Model.WorkLog.CourseCode) ? "N/A" : Model.WorkLog.CourseCode) + + @commonLocalizer["Instructors"] + @(String.IsNullOrWhiteSpace(Model.WorkLog.Instructors) ? "N/A" : Model.WorkLog.Instructors) +
+ @commonLocalizer["Start"] + @(Model.WorkLog.StartedOn.HasValue ? Model.WorkLog.StartedOn.Value.TimeConverterToString(Model.Department) : "N/A") + + @commonLocalizer["End"] + @(Model.WorkLog.EndedOn.HasValue ? Model.WorkLog.EndedOn.Value.TimeConverterToString(Model.Department) : "N/A") + + @localizer["Duration"] + @($"{(int)dur.TotalHours}h {dur.Minutes}m") +
+
+
+ } + + @* Meeting Information *@ + @if (logType == LogTypes.Meeting) + { +
+
+
@localizer["MeetingInformation"]
+ + + + + + + + + + + + @if (Model.WorkLog.StartedOn.HasValue && Model.WorkLog.EndedOn.HasValue) + { + var dur = Model.WorkLog.EndedOn.Value - Model.WorkLog.StartedOn.Value; + + } + else + { + + } + + +
+ @localizer["MeetingType"] + @(String.IsNullOrWhiteSpace(Model.WorkLog.Type) ? "N/A" : Model.WorkLog.Type) + + @localizer["Location"] + @(String.IsNullOrWhiteSpace(Model.WorkLog.Location) ? "N/A" : Model.WorkLog.Location) + + @localizer["Presiding"] + @(String.IsNullOrWhiteSpace(Model.WorkLog.Instructors) ? "N/A" : Model.WorkLog.Instructors) + + @localizer["OtherAttendees"] + @(String.IsNullOrWhiteSpace(Model.WorkLog.OtherPersonnel) ? "N/A" : Model.WorkLog.OtherPersonnel) +
+ @commonLocalizer["Start"] + @(Model.WorkLog.StartedOn.HasValue ? Model.WorkLog.StartedOn.Value.TimeConverterToString(Model.Department) : "N/A") + + @commonLocalizer["End"] + @(Model.WorkLog.EndedOn.HasValue ? Model.WorkLog.EndedOn.Value.TimeConverterToString(Model.Department) : "N/A") + + @localizer["Duration"] + @($"{(int)dur.TotalHours}h {dur.Minutes}m") +
+
+
+ } + + @* Coroner Information *@ + @if (logType == LogTypes.Coroner) + { +
+
+
@localizer["CoronerInformation"]
+ + + + + + + + + + + + + + +
+ @localizer["CaseNumber"] + @(String.IsNullOrWhiteSpace(Model.WorkLog.ExternalId) ? "N/A" : Model.WorkLog.ExternalId) + + @localizer["Date"] + @(Model.WorkLog.StartedOn.HasValue ? Model.WorkLog.StartedOn.Value.TimeConverterToString(Model.Department) : "N/A") + + @localizer["PronouncedDeceasedBy"] + @(String.IsNullOrWhiteSpace(Model.WorkLog.PronouncedDeceasedBy) ? "N/A" : Model.WorkLog.PronouncedDeceasedBy) + + @localizer["BodyLocation"] + @(String.IsNullOrWhiteSpace(Model.WorkLog.BodyLocation) ? "N/A" : Model.WorkLog.BodyLocation) +
+ @localizer["SeniorOIC"] + @(String.IsNullOrWhiteSpace(Model.WorkLog.Instructors) ? "N/A" : Model.WorkLog.Instructors) + + @localizer["DestinationLocation"] + @(String.IsNullOrWhiteSpace(Model.WorkLog.Location) ? "N/A" : Model.WorkLog.Location) + + @localizer["OthersHavingContact"] + @(String.IsNullOrWhiteSpace(Model.WorkLog.OtherPersonnel) ? "N/A" : Model.WorkLog.OtherPersonnel) +
+
+
+ } + + @* Log Details — Narrative, Cause, Initial Report, Investigator *@ +
+
+
@localizer["LogDetails"]
+
+
+ + @if (!String.IsNullOrWhiteSpace(Model.WorkLog.InitialReport) && (logType == LogTypes.Run || logType == LogTypes.Callback)) + { +
+
+ @localizer["ConditionInitialReport"] +
@Html.Raw(Model.WorkLog.InitialReport)
+
+
+ } + + @if (!String.IsNullOrWhiteSpace(Model.WorkLog.Narrative)) + { +
+
+ @localizer["Narrative"] +
@Html.Raw(Model.WorkLog.Narrative)
+
+
+ } + + @if (!String.IsNullOrWhiteSpace(Model.WorkLog.Cause) && (logType == LogTypes.Run || logType == LogTypes.Callback)) + { +
+
+ @localizer["Cause"] +
@Html.Raw(Model.WorkLog.Cause)
+
+
+ } + + @if (!String.IsNullOrWhiteSpace(Model.WorkLog.InvestigatedByUserId) && (logType == LogTypes.Run || logType == LogTypes.Callback)) + { +
+
+ @localizer["Investigator"] + + @if (Model.PersonnelNames.ContainsKey(Model.WorkLog.InvestigatedByUserId)) + { + @Model.PersonnelNames[Model.WorkLog.InvestigatedByUserId] + } + else + { + N/A + } + +
+
+ } + + @* Units *@ + @if (Model.WorkLog.Units != null && Model.WorkLog.Units.Any()) + { +
+
+
@localizer["Units"]
+ + + + + + + + + + + + + + @foreach (var logUnit in Model.WorkLog.Units) + { + var unit = Model.Units.FirstOrDefault(u => u.UnitId == logUnit.UnitId); + var unitPersonnel = Model.WorkLog.Users != null + ? Model.WorkLog.Users.Where(p => p.UnitId == logUnit.UnitId).ToList() + : new List(); + + + + + + + + + + } + +
@commonLocalizer["Units"]@commonLocalizer["Dispatched"]@commonLocalizer["Enrotue"]@commonLocalizer["OnScene"]@commonLocalizer["Released"]@commonLocalizer["InQuarters"]@localizer["Personnel"]
@(unit != null ? unit.Name : $"Unit #{logUnit.UnitId}")@(logUnit.Dispatched.HasValue ? logUnit.Dispatched.Value.TimeConverterToString(Model.Department) : "—")@(logUnit.Enroute.HasValue ? logUnit.Enroute.Value.TimeConverterToString(Model.Department) : "—")@(logUnit.OnScene.HasValue ? logUnit.OnScene.Value.TimeConverterToString(Model.Department) : "—")@(logUnit.Released.HasValue ? logUnit.Released.Value.TimeConverterToString(Model.Department) : "—")@(logUnit.InQuarters.HasValue ? logUnit.InQuarters.Value.TimeConverterToString(Model.Department) : "—") + @if (unitPersonnel.Any()) + { + @string.Join(", ", unitPersonnel.Select(p => Model.PersonnelNames.ContainsKey(p.UserId) ? Model.PersonnelNames[p.UserId] : commonLocalizer["Unknown"].Value)) + } + else + { + + } +
+
+
+ } + + @* Non-unit Personnel *@ + @{ + var nonUnitPersonnel = Model.WorkLog.Users != null + ? Model.WorkLog.Users.Where(p => !p.UnitId.HasValue).ToList() + : new List(); + } + @if (nonUnitPersonnel.Any()) + { +
+
+
@localizer["Personnel"]
+ + + + + + + + @foreach (var p in nonUnitPersonnel) + { + + + + } + +
@commonLocalizer["Name"]
@(Model.PersonnelNames.ContainsKey(p.UserId) ? Model.PersonnelNames[p.UserId] : commonLocalizer["Unknown"].Value)
+
+
+ } + + @* Attachments *@ + @if (Model.Attachments != null && Model.Attachments.Any()) + { +
+
+
@localizer["Attachments"]
+ + + + + + + + + + + @foreach (var attachment in Model.Attachments) + { + + + + + + + } + +
@commonLocalizer["Name"]@commonLocalizer["Type"]@commonLocalizer["UploadedBy"]@commonLocalizer["Timestamp"]
@attachment.FileName@attachment.Type + @if (!String.IsNullOrWhiteSpace(attachment.UserId) && Model.PersonnelNames.ContainsKey(attachment.UserId)) + { + @Model.PersonnelNames[attachment.UserId] + } + else + { + + } + @attachment.Timestamp.TimeConverterToString(Model.Department)
+
+
+ } + + @* Footer *@ +
+ +
+ +
+ + + + + + + diff --git a/Web/Resgrid.Web/Areas/User/Views/Logs/ViewLog.cshtml b/Web/Resgrid.Web/Areas/User/Views/Logs/ViewLog.cshtml new file mode 100644 index 00000000..5274e0c6 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Logs/ViewLog.cshtml @@ -0,0 +1,650 @@ +@using Resgrid.Model +@using Resgrid.Model.Helpers +@using Resgrid.Web.Helpers +@model Resgrid.Web.Areas.User.Models.Logs.ViewLogsView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["ViewLogHeader"]; + var logType = Model.WorkLog.LogType.HasValue ? (LogTypes)Model.WorkLog.LogType.Value : LogTypes.Run; + var logTypeLabelClass = logType == LogTypes.Run ? "danger" + : logType == LogTypes.Training ? "primary" + : logType == LogTypes.Work ? "warning" + : logType == LogTypes.Meeting ? "info" + : logType == LogTypes.Coroner ? "default" + : "success"; +} + +
+
+

@localizer["ViewLogHeader"]

+ +
+ +
+ +
+
+
+ + @* General Information *@ +
+
+
@localizer["GeneralInformation"]
+
+
+
+
+
+ + @localizer["PrintExportView"] + + + @logType.ToString() + +

@localizer["LogId"] #@Model.WorkLog.LogId

+
+
+
+
+
+
+
@localizer["LogType"]:
+
@logType.ToString()
+ +
@localizer["LoggedOn"]:
+
@Model.WorkLog.LoggedOn.TimeConverterToString(Model.Department)
+ +
@localizer["LoggedBy"]:
+
+ @if (!String.IsNullOrWhiteSpace(Model.WorkLog.LoggedByUserId) && Model.PersonnelNames.ContainsKey(Model.WorkLog.LoggedByUserId)) + { + @Model.PersonnelNames[Model.WorkLog.LoggedByUserId] + } + else + { + N/A + } +
+ +
@commonLocalizer["Station"]:
+
+ @if (Model.WorkLog.StationGroup != null) + { + @Model.WorkLog.StationGroup.Name + } + else if (Model.WorkLog.StationGroupId.HasValue) + { + var grp = Model.Groups.FirstOrDefault(g => g.DepartmentGroupId == Model.WorkLog.StationGroupId.Value); + @(grp != null ? grp.Name : commonLocalizer["Unknown"].Value) + } + else + { + @Model.Department.Name + } +
+
+
+
+
+ @if (!String.IsNullOrWhiteSpace(Model.WorkLog.ExternalId) && logType != LogTypes.Coroner) + { +
@localizer["ExternalId"]:
+
@Model.WorkLog.ExternalId
+ } + + @if (Model.WorkLog.StartedOn.HasValue) + { +
@commonLocalizer["Start"]:
+
@Model.WorkLog.StartedOn.Value.TimeConverterToString(Model.Department)
+ } + + @if (Model.WorkLog.EndedOn.HasValue) + { +
@commonLocalizer["End"]:
+
@Model.WorkLog.EndedOn.Value.TimeConverterToString(Model.Department)
+ } + + @if (Model.WorkLog.StartedOn.HasValue && Model.WorkLog.EndedOn.HasValue) + { + var duration = Model.WorkLog.EndedOn.Value - Model.WorkLog.StartedOn.Value; +
@localizer["Duration"]:
+
@($"{(int)duration.TotalHours}h {duration.Minutes}m")
+ } +
+
+
+
+
+ + @* Call Information (Run / Callback log types) *@ + @if ((logType == LogTypes.Run || logType == LogTypes.Callback) && Model.WorkLog.Call != null) + { +
+
+
@localizer["CallInformation"]
+
+
+
+
+
+
@localizer["CallName"]:
+
@Model.WorkLog.Call.Name
+ +
@localizer["CallNumber"]:
+
@Model.WorkLog.Call.Number
+ + @if (!String.IsNullOrWhiteSpace(Model.WorkLog.Call.Type)) + { +
@commonLocalizer["Type"]:
+
@Model.WorkLog.Call.Type
+ } + +
@localizer["CallPriority"]:
+
@(((CallPriority)Model.WorkLog.Call.Priority).ToString())
+
+
+
+
+
@localizer["LoggedOn"]:
+
@Model.WorkLog.Call.LoggedOn.TimeConverterToString(Model.Department)
+ + @if (!String.IsNullOrWhiteSpace(Model.WorkLog.Call.Address)) + { +
@localizer["CallAddress"]:
+
@Model.WorkLog.Call.Address
+ } +
+
+
+ @if (!String.IsNullOrWhiteSpace(Model.WorkLog.Call.NatureOfCall)) + { +
+
+ @localizer["NatureOfCall"]: +
@Html.Raw(Model.WorkLog.Call.NatureOfCall)
+
+
+ } + +
+
+ } + + @* Training Information *@ + @if (logType == LogTypes.Training) + { +
+
+
@localizer["TrainingInformation"]
+
+
+
+ @if (!String.IsNullOrWhiteSpace(Model.WorkLog.Course)) + { +
@commonLocalizer["TrainingOrCourse"]:
+
@Model.WorkLog.Course
+ } + @if (!String.IsNullOrWhiteSpace(Model.WorkLog.CourseCode)) + { +
@commonLocalizer["TrainingCode"]:
+
@Model.WorkLog.CourseCode
+ } + @if (!String.IsNullOrWhiteSpace(Model.WorkLog.Instructors)) + { +
@commonLocalizer["Instructors"]:
+
@Model.WorkLog.Instructors
+ } + @if (Model.WorkLog.StartedOn.HasValue) + { +
@commonLocalizer["Start"]:
+
@Model.WorkLog.StartedOn.Value.TimeConverterToString(Model.Department)
+ } + @if (Model.WorkLog.EndedOn.HasValue) + { +
@commonLocalizer["End"]:
+
@Model.WorkLog.EndedOn.Value.TimeConverterToString(Model.Department)
+ } +
+
+
+ } + + @* Meeting Information *@ + @if (logType == LogTypes.Meeting) + { +
+
+
@localizer["MeetingInformation"]
+
+
+
+ @if (!String.IsNullOrWhiteSpace(Model.WorkLog.Type)) + { +
@localizer["MeetingType"]:
+
@Model.WorkLog.Type
+ } + @if (!String.IsNullOrWhiteSpace(Model.WorkLog.Location)) + { +
@localizer["Location"]:
+
@Model.WorkLog.Location
+ } + @if (!String.IsNullOrWhiteSpace(Model.WorkLog.Instructors)) + { +
@localizer["Presiding"]:
+
@Model.WorkLog.Instructors
+ } + @if (!String.IsNullOrWhiteSpace(Model.WorkLog.OtherPersonnel)) + { +
@localizer["OtherAttendees"]:
+
@Model.WorkLog.OtherPersonnel
+ } + @if (Model.WorkLog.StartedOn.HasValue) + { +
@commonLocalizer["Start"]:
+
@Model.WorkLog.StartedOn.Value.TimeConverterToString(Model.Department)
+ } + @if (Model.WorkLog.EndedOn.HasValue) + { +
@commonLocalizer["End"]:
+
@Model.WorkLog.EndedOn.Value.TimeConverterToString(Model.Department)
+ } +
+
+
+ } + + @* Coroner Information *@ + @if (logType == LogTypes.Coroner) + { +
+
+
@localizer["CoronerInformation"]
+
+
+
+ @if (!String.IsNullOrWhiteSpace(Model.WorkLog.ExternalId)) + { +
@localizer["CaseNumber"]:
+
@Model.WorkLog.ExternalId
+ } + @if (Model.WorkLog.StartedOn.HasValue) + { +
@localizer["Date"]:
+
@Model.WorkLog.StartedOn.Value.TimeConverterToString(Model.Department)
+ } + @if (!String.IsNullOrWhiteSpace(Model.WorkLog.PronouncedDeceasedBy)) + { +
@localizer["PronouncedDeceasedBy"]:
+
@Model.WorkLog.PronouncedDeceasedBy
+ } + @if (!String.IsNullOrWhiteSpace(Model.WorkLog.BodyLocation)) + { +
@localizer["BodyLocation"]:
+
@Model.WorkLog.BodyLocation
+ } + @if (!String.IsNullOrWhiteSpace(Model.WorkLog.Instructors)) + { +
@localizer["SeniorOIC"]:
+
@Model.WorkLog.Instructors
+ } + @if (!String.IsNullOrWhiteSpace(Model.WorkLog.Location)) + { +
@localizer["DestinationLocation"]:
+
@Model.WorkLog.Location
+ } + @if (!String.IsNullOrWhiteSpace(Model.WorkLog.OtherPersonnel)) + { +
@localizer["OthersHavingContact"]:
+
@Model.WorkLog.OtherPersonnel
+ } +
+
+
+ } + + @* Log Narrative and Details *@ +
+
+
@localizer["LogDetails"]
+
+
+ + @if (!String.IsNullOrWhiteSpace(Model.WorkLog.InitialReport) && (logType == LogTypes.Run || logType == LogTypes.Callback)) + { +
+ +
@Html.Raw(Model.WorkLog.InitialReport)
+
+ } + + @if (!String.IsNullOrWhiteSpace(Model.WorkLog.Narrative)) + { +
+ +
@Html.Raw(Model.WorkLog.Narrative)
+
+ } + + @if (!String.IsNullOrWhiteSpace(Model.WorkLog.Cause) && (logType == LogTypes.Run || logType == LogTypes.Callback)) + { +
+ +
@Html.Raw(Model.WorkLog.Cause)
+
+ } + + @if (!String.IsNullOrWhiteSpace(Model.WorkLog.InvestigatedByUserId) && (logType == LogTypes.Run || logType == LogTypes.Callback)) + { +
+ +

+ @if (Model.PersonnelNames.ContainsKey(Model.WorkLog.InvestigatedByUserId)) + { + @Model.PersonnelNames[Model.WorkLog.InvestigatedByUserId] + } +

+
+ } + +
+
+ + @* Units and Personnel *@ + @if ((Model.WorkLog.Units != null && Model.WorkLog.Units.Any()) || (Model.WorkLog.Users != null && Model.WorkLog.Users.Any())) + { +
+
+
@localizer["UnitsAndPersonnel"]
+
+
+ + @if (Model.WorkLog.Units != null && Model.WorkLog.Units.Any()) + { +

@localizer["Units"]

+
+ + + + + + + + + + + + + + @foreach (var logUnit in Model.WorkLog.Units) + { + var unit = Model.Units.FirstOrDefault(u => u.UnitId == logUnit.UnitId); + var unitPersonnel = Model.WorkLog.Users != null + ? Model.WorkLog.Users.Where(p => p.UnitId == logUnit.UnitId).ToList() + : new List(); + + + + + + + + + + } + +
@commonLocalizer["Units"]@commonLocalizer["Dispatched"]@commonLocalizer["Enrotue"]@commonLocalizer["OnScene"]@commonLocalizer["Released"]@commonLocalizer["InQuarters"]@localizer["Personnel"]
@(unit != null ? unit.Name : $"Unit #{logUnit.UnitId}") + @if (logUnit.Dispatched.HasValue) + { + @logUnit.Dispatched.Value.TimeConverterToString(Model.Department) + } + else + { + + } + + @if (logUnit.Enroute.HasValue) + { + @logUnit.Enroute.Value.TimeConverterToString(Model.Department) + } + else + { + + } + + @if (logUnit.OnScene.HasValue) + { + @logUnit.OnScene.Value.TimeConverterToString(Model.Department) + } + else + { + + } + + @if (logUnit.Released.HasValue) + { + @logUnit.Released.Value.TimeConverterToString(Model.Department) + } + else + { + + } + + @if (logUnit.InQuarters.HasValue) + { + @logUnit.InQuarters.Value.TimeConverterToString(Model.Department) + } + else + { + + } + + @if (unitPersonnel.Any()) + { +
    + @foreach (var p in unitPersonnel) + { +
  • + @if (Model.PersonnelNames.ContainsKey(p.UserId)) + { + @Model.PersonnelNames[p.UserId] + } + else + { + @commonLocalizer["Unknown"] + } +
  • + } +
+ } + else + { + + } +
+
+ } + + @{ + var nonUnitPersonnel = Model.WorkLog.Users != null + ? Model.WorkLog.Users.Where(p => !p.UnitId.HasValue).ToList() + : new List(); + } + @if (nonUnitPersonnel.Any()) + { +

@localizer["Personnel"] (@localizer["PersonnelNotAssignedUnit"])

+ + + + + + + + @foreach (var p in nonUnitPersonnel) + { + + + + } + +
@commonLocalizer["Name"]
+ @if (Model.PersonnelNames.ContainsKey(p.UserId)) + { + @Model.PersonnelNames[p.UserId] + } + else + { + @commonLocalizer["Unknown"] + } +
+ } + +
+
+ } + + @* Attachments *@ + @if (Model.Attachments != null && Model.Attachments.Any()) + { +
+
+
@localizer["Attachments"]
+
+
+ + + + + + + + + + + + @foreach (var attachment in Model.Attachments) + { + + + + + + + + } + +
@commonLocalizer["Name"]@commonLocalizer["Type"]@commonLocalizer["UploadedBy"]@commonLocalizer["Timestamp"]@commonLocalizer["Action"]
@attachment.FileName@attachment.Type + @if (!String.IsNullOrWhiteSpace(attachment.UserId) && Model.PersonnelNames.ContainsKey(attachment.UserId)) + { + @Model.PersonnelNames[attachment.UserId] + } + else + { + + } + @attachment.Timestamp.TimeConverterToString(Model.Department) + + @commonLocalizer["Download"] + +
+
+
+ } + +
+
+ + @* Sidebar *@ +
+
+ +
+
+
@localizer["LogSummary"]
+
+
+
    +
  • + @localizer["LogId"] + #@Model.WorkLog.LogId +
  • +
  • + @localizer["LogType"] + + @logType.ToString() + +
  • +
  • + @localizer["LoggedOn"] + @Model.WorkLog.LoggedOn.TimeConverterToString(Model.Department) +
  • + @if (Model.WorkLog.StartedOn.HasValue && Model.WorkLog.EndedOn.HasValue) + { + var dur = Model.WorkLog.EndedOn.Value - Model.WorkLog.StartedOn.Value; +
  • + @localizer["Duration"] + @($"{(int)dur.TotalHours}h {dur.Minutes}m") +
  • + } +
  • + @commonLocalizer["Units"] + @(Model.WorkLog.Units != null ? Model.WorkLog.Units.Count : 0) +
  • +
  • + @commonLocalizer["Personnel"] + @(Model.WorkLog.Users != null ? Model.WorkLog.Users.Count : 0) +
  • +
  • + @commonLocalizer["Attachments"] + @(Model.Attachments != null ? Model.Attachments.Count : 0) +
  • +
+
+
+ +
+
+
@commonLocalizer["Actions"]
+
+ +
+ +
+
+
+ + diff --git a/Web/Resgrid.Web/Areas/User/Views/Logs/_UnitLogBlockPartial.cshtml b/Web/Resgrid.Web/Areas/User/Views/Logs/_UnitLogBlockPartial.cshtml index 907bfea3..741b9073 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Logs/_UnitLogBlockPartial.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Logs/_UnitLogBlockPartial.cshtml @@ -40,15 +40,15 @@ $('#unit_releasedtime_@Model.UnitId').datetimepicker({ step: 5 }); $('#unit_inquarterstime_@Model.UnitId').datetimepicker({ step: 5 }); - $('#unit_personnel_@Model.UnitId').kendoMultiSelect({ + $('#unit_personnel_@Model.UnitId').select2({ placeholder: "Select Personnel for @Model.UnitName ...", - dataTextField: "Name", - dataValueField: "UserId", - autoBind: false, - dataSource: { - type: "json", - transport: { - read: resgrid.absoluteBaseUrl + '/User/Personnel/GetPersonnelForGridWithFilter' + allowClear: true, + multiple: true, + ajax: { + url: resgrid.absoluteBaseUrl + '/User/Personnel/GetPersonnelForGridWithFilter', + dataType: 'json', + processResults: function (data) { + return { results: $.map(data, function (u) { return { id: u.UserId, text: u.Name }; }) }; } } }); diff --git a/Web/Resgrid.Web/Areas/User/Views/Mapping/ViewType.cshtml b/Web/Resgrid.Web/Areas/User/Views/Mapping/ViewType.cshtml index 5ced8baf..df48d17c 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Mapping/ViewType.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Mapping/ViewType.cshtml @@ -106,7 +106,7 @@
-
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/Messages/Compose.cshtml b/Web/Resgrid.Web/Areas/User/Views/Messages/Compose.cshtml index 3cc537bc..0c5d9c14 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Messages/Compose.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Messages/Compose.cshtml @@ -61,9 +61,9 @@
-
-
-
+
+
+
@@ -96,7 +96,7 @@
diff --git a/Web/Resgrid.Web/Areas/User/Views/Messages/Inbox.cshtml b/Web/Resgrid.Web/Areas/User/Views/Messages/Inbox.cshtml index 53e53f85..fcb9cff2 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Messages/Inbox.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Messages/Inbox.cshtml @@ -90,10 +90,5 @@ })(); - - } diff --git a/Web/Resgrid.Web/Areas/User/Views/Messages/Outbox.cshtml b/Web/Resgrid.Web/Areas/User/Views/Messages/Outbox.cshtml index 3122e64a..dd0d18ae 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Messages/Outbox.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Messages/Outbox.cshtml @@ -68,10 +68,5 @@ })(); - - } diff --git a/Web/Resgrid.Web/Areas/User/Views/Notifications/New.cshtml b/Web/Resgrid.Web/Areas/User/Views/Notifications/New.cshtml index b932660b..7abd930c 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Notifications/New.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Notifications/New.cshtml @@ -136,13 +136,13 @@
- +
- +
- +
@@ -188,4 +188,4 @@ @section Scripts { -} \ No newline at end of file +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Orders/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Orders/Index.cshtml index ec93724a..efb4b195 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Orders/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Orders/Index.cshtml @@ -169,9 +169,6 @@ @section Scripts { - } diff --git a/Web/Resgrid.Web/Areas/User/Views/Personnel/ViewEvents.cshtml b/Web/Resgrid.Web/Areas/User/Views/Personnel/ViewEvents.cshtml new file mode 100644 index 00000000..70dc564a --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Personnel/ViewEvents.cshtml @@ -0,0 +1,107 @@ +@model Resgrid.Web.Areas.User.Models.Personnel.ViewPersonEventsView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + @localizer["ViewPersonEventsHeader"]; +} +@section Styles + { + +} + +
+
+

@localizer["EventsFor"] @Model.PersonName

+ +
+
+
+ @if (ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + { + @localizer["ClearAllStatuses"] + } +
+
+
+ +@using (Html.BeginForm("GeneratePersonnelEventsReport", "Personnel", FormMethod.Post, new { area = "User", @class = "form-horizontal" })) +{ +
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+} + + + +@section Scripts + { + + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Personnel/ViewPerson.cshtml b/Web/Resgrid.Web/Areas/User/Views/Personnel/ViewPerson.cshtml index 926417c1..f1b4d8a2 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Personnel/ViewPerson.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Personnel/ViewPerson.cshtml @@ -22,6 +22,11 @@ +
diff --git a/Web/Resgrid.Web/Areas/User/Views/Profile/Reporting.cshtml b/Web/Resgrid.Web/Areas/User/Views/Profile/Reporting.cshtml index a2bfe8ec..841ec759 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Profile/Reporting.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Profile/Reporting.cshtml @@ -36,7 +36,7 @@
-
+
@@ -45,17 +45,6 @@ @section Scripts { - } diff --git a/Web/Resgrid.Web/Areas/User/Views/Profile/ViewSchedules.cshtml b/Web/Resgrid.Web/Areas/User/Views/Profile/ViewSchedules.cshtml index 207fd466..e55bd7c0 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Profile/ViewSchedules.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Profile/ViewSchedules.cshtml @@ -53,7 +53,7 @@
-
+
@@ -63,17 +63,6 @@ @section Scripts { - diff --git a/Web/Resgrid.Web/Areas/User/Views/Reports/ActionLogs.cshtml b/Web/Resgrid.Web/Areas/User/Views/Reports/ActionLogs.cshtml index 280c8091..0dfe9c37 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Reports/ActionLogs.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Reports/ActionLogs.cshtml @@ -21,10 +21,7 @@ asp-fallback-test-class="hidden" asp-fallback-test-property="visibility" asp-fallback-test-value="hidden" /> - - - - + + + + + + + + + + + +
+
+
+ Resgrid Logo +
+
+ @if (Model.Rows != null && Model.Rows.Count > 0) + { +

@Model.Rows[0].PersonName Events Report

+ } + else + { +

Resgrid Personnel Events Report

+ } +
+
+
+
+ + + + + + + + + + + + @if (Model.Rows != null) + { + @foreach (var row in Model.Rows) + { + + + + + + + + } + } + +
StatusDestination\CallTimestampLocationNote
@row.State@row.DestinationName@row.Timestamp@row.Latitude,@row.Longitude@row.Note
+
+
+
+
+ @Model.RunOn.FormatForDepartment(Model.Department) +
+
+
+ + + + + + diff --git a/Web/Resgrid.Web/Areas/User/Views/Reports/PersonnelHoursDetailReport.cshtml b/Web/Resgrid.Web/Areas/User/Views/Reports/PersonnelHoursDetailReport.cshtml index af18b51c..ffc8b25c 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Reports/PersonnelHoursDetailReport.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Reports/PersonnelHoursDetailReport.cshtml @@ -21,10 +21,7 @@ asp-fallback-test-class="hidden" asp-fallback-test-property="visibility" asp-fallback-test-value="hidden" /> - - - - +