From bad6b84a6c6ad8e14ed293c1d2e0493cf974e72e Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sun, 20 Sep 2026 21:07:13 -0700 Subject: [PATCH] RG-T51 Backoffice Styling and UI fixes, fixing issue with Work Order Id's --- .../Handlers/CallsActionHandler.cs | 21 +- .../Handlers/UnitsActionHandler.cs | 10 +- .../Handlers/UnitsAvailableActionHandler.cs | 11 +- .../Localization/ChatbotResources.cs | 22 ++ Core/Resgrid.Framework/DateTimeHelpers.cs | 19 ++ .../Areas/User/Checklists/Checklists.ar.resx | 2 +- .../Areas/User/Checklists/Checklists.de.resx | 2 +- .../Areas/User/Checklists/Checklists.el.resx | 2 +- .../Areas/User/Checklists/Checklists.en.resx | 2 +- .../Areas/User/Checklists/Checklists.es.resx | 2 +- .../Areas/User/Checklists/Checklists.fr.resx | 2 +- .../Areas/User/Checklists/Checklists.it.resx | 2 +- .../Areas/User/Checklists/Checklists.pl.resx | 2 +- .../Areas/User/Checklists/Checklists.resx | 2 +- .../Areas/User/Checklists/Checklists.sv.resx | 2 +- .../Areas/User/Checklists/Checklists.uk.resx | 2 +- .../User/Deployments/Deployments.ar.resx | 5 +- .../User/Deployments/Deployments.de.resx | 5 +- .../User/Deployments/Deployments.el.resx | 5 +- .../User/Deployments/Deployments.en.resx | 5 +- .../User/Deployments/Deployments.es.resx | 5 +- .../User/Deployments/Deployments.fr.resx | 5 +- .../User/Deployments/Deployments.it.resx | 5 +- .../User/Deployments/Deployments.pl.resx | 5 +- .../Areas/User/Deployments/Deployments.resx | 5 +- .../User/Deployments/Deployments.sv.resx | 5 +- .../User/Deployments/Deployments.uk.resx | 5 +- .../Areas/User/Records/Records.ar.resx | 14 +- .../Areas/User/Records/Records.de.resx | 14 +- .../Areas/User/Records/Records.el.resx | 14 +- .../Areas/User/Records/Records.en.resx | 14 +- .../Areas/User/Records/Records.es.resx | 14 +- .../Areas/User/Records/Records.fr.resx | 14 +- .../Areas/User/Records/Records.it.resx | 14 +- .../Areas/User/Records/Records.pl.resx | 14 +- .../Areas/User/Records/Records.sv.resx | 14 +- .../Areas/User/Records/Records.uk.resx | 14 +- .../Areas/User/WorkOrders/WorkOrders.ar.resx | 22 +- .../Areas/User/WorkOrders/WorkOrders.de.resx | 26 ++- .../Areas/User/WorkOrders/WorkOrders.el.resx | 26 ++- .../Areas/User/WorkOrders/WorkOrders.en.resx | 26 ++- .../Areas/User/WorkOrders/WorkOrders.es.resx | 26 ++- .../Areas/User/WorkOrders/WorkOrders.fr.resx | 26 ++- .../Areas/User/WorkOrders/WorkOrders.it.resx | 26 ++- .../Areas/User/WorkOrders/WorkOrders.pl.resx | 26 ++- .../Areas/User/WorkOrders/WorkOrders.resx | 26 ++- .../Areas/User/WorkOrders/WorkOrders.sv.resx | 26 ++- .../Areas/User/WorkOrders/WorkOrders.uk.resx | 26 ++- Core/Resgrid.Localization/Common.ar.resx | 2 + Core/Resgrid.Localization/Common.de.resx | 2 + Core/Resgrid.Localization/Common.el.resx | 2 + Core/Resgrid.Localization/Common.en.resx | 2 + Core/Resgrid.Localization/Common.es.resx | 2 + Core/Resgrid.Localization/Common.fr.resx | 2 + Core/Resgrid.Localization/Common.it.resx | 2 + Core/Resgrid.Localization/Common.pl.resx | 2 + Core/Resgrid.Localization/Common.sv.resx | 2 + Core/Resgrid.Localization/Common.uk.resx | 2 + .../Helpers/TimeConverterHelper.cs | 55 +---- .../Inventories/InventoryContracts.cs | 4 +- .../Inventories/InventoryModels.cs | 4 +- .../Repositories/IWorkOrderRepository.cs | 16 +- Core/Resgrid.Model/Search/SearchProjection.cs | 2 +- .../Resgrid.Model/Services/ISearchServices.cs | 7 + .../Services/IWorkOrderNotificationService.cs | 2 +- .../Services/IWorkOrdersService.cs | 42 ++-- .../WorkOrders/WorkOrderAssignees.cs | 18 ++ .../WorkOrders/WorkOrderCurrencies.cs | 20 ++ .../WorkOrders/WorkOrderMaintenance.cs | 44 ++-- .../WorkOrders/WorkOrderModels.cs | 20 +- .../WorkOrders/WorkOrderOperations.cs | 30 +-- .../WorkOrders/WorkOrderReporting.cs | 28 +-- .../WorkOrders/WorkOrderWorkflowPayload.cs | 5 +- .../LuceneGlobalSearchService.cs | 28 ++- Core/Resgrid.Services/AdpTableBindings.cs | 2 +- .../ChecklistReportDocuments.cs | 15 +- Core/Resgrid.Services/CommunicationService.cs | 7 +- .../CalOesMarsService.WorkItems.cs | 11 +- .../CostRecovery/CalOesMarsService.cs | 8 + .../InventoryAlertNotifications.cs | 56 ++++- Core/Resgrid.Services/InventoryPosting.cs | 4 +- Core/Resgrid.Services/InventoryReferences.cs | 4 +- .../InventoryWorkOrderAllocations.cs | 12 +- Core/Resgrid.Services/InventoryWorkOrders.cs | 18 +- .../Invoicing/DeploymentService.cs | 5 + .../Records/RecordSavedReportsService.cs | 22 +- .../RecordsAnalyticsService.Readiness.cs | 8 +- .../Records/RecordsAnalyticsService.cs | 5 +- .../Search/SearchProjectionService.cs | 19 +- .../Search/UnifiedSearchService.cs | 12 ++ .../WorkOrderAuthorizationService.cs | 23 ++- .../WorkOrderBulkOperations.cs | 16 +- Core/Resgrid.Services/WorkOrderCsvImport.cs | 12 +- Core/Resgrid.Services/WorkOrderFiles.cs | 10 +- Core/Resgrid.Services/WorkOrderGdprExport.cs | 6 +- .../WorkOrderInventoryParts.cs | 22 +- .../WorkOrderMaintenanceCore.cs | 23 ++- .../WorkOrderMaintenanceGdprExport.cs | 2 +- .../WorkOrderNotificationService.cs | 21 +- .../WorkOrderPartAllocations.cs | 12 +- Core/Resgrid.Services/WorkOrderPolicies.cs | 31 ++- .../WorkOrderRecurrenceService.cs | 81 +++++--- .../WorkOrderReportDocuments.cs | 5 +- Core/Resgrid.Services/WorkOrderReporting.cs | 38 ++-- .../WorkOrderTimeAnalytics.cs | 2 +- .../WorkOrderVendorCharges.cs | 15 +- Core/Resgrid.Services/WorkOrdersService.cs | 78 ++++--- .../Migrations/M0197_AddWorkOrders.cs | 10 +- .../M0203_AddWorkOrderIntegrations.cs | 10 +- .../M0204_AddWorkOrderRecurrences.cs | 29 +-- .../Migrations/M0206_AddWorkOrderReporting.cs | 10 +- .../M0207_AddWorkOrderOperations.cs | 8 +- .../M0225_AddMaintenanceAssignees.cs | 28 +++ .../Migrations/M0226_AddWorkOrderCurrency.cs | 21 ++ .../Migrations/M0197_AddWorkOrdersPg.cs | 10 +- .../M0203_AddWorkOrderIntegrationsPg.cs | 10 +- .../M0204_AddWorkOrderRecurrencesPg.cs | 29 +-- .../M0206_AddWorkOrderReportingPg.cs | 10 +- .../M0207_AddWorkOrderOperationsPg.cs | 8 +- .../M0225_AddMaintenanceAssigneesPg.cs | 28 +++ .../M0226_AddWorkOrderCurrencyPg.cs | 21 ++ .../WorkOrderMaintenanceRepository.cs | 2 +- .../WorkOrderReportingRepository.cs | 45 +++-- .../WorkOrderRepository.cs | 31 ++- .../ExternalChatbotAuthorizationTests.cs | 109 +++++++--- .../Rms/ChecklistReadinessEvidenceTests.cs | 2 +- .../Rms/RecordEvidenceWebControllerTests.cs | 4 +- .../Rms/RecordSavedReportsServiceTests.cs | 20 ++ .../Rms/RecordsAnalyticsReadinessTests.cs | 7 +- .../Resgrid.Tests/Rms/RmsDefinitionHarness.cs | 2 +- .../BusinessOperationsProjectionTests.cs | 12 +- .../Resgrid.Tests/Search/GlobalSearchTests.cs | 25 +++ .../UnifiedSearchBusinessOperationsTests.cs | 3 + .../Services/CalOesMarsServiceTests.cs | 23 +++ .../Services/ChecklistP2M4Tests.cs | 2 +- .../Services/ChecklistPageAcceptanceTests.cs | 1 + .../Services/ChecklistPr504BoundaryTests.cs | 1 + .../Services/DeploymentServiceTests.cs | 12 +- .../Services/InventoryDatabaseFixture.cs | 4 +- .../Services/InventoryM5NotificationTests.cs | 86 +++++++- .../Services/InventoryModernizationTests.cs | 2 +- .../InventoryWorkOrderDatabaseTests.cs | 2 +- ...ventoryWorkOrderOperationsDatabaseTests.cs | 2 +- .../Services/WorkOrderAuthorizationTests.cs | 56 ++++- .../Services/WorkOrderCompletionTests.cs | 93 +++++++++ .../Services/WorkOrderDatabaseTests.cs | 6 +- .../Services/WorkOrderGdprTests.cs | 22 +- .../Services/WorkOrderGuidTests.cs | 126 ++++++++++++ .../Services/WorkOrderHttpTests.cs | 117 ++++++++++- .../Services/WorkOrderLocalizationTests.cs | 2 +- .../WorkOrderMaintenanceAssignmentTests.cs | 79 ++++++++ .../WorkOrderMaintenanceDatabaseTests.cs | 22 +- .../Services/WorkOrderNotificationTests.cs | 13 +- .../Services/WorkOrderOperationsTests.cs | 4 +- .../Services/WorkOrderP2M1Tests.cs | 12 +- .../Services/WorkOrderP2M23Tests.cs | 2 +- .../Services/WorkOrderPr505Tests.cs | 2 +- .../Services/WorkOrderProtectionTests.cs | 12 +- .../WorkOrderReportingDatabaseTests.cs | 10 +- .../Services/WorkOrderReportingTests.cs | 37 ++-- .../WorkOrderSettingsDatabaseTests.cs | 37 ++++ .../Services/WorkOrderSettingsTests.cs | 117 +++++++++++ .../Resgrid.Tests/Web/DepartmentTimeTests.cs | 113 +++++++++++ .../Web/work-orders-maintenance.test.cjs | 2 +- Tests/Resgrid.Tests/Web/work-orders.test.cjs | 15 +- .../Web/workspace-wizard.test.cjs | 18 +- .../v4/WorkOrderMaintenanceController.cs | 8 +- .../v4/WorkOrderOperationsController.cs | 8 +- .../v4/WorkOrderReportingController.cs | 2 +- .../Controllers/v4/WorkOrdersController.cs | 8 +- .../v4/WorkOrders/WorkOrderApiContracts.cs | 12 +- .../Areas/User/Controllers/BidsController.cs | 1 + .../User/Controllers/CalOesMarsController.cs | 26 ++- .../Controllers/CertificationsController.cs | 5 +- .../ChecklistReportingController.cs | 26 ++- .../User/Controllers/ChecklistsController.cs | 1 + .../ChecklistsSchedulingController.cs | 4 +- .../User/Controllers/ContractsController.cs | 3 +- .../Controllers/DeploymentWizardController.cs | 14 +- .../User/Controllers/DeploymentsController.cs | 28 +-- .../User/Controllers/DisclosuresController.cs | 6 +- .../Controllers/IncidentAnalysisController.cs | 6 +- .../Controllers/IncidentReportsController.cs | 6 +- .../User/Controllers/InvoicingController.cs | 3 +- .../Controllers/RateSchedulesController.cs | 1 + .../RecordDefinitionsController.cs | 1 + .../RecordDeploymentConnectorsController.cs | 1 + .../RecordDeploymentsController.cs | 5 +- .../Controllers/RecordDocumentsController.cs | 1 + .../Controllers/RecordEvidenceController.cs | 7 +- .../RecordInspectionsController.cs | 4 +- .../Controllers/RecordLegalHoldsController.cs | 3 +- .../Controllers/RecordPermitsController.cs | 5 +- .../RecordSavedReportsController.cs | 1 + .../RecordSubmissionsController.cs | 1 + .../Controllers/RecordsAnalyticsController.cs | 9 +- .../User/Controllers/RecordsController.cs | 18 +- .../RecordsExportTemplatesController.cs | 1 + .../Controllers/RecordsInventoryController.cs | 1 + .../RecordsPreventionMvcControllerBase.cs | 5 +- .../WorkOrderMaintenanceController.cs | 17 +- .../WorkOrderOperationsController.cs | 28 ++- .../WorkOrderReportingController.cs | 10 +- .../User/Controllers/WorkOrdersController.cs | 39 ++-- .../User/Controllers/WorkforceController.cs | 21 +- .../Models/Records/RecordsRms5ViewModels.cs | 1 - .../WorkOrders/WorkOrderOperationsViews.cs | 2 +- .../WorkOrders/WorkOrderSettingChoices.cs | 18 ++ .../User/Models/WorkOrders/WorkOrderViews.cs | 6 +- .../Areas/User/Views/Bids/Edit.cshtml | 13 +- .../Areas/User/Views/Bids/Index.cshtml | 17 +- .../Areas/User/Views/Bids/New.cshtml | 6 +- .../Areas/User/Views/Bids/View.cshtml | 26 ++- .../Areas/User/Views/CalOesMars/Agency.cshtml | 9 +- .../User/Views/CalOesMars/Agreements.cshtml | 8 +- .../User/Views/CalOesMars/Handoff.cshtml | 20 +- .../Areas/User/Views/CalOesMars/Index.cshtml | 9 +- .../User/Views/CalOesMars/Invoice.cshtml | 20 +- .../Areas/User/Views/CalOesMars/Queue.cshtml | 9 +- .../Areas/User/Views/CalOesMars/Rate.cshtml | 13 +- .../Areas/User/Views/CalOesMars/Rates.cshtml | 35 ++-- .../Views/CalOesMars/Reconciliation.cshtml | 9 +- .../User/Views/CalOesMars/Resources.cshtml | 8 +- .../User/Views/CalOesMars/WorkItem.cshtml | 42 ++-- .../User/Views/Certifications/EditType.cshtml | 6 +- .../User/Views/Certifications/Index.cshtml | 22 +- .../User/Views/Certifications/Record.cshtml | 13 +- .../Certifications/RoleRequirements.cshtml | 13 +- .../User/Views/Certifications/Settings.cshtml | 8 +- .../User/Views/Certifications/Types.cshtml | 21 +- .../User/Views/Certifications/Unit.cshtml | 26 ++- .../User/Views/Certifications/_Shell.cshtml | 11 +- .../User/Views/Certifications/_Tabs.cshtml | 10 +- .../Views/Checklists/CompletionDetail.cshtml | 38 ++-- .../User/Views/Checklists/Compliance.cshtml | 16 +- .../Areas/User/Views/Checklists/Detail.cshtml | 40 ++-- .../Areas/User/Views/Checklists/Due.cshtml | 14 +- .../Areas/User/Views/Checklists/Edit.cshtml | 3 +- .../User/Views/Checklists/EditSchedule.cshtml | 3 +- .../Areas/User/Views/Checklists/Index.cshtml | 21 +- .../Areas/User/Views/Checklists/Locked.cshtml | 3 +- .../User/Views/Checklists/Occurrence.cshtml | 44 ++-- .../Views/Checklists/ReadinessPacket.cshtml | 7 +- .../User/Views/Checklists/Reminders.cshtml | 5 +- .../Areas/User/Views/Checklists/Run.cshtml | 6 +- .../User/Views/Checklists/Schedules.cshtml | 23 ++- .../User/Views/Checklists/Template.cshtml | 4 +- .../User/Views/Checklists/Templates.cshtml | 4 +- .../Areas/User/Views/Checklists/_Shell.cshtml | 11 +- .../Areas/User/Views/Checklists/_Tabs.cshtml | 6 +- .../User/Views/Contracts/Compliance.cshtml | 9 +- .../Areas/User/Views/Contracts/Edit.cshtml | 6 +- .../Areas/User/Views/Contracts/Index.cshtml | 19 +- .../Areas/User/Views/Contracts/View.cshtml | 22 +- .../User/Views/DeploymentWizard/Index.cshtml | 6 +- .../Areas/User/Views/Deployments/Edit.cshtml | 6 +- .../Deployments/FromExternalOrder.cshtml | 6 +- .../Areas/User/Views/Deployments/Index.cshtml | 28 ++- .../User/Views/Deployments/TimeReport.cshtml | 27 ++- .../Areas/User/Views/Deployments/View.cshtml | 35 ++-- .../Views/Deployments/_ExpenseForm.cshtml | 3 +- .../User/Views/Deployments/_Shell.cshtml | 5 +- .../Areas/User/Views/Invoicing/Aging.cshtml | 11 +- .../Views/Invoicing/BillingProfile.cshtml | 15 +- .../Areas/User/Views/Invoicing/Edit.cshtml | 6 +- .../User/Views/Invoicing/EditRateCard.cshtml | 6 +- .../Areas/User/Views/Invoicing/Index.cshtml | 27 ++- .../Areas/User/Views/Invoicing/New.cshtml | 6 +- .../User/Views/Invoicing/RateCards.cshtml | 24 ++- .../User/Views/Invoicing/Settings.cshtml | 14 +- .../Areas/User/Views/Invoicing/View.cshtml | 96 ++++----- .../Areas/User/Views/Invoicing/_Shell.cshtml | 11 +- .../Areas/User/Views/Invoicing/_Tabs.cshtml | 2 +- .../User/Views/RateSchedules/Edit.cshtml | 24 ++- .../User/Views/RateSchedules/Index.cshtml | 25 ++- .../Areas/User/Views/RecordCrr/Edit.cshtml | 3 +- .../Areas/User/Views/RecordCrr/Index.cshtml | 7 +- .../User/Views/RecordEvidence/Index.cshtml | 3 +- .../User/Views/RecordEvidence/Select.cshtml | 11 +- .../User/Views/RecordHydrants/Details.cshtml | 15 +- .../User/Views/RecordHydrants/Index.cshtml | 3 +- .../Views/RecordInspections/Details.cshtml | 15 +- .../User/Views/RecordInspections/Index.cshtml | 5 +- .../Views/RecordInvestigations/Custody.cshtml | 5 +- .../Views/RecordInvestigations/Details.cshtml | 21 +- .../Views/RecordInvestigations/Index.cshtml | 5 +- .../User/Views/RecordLegalHolds/Index.cshtml | 9 +- .../Views/RecordOccupancies/Crosswalk.cshtml | 7 +- .../Views/RecordOccupancies/Details.cshtml | 13 +- .../User/Views/RecordOccupancies/Index.cshtml | 3 +- .../User/Views/RecordPermits/Details.cshtml | 11 +- .../User/Views/RecordPermits/Index.cshtml | 3 +- .../Views/RecordSubmissions/Details.cshtml | 3 +- .../User/Views/Records/FieldRollout.cshtml | 3 +- .../Views/Records/_DefinitionFields.cshtml | 6 +- .../Views/Records/_DefinitionValues.cshtml | 3 +- .../User/Views/RecordsAnalytics/Index.cshtml | 3 +- .../Views/RecordsAnalytics/Readiness.cshtml | 7 +- .../RecordsAnalytics/_AnalyticsShell.cshtml | 3 +- .../User/Views/RecordsHealth/Index.cshtml | 5 +- .../User/Views/RecordsInventory/Edit.cshtml | 3 +- .../User/Views/RecordsQuality/Index.cshtml | 5 +- .../User/Views/RecordsQuality/Review.cshtml | 5 +- .../User/Views/RecordsQuality/Trends.cshtml | 3 +- .../User/Views/Shared/_CalOesMarsShell.cshtml | 27 +-- .../User/Views/Shared/_ContractorShell.cshtml | 5 +- .../User/Views/Shared/_Navigation.cshtml | 191 +++++++++++------- .../Shared/_PreventionAttachments.cshtml | 2 +- .../User/Views/Shared/_WorkforceShell.cshtml | 37 ++-- .../Shared/_WorkspaceHeaderActions.cshtml | 11 + .../Areas/User/Views/WorkOrders/Bulk.cshtml | 5 +- .../Areas/User/Views/WorkOrders/Detail.cshtml | 89 ++++---- .../Areas/User/Views/WorkOrders/Edit.cshtml | 5 +- .../Views/WorkOrders/EditRecurrence.cshtml | 22 +- .../Areas/User/Views/WorkOrders/Index.cshtml | 24 ++- .../Areas/User/Views/WorkOrders/Locked.cshtml | 3 +- .../User/Views/WorkOrders/Operations.cshtml | 28 ++- .../User/Views/WorkOrders/Recurrence.cshtml | 54 ++--- .../User/Views/WorkOrders/Recurrences.cshtml | 27 ++- .../User/Views/WorkOrders/Reports.cshtml | 17 +- .../{Policy.cshtml => Settings.cshtml} | 55 ++--- .../User/Views/WorkOrders/_Maintenance.cshtml | 5 +- .../User/Views/WorkOrders/_OrderFields.cshtml | 14 +- .../Areas/User/Views/WorkOrders/_Shell.cshtml | 13 +- .../Areas/User/Views/WorkOrders/_Tabs.cshtml | 4 +- .../User/Views/Workforce/AnnualFacts.cshtml | 6 +- .../User/Views/Workforce/Compensation.cshtml | 6 +- .../Workforce/CompensationProfile.cshtml | 9 +- .../User/Views/Workforce/Contractors.cshtml | 19 +- .../Areas/User/Views/Workforce/CostRun.cshtml | 9 +- .../User/Views/Workforce/CostRuns.cshtml | 9 +- .../User/Views/Workforce/Demographics.cshtml | 9 +- .../User/Views/Workforce/Employer.cshtml | 6 +- .../Views/Workforce/Establishments.cshtml | 19 +- .../Areas/User/Views/Workforce/Index.cshtml | 6 +- .../Areas/User/Views/Workforce/PayData.cshtml | 9 +- .../User/Views/Workforce/PayDataRun.cshtml | 13 +- .../User/Views/Workforce/ResourceCosts.cshtml | 19 +- .../Areas/User/Views/Workforce/Usage.cshtml | 17 +- .../User/Views/Workforce/WorkEntries.cshtml | 6 +- .../Areas/User/Views/Workforce/Worker.cshtml | 9 +- .../Areas/User/Views/Workforce/Workers.cshtml | 6 +- .../User/Views/Workforce/Worksheet.cshtml | 6 +- Web/Resgrid.Web/Helpers/DepartmentTime.cs | 76 +++++++ Web/Resgrid.Web/Startup.cs | 1 + .../wwwroot/css/module-workspace.css | 94 +++++++++ Web/Resgrid.Web/wwwroot/css/style.css | 100 +++++++++ Web/Resgrid.Web/wwwroot/css/workspace.css | 1 + .../workspace/resgrid.common.workspace.js | 3 + .../js/app/internal/workorders/work-orders.js | 6 +- Web/Resgrid.Web/wwwroot/scss/_custom.scss | 101 ++++++++- 351 files changed, 4215 insertions(+), 1688 deletions(-) create mode 100644 Core/Resgrid.Model/WorkOrders/WorkOrderAssignees.cs create mode 100644 Core/Resgrid.Model/WorkOrders/WorkOrderCurrencies.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0225_AddMaintenanceAssignees.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0226_AddWorkOrderCurrency.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0225_AddMaintenanceAssigneesPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0226_AddWorkOrderCurrencyPg.cs create mode 100644 Tests/Resgrid.Tests/Services/WorkOrderCompletionTests.cs create mode 100644 Tests/Resgrid.Tests/Services/WorkOrderGuidTests.cs create mode 100644 Tests/Resgrid.Tests/Services/WorkOrderMaintenanceAssignmentTests.cs create mode 100644 Tests/Resgrid.Tests/Services/WorkOrderSettingsDatabaseTests.cs create mode 100644 Tests/Resgrid.Tests/Services/WorkOrderSettingsTests.cs create mode 100644 Tests/Resgrid.Tests/Web/DepartmentTimeTests.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/WorkOrders/WorkOrderSettingChoices.cs create mode 100644 Web/Resgrid.Web/Areas/User/Views/Shared/_WorkspaceHeaderActions.cshtml rename Web/Resgrid.Web/Areas/User/Views/WorkOrders/{Policy.cshtml => Settings.cshtml} (73%) create mode 100644 Web/Resgrid.Web/Helpers/DepartmentTime.cs create mode 100644 Web/Resgrid.Web/wwwroot/css/module-workspace.css diff --git a/Core/Resgrid.Chatbot/Handlers/CallsActionHandler.cs b/Core/Resgrid.Chatbot/Handlers/CallsActionHandler.cs index 92fb23b05..fd802b80b 100644 --- a/Core/Resgrid.Chatbot/Handlers/CallsActionHandler.cs +++ b/Core/Resgrid.Chatbot/Handlers/CallsActionHandler.cs @@ -40,20 +40,25 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn var culture = session.Culture; try { + // Layer 2 for a list: every active member may list their department's calls, so the + // permission is decided once per request against session.DepartmentId. A per-row + // CanUserViewCallAsync answers the same membership question with two lookups per call. + if (!await _authorizationService.IsUserValidWithinLimitsAsync(session.UserId, session.DepartmentId)) + return new ChatbotResponse { Text = ChatbotResources.Get("Calls_NoPermission", culture), Processed = false }; + var department = await _departmentsService.GetDepartmentByIdAsync(session.DepartmentId); var departmentName = department?.Name ?? ChatbotResources.Get("Common_YourDepartment", culture); var activeCalls = await _callsService.GetActiveCallsByDepartmentAsync(session.DepartmentId); - if (activeCalls == null || !activeCalls.Any()) + // The department boundary is the per-row rule (the unread-messages list applies the same one). + var callList = (activeCalls ?? Enumerable.Empty()) + .Where(call => call != null && call.DepartmentId == session.DepartmentId) + .Take(10) + .ToList(); + + if (callList.Count == 0) return new ChatbotResponse { Text = ChatbotResources.Get("Calls_NoActive", culture, departmentName), Processed = true }; - var callList = new System.Collections.Generic.List(); - foreach (var call in activeCalls) - { - if (call.DepartmentId == session.DepartmentId && await _authorizationService.CanUserViewCallAsync(session.UserId, call.CallId)) - callList.Add(call); - if (callList.Count == 10) break; - } var sb = new StringBuilder(); sb.AppendLine(ChatbotResources.Get("Calls_Header", culture, departmentName)); sb.AppendLine("----------------------"); diff --git a/Core/Resgrid.Chatbot/Handlers/UnitsActionHandler.cs b/Core/Resgrid.Chatbot/Handlers/UnitsActionHandler.cs index 1f12da5e0..7e3ad306d 100644 --- a/Core/Resgrid.Chatbot/Handlers/UnitsActionHandler.cs +++ b/Core/Resgrid.Chatbot/Handlers/UnitsActionHandler.cs @@ -33,6 +33,12 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn var culture = session.Culture; try { + // Layer 2 for a list: every active member may list their department's units, so the + // permission is decided once per request against session.DepartmentId. A per-row + // CanUserViewUnitAsync answers the same membership question with two lookups per unit. + if (!await _authorizationService.IsUserValidWithinLimitsAsync(session.UserId, session.DepartmentId)) + return new ChatbotResponse { Text = ChatbotResources.Get("Units_NoPermission", culture), Processed = false }; + var department = await _departmentsService.GetDepartmentByIdAsync(session.DepartmentId); var departmentName = department?.Name ?? ChatbotResources.Get("Common_YourDepartment", culture); var unitStatuses = await _unitsService.GetAllLatestStatusForUnitsByDepartmentIdAsync(session.DepartmentId); @@ -47,9 +53,9 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn var listedCount = 0; foreach (var unitState in unitStatuses) { + // The department boundary is the per-row rule. var unit = unitState?.Unit; - if (unit == null || unit.DepartmentId != session.DepartmentId - || !await _authorizationService.CanUserViewUnitAsync(session.UserId, unit.UnitId)) + if (unit == null || unit.DepartmentId != session.DepartmentId) continue; var status = await _customStateService.GetCustomUnitStateAsync(unitState); diff --git a/Core/Resgrid.Chatbot/Handlers/UnitsAvailableActionHandler.cs b/Core/Resgrid.Chatbot/Handlers/UnitsAvailableActionHandler.cs index 0b8b0816d..f5cbdd948 100644 --- a/Core/Resgrid.Chatbot/Handlers/UnitsAvailableActionHandler.cs +++ b/Core/Resgrid.Chatbot/Handlers/UnitsAvailableActionHandler.cs @@ -43,6 +43,13 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn var culture = session.Culture; try { + // Layer 2 for a list: every active member may list their department's units, so the + // permission is decided once per request against session.DepartmentId. A per-row + // CanUserViewUnitAsync answers the same membership question with two lookups per unit — + // over the whole department here, since availability is classified before the cap. + if (!await _authorizationService.IsUserValidWithinLimitsAsync(session.UserId, session.DepartmentId)) + return new ChatbotResponse { Text = ChatbotResources.Get("Units_NoPermission", culture), Processed = false }; + var unitStatuses = await _unitsService.GetAllLatestStatusForUnitsByDepartmentIdAsync(session.DepartmentId); if (unitStatuses == null || !unitStatuses.Any()) @@ -51,12 +58,10 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn var lines = new StringBuilder(); var availableCount = 0; + // The department boundary is the per-row rule. foreach (var unitState in unitStatuses.Where(u => u?.Unit != null && u.Unit.DepartmentId == session.DepartmentId) .OrderBy(u => u.Unit.Name)) { - if (!await _authorizationService.CanUserViewUnitAsync(session.UserId, unitState.Unit.UnitId)) - continue; - var availability = await _platformReportingService.ClassifyUnitAvailabilityAsync(session.DepartmentId, unitState.State); if (availability != AvailabilityClass.Available) continue; diff --git a/Core/Resgrid.Chatbot/Localization/ChatbotResources.cs b/Core/Resgrid.Chatbot/Localization/ChatbotResources.cs index 64b9689cb..2a079a835 100644 --- a/Core/Resgrid.Chatbot/Localization/ChatbotResources.cs +++ b/Core/Resgrid.Chatbot/Localization/ChatbotResources.cs @@ -1195,6 +1195,17 @@ private static Dictionary EnOnly(string en) "Немає активних викликів для {0}.", "لا توجد بلاغات نشطة لـ {0}."), + ["Calls_NoPermission"] = L( + "You don't have permission to view calls for your department.", + "No tienes permiso para ver las llamadas de tu departamento.", + "Du har inte behörighet att se larm för din avdelning.", + "Sie haben keine Berechtigung, die Einsätze Ihrer Abteilung anzuzeigen.", + "Vous n'avez pas la permission de voir les appels de votre département.", + "Non hai il permesso di visualizzare le chiamate del tuo dipartimento.", + "Nie masz uprawnień do wyświetlania zgłoszeń swojego działu.", + "У вас немає дозволу переглядати виклики вашого підрозділу.", + "ليس لديك إذن لعرض بلاغات قسمك."), + ["Calls_Header"] = L( "Active Calls for {0}:", "Llamadas activas para {0}:", @@ -1231,6 +1242,17 @@ private static Dictionary EnOnly(string en) "Підрозділів для вашого відділу не знайдено.", "لم يتم العثور على وحدات لقسمك."), + ["Units_NoPermission"] = L( + "You don't have permission to view units for your department.", + "No tienes permiso para ver las unidades de tu departamento.", + "Du har inte behörighet att se enheter för din avdelning.", + "Sie haben keine Berechtigung, die Einheiten Ihrer Abteilung anzuzeigen.", + "Vous n'avez pas la permission de voir les unités de votre département.", + "Non hai il permesso di visualizzare le unità del tuo dipartimento.", + "Nie masz uprawnień do wyświetlania jednostek swojego działu.", + "У вас немає дозволу переглядати підрозділи вашого відділу.", + "ليس لديك إذن لعرض وحدات قسمك."), + ["Units_Header"] = L( "Unit Statuses for {0}:", "Estados de unidades para {0}:", diff --git a/Core/Resgrid.Framework/DateTimeHelpers.cs b/Core/Resgrid.Framework/DateTimeHelpers.cs index f6303c64d..b81b3b7a6 100644 --- a/Core/Resgrid.Framework/DateTimeHelpers.cs +++ b/Core/Resgrid.Framework/DateTimeHelpers.cs @@ -96,6 +96,25 @@ public static int FindDay(int year, int month, DayOfWeek day, int occurance) return resultedDay; } + /// + /// True when is a Windows zone id that and + /// can resolve (blank counts as "no zone", which both accept). + /// + public static bool IsKnownTimeZone(string timeZone) + { + if (String.IsNullOrWhiteSpace(timeZone)) + return true; + + try + { + return DateTimeZoneProviders.Tzdb.GetZoneOrNull(TZConvert.WindowsToIana(timeZone)) != null; + } + catch (Exception) + { + return false; + } + } + /// /// Converts a local (department) time into UTC. When lenient is set, local times that are /// ambiguous or skipped by a DST transition are resolved instead of throwing, which matters diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.ar.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.ar.resx index a0a151963..71f0939d7 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.ar.resx @@ -663,7 +663,7 @@ الوحدات المرسلة المعدات المصروفة وقت البلاغ تحتوي الرسالة المجدولة على إشعار PDF ورابط آمن للتقرير. افتح التقرير للتحقق من صلاحياتك الحالية وفتح البيانات المحمية؛ لا تُرسل نتائج الفحوص أو البيانات الشخصية بالبريد الإلكتروني. - حدد فترة زمنية مرتبة بتوقيت UTC لا تتجاوز 93 يوماً. + حدد فترة زمنية مرتبة بتوقيت لا تتجاوز 93 يوماً. هذا التقرير كبير جداً. حدد فترة أقصر. البلاغ غير متاح أو تم رفض الوصول. بيانات المصدر التاريخي للمعدات ناقصة أو غير متسقة. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.de.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.de.resx index a76c200fc..57480ed1e 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.de.resx @@ -663,7 +663,7 @@ Alarmierte Einheiten Zum Einsatzzeitpunkt ausgegebene Ausrüstung Der geplante Versand enthält einen PDF-Hinweis und einen sicheren Berichtslink. Öffnen Sie den Bericht, um Ihre aktuelle Berechtigung zu prüfen und geschützte Daten freizugeben. Prüfergebnisse und personenbezogene Daten werden nicht per E-Mail versendet. - Wählen Sie einen chronologischen UTC-Zeitraum von höchstens 93 Tagen. + Wählen Sie einen chronologischen Zeitraum von höchstens 93 Tagen. Dieser Bericht ist zu groß. Wählen Sie einen kürzeren Zeitraum. Der Einsatz ist nicht verfügbar oder der Zugriff wurde verweigert. Die Herkunftsnachweise der früheren Ausrüstung sind unvollständig oder widersprüchlich. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.el.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.el.resx index 854603a66..85695c871 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.el.resx @@ -663,7 +663,7 @@ Μονάδες που κινητοποιήθηκαν Εξοπλισμός που είχε χορηγηθεί κατά το συμβάν Η προγραμματισμένη αποστολή περιέχει ειδοποίηση PDF και ασφαλή σύνδεσμο αναφοράς. Ανοίξτε την αναφορά για επαλήθευση της τρέχουσας πρόσβασης και ξεκλείδωμα προστατευμένων δεδομένων· αποτελέσματα ελέγχων και προσωπικά δεδομένα δεν αποστέλλονται με ηλεκτρονικό ταχυδρομείο. - Επιλέξτε χρονολογική περίοδο UTC έως 93 ημερών. + Επιλέξτε χρονολογική περίοδο έως 93 ημερών. Η αναφορά είναι πολύ μεγάλη. Επιλέξτε μικρότερη περίοδο. Το συμβάν δεν είναι διαθέσιμο ή δεν επιτρέπεται η πρόσβαση. Η προέλευση του παλαιότερου εξοπλισμού είναι ελλιπής ή ασυνεπής. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.en.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.en.resx index ec2e768e4..ef9b91eb8 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.en.resx @@ -663,7 +663,7 @@ Dispatched units Equipment issued at call time Scheduled delivery contains a PDF notice and a secure report link. Open the report to verify your current access and unlock protected data; checklist results and personal data are not emailed. - Select a chronological UTC period of at most 93 days. + Select a chronological period of at most 93 days. This report is too large. Select a shorter period. The call is unavailable or access is denied. Historical equipment provenance is incomplete or inconsistent. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx index 429bc4d8c..1c8a0623d 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx @@ -663,7 +663,7 @@ Unidades movilizadas Equipo entregado en el momento del incidente El envío programado contiene un aviso PDF y un enlace seguro al informe. Abra el informe para verificar su acceso actual y desbloquear datos protegidos; no se envían por correo resultados de verificaciones ni datos personales. - Seleccione un período UTC cronológico de hasta 93 días. + Seleccione un período cronológico de hasta 93 días. Este informe es demasiado grande. Seleccione un período más corto. El incidente no está disponible o se ha denegado el acceso. La procedencia histórica del equipo está incompleta o es incoherente. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.fr.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.fr.resx index 1ea8e0e7f..4343471d8 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.fr.resx @@ -663,7 +663,7 @@ Unités mobilisées Équipement attribué au moment de l’intervention L’envoi programmé contient un avis PDF et un lien sécurisé vers le rapport. Ouvrez le rapport pour vérifier vos droits actuels et déverrouiller les données protégées ; les résultats de contrôle et les données personnelles ne sont pas envoyés par courriel. - Sélectionnez une période UTC chronologique de 93 jours au maximum. + Sélectionnez une période chronologique de 93 jours au maximum. Ce rapport est trop volumineux. Sélectionnez une période plus courte. L’intervention est indisponible ou l’accès est refusé. La provenance historique de l’équipement est incomplète ou incohérente. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.it.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.it.resx index 1a895400e..6c1ac3b02 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.it.resx @@ -663,7 +663,7 @@ Unità mobilitate Attrezzatura assegnata al momento dell’intervento L’invio programmato contiene un avviso PDF e un collegamento sicuro al rapporto. Apri il rapporto per verificare l’accesso attuale e sbloccare i dati protetti; i risultati dei controlli e i dati personali non vengono inviati per e-mail. - Seleziona un periodo UTC cronologico di massimo 93 giorni. + Seleziona un periodo cronologico di massimo 93 giorni. Questo rapporto è troppo grande. Seleziona un periodo più breve. L’intervento non è disponibile o l’accesso è negato. La provenienza storica dell’attrezzatura è incompleta o incoerente. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.pl.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.pl.resx index 7a27b5cad..0b2888f1c 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.pl.resx @@ -663,7 +663,7 @@ Zadysponowane jednostki Sprzęt wydany w chwili zdarzenia Zaplanowana wysyłka zawiera powiadomienie PDF i bezpieczny odnośnik do raportu. Otwórz raport, aby zweryfikować bieżący dostęp i odblokować chronione dane; wyniki kontroli i dane osobowe nie są wysyłane pocztą elektroniczną. - Wybierz chronologiczny okres UTC nie dłuższy niż 93 dni. + Wybierz chronologiczny okres nie dłuższy niż 93 dni. Ten raport jest zbyt duży. Wybierz krótszy okres. Zdarzenie jest niedostępne lub odmówiono dostępu. Historyczne pochodzenie sprzętu jest niepełne lub niespójne. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.resx index ec2e768e4..ef9b91eb8 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.resx @@ -663,7 +663,7 @@ Dispatched units Equipment issued at call time Scheduled delivery contains a PDF notice and a secure report link. Open the report to verify your current access and unlock protected data; checklist results and personal data are not emailed. - Select a chronological UTC period of at most 93 days. + Select a chronological period of at most 93 days. This report is too large. Select a shorter period. The call is unavailable or access is denied. Historical equipment provenance is incomplete or inconsistent. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.sv.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.sv.resx index e54247d70..4e6daa946 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.sv.resx @@ -663,7 +663,7 @@ Larmade enheter Utrustning utlämnad vid insatstidpunkten Den schemalagda leveransen innehåller ett PDF-meddelande och en säker rapportlänk. Öppna rapporten för att kontrollera din aktuella behörighet och låsa upp skyddade uppgifter; kontrollresultat och personuppgifter skickas inte med e-post. - Välj en kronologisk UTC-period på högst 93 dagar. + Välj en kronologisk period på högst 93 dagar. Rapporten är för stor. Välj en kortare period. Insatsen är inte tillgänglig eller åtkomst nekas. Utrustningens historiska ursprungsuppgifter är ofullständiga eller motsägelsefulla. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.uk.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.uk.resx index db66cb828..32a40bf63 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.uk.resx @@ -663,7 +663,7 @@ Направлені підрозділи Спорядження, видане на момент події Запланована доставка містить PDF-повідомлення та захищене посилання на звіт. Відкрийте звіт, щоб перевірити поточні права доступу й розблокувати захищені дані; результати перевірок і персональні дані не надсилаються електронною поштою. - Виберіть хронологічний період UTC тривалістю не більше 93 днів. + Виберіть хронологічний період тривалістю не більше 93 днів. Цей звіт завеликий. Виберіть коротший період. Подія недоступна або в доступі відмовлено. Історичні дані про походження спорядження неповні або суперечливі. diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.ar.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.ar.resx index 6979aa834..d8080f173 100644 --- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.ar.resx @@ -41,7 +41,7 @@ البلد المضيف الولاية / المقاطعة المضيفة المنطقة الزمنية - المنطقة الزمنية المحلية للحادث لتقارير الوقت؛ الفراغ يستخدم منطقة القسم. + المنطقة الزمنية لمحاسبة الانتشار. تستخدم التواريخ والأوقات في هذه الصفحات المنطقة الزمنية للقسم. العملة خارج المقاطعة / الولاية السفر جوًا @@ -134,7 +134,7 @@ بدون راحة 8 ساعات (يستمر الوقت الإضافي إلى الوردية التالية) توقف بسبب ظروف غير آمنة إدخالات الوقت - الأوقات بالتوقيت المحلي للحادث + الأوقات حسب المنطقة الزمنية للقسم الموضوع نوع الإدخال البداية @@ -215,6 +215,7 @@ اختر وضعًا ماليًا. يجب ألا تكون النهاية قبل البداية. يجب أن يكون الخصم بين 0 و100 بالمئة. + لم يتم التعرف على المنطقة الزمنية. أدخل معرّف منطقة زمنية لنظام Windows مثل "Pacific Standard Time"، أو اتركه فارغًا لاستخدام منطقة القسم. لم يتم العثور على جهة الاتصال. لم يتم العثور على النداء. هذا النداء لديه انتشار بالفعل. diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.de.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.de.resx index f396c6cea..54a93032b 100644 --- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.de.resx @@ -41,7 +41,7 @@ Gastland Gast-Bundesland / -Provinz Zeitzone - Zeitzone am Ereignisort für Zeitberichte; leer verwendet die der Organisation. + Zeitzone für die Einsatzabrechnung. Datum und Uhrzeit auf diesen Seiten verwenden die Zeitzone der Abteilung. Währung Außerhalb der Provinz / des Bundeslandes Anreise per Flugzeug @@ -134,7 +134,7 @@ Keine 8 Stunden Ruhe (Überstunden laufen in die nächste Schicht) Abbruch wegen unsicherer Bedingungen Zeiteinträge - Zeiten in Ereignis-Ortszeit + Zeiten in der Zeitzone der Abteilung Betreff Eintragsart Beginn @@ -215,6 +215,7 @@ Wählen Sie eine Finanzierungsart. Das Ende darf nicht vor dem Beginn liegen. Der Rabatt muss zwischen 0 und 100 Prozent liegen. + Die Zeitzone wurde nicht erkannt. Geben Sie eine Windows-Zeitzonen-ID wie "Pacific Standard Time" ein oder lassen Sie das Feld leer, um die der Organisation zu verwenden. Der Kontakt wurde nicht gefunden. Der Alarm wurde nicht gefunden. Dieser Alarm hat bereits einen Einsatz. diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.el.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.el.resx index c9f499926..389bb5633 100644 --- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.el.resx @@ -41,7 +41,7 @@ Χώρα υποδοχής Πολιτεία / επαρχία υποδοχής Ζώνη ώρας - Τοπική ζώνη ώρας του συμβάντος για τις αναφορές χρόνου· κενό χρησιμοποιεί αυτήν του τμήματος. + Ζώνη ώρας για τη λογιστική της ανάπτυξης. Οι ημερομηνίες και οι ώρες σε αυτές τις σελίδες χρησιμοποιούν τη ζώνη ώρας του τμήματος. Νόμισμα Εκτός επαρχίας / πολιτείας Μετακίνηση αεροπορικώς @@ -134,7 +134,7 @@ Χωρίς 8ωρη ανάπαυση (οι υπερωρίες μεταφέρονται στην επόμενη βάρδια) Διακοπή λόγω μη ασφαλών συνθηκών Καταχωρίσεις χρόνου - οι ώρες είναι σε τοπική ώρα συμβάντος + ώρες στη ζώνη ώρας του τμήματος Υποκείμενο Τύπος καταχώρισης Έναρξη @@ -215,6 +215,7 @@ Επιλέξτε οικονομική λειτουργία. Η λήξη δεν μπορεί να προηγείται της έναρξης. Η έκπτωση πρέπει να είναι μεταξύ 0 και 100 τοις εκατό. + Η ζώνη ώρας δεν αναγνωρίζεται. Εισαγάγετε ένα αναγνωριστικό ζώνης ώρας των Windows, όπως "Pacific Standard Time", ή αφήστε το κενό για να χρησιμοποιηθεί αυτή του τμήματος. Η επαφή δεν βρέθηκε. Η κλήση δεν βρέθηκε. Αυτή η κλήση έχει ήδη αποστολή. diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.en.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.en.resx index 227e7f5c0..6c0a0820f 100644 --- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.en.resx @@ -41,7 +41,7 @@ Host country Host state / province Time zone - Incident-local time zone for time reports; blank uses the department's. + Time zone for deployment accounting. Dates and times on these pages use the department's time zone. Currency Out of province / state Travel via air @@ -134,7 +134,7 @@ No clear 8 (OT carries into the next shift) Unsafe conditions stand-down Time entries - times are incident-local + times use the department time zone Subject Entry type Start @@ -215,6 +215,7 @@ Choose a finance mode. The end must not be before the start. The discount must be between 0 and 100 percent. + The time zone is not recognized. Enter a Windows time zone id such as "Pacific Standard Time", or leave it blank to use the department's. The contact was not found. The call was not found. That call already has a deployment. diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.es.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.es.resx index fd5e509b8..40c2fee2f 100644 --- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.es.resx @@ -41,7 +41,7 @@ País anfitrión Estado / provincia anfitrión Zona horaria - Zona horaria local del incidente para los partes de horas; en blanco usa la del departamento. + Zona horaria para la contabilidad del despliegue. Las fechas y horas de estas páginas usan la zona horaria del departamento. Moneda Fuera de la provincia / estado Viaje en avión @@ -134,7 +134,7 @@ Sin descanso de 8 h (las horas extra pasan al siguiente turno) Parada por condiciones inseguras Entradas de tiempo - horas en hora local del incidente + horas en la zona horaria del departamento Sujeto Tipo de entrada Inicio @@ -215,6 +215,7 @@ Elija un modo financiero. El fin no puede ser anterior al inicio. El descuento debe estar entre 0 y 100 por ciento. + No se reconoce la zona horaria. Introduce un identificador de zona horaria de Windows como "Pacific Standard Time" o déjalo en blanco para usar la del departamento. No se encontró el contacto. No se encontró la llamada. Esa llamada ya tiene un despliegue. diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.fr.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.fr.resx index 5def6a7e8..70b3c5858 100644 --- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.fr.resx @@ -41,7 +41,7 @@ Pays hôte État / province hôte Fuseau horaire - Fuseau horaire local de l'incident pour les rapports de temps ; vide utilise celui du service. + Fuseau horaire utilisé pour la comptabilité du déploiement. Les dates et heures de ces pages utilisent le fuseau horaire du service. Devise Hors province / État Déplacement par avion @@ -134,7 +134,7 @@ Pas de repos de 8 h (les heures supplémentaires se reportent sur le quart suivant) Arrêt pour conditions dangereuses Entrées de temps - heures en heure locale de l'incident + heures dans le fuseau horaire du service Sujet Type d'entrée Début @@ -215,6 +215,7 @@ Choisissez un mode financier. La fin ne peut pas précéder le début. La remise doit être comprise entre 0 et 100 pour cent. + Le fuseau horaire n'est pas reconnu. Saisissez un identifiant de fuseau horaire Windows tel que "Pacific Standard Time", ou laissez vide pour utiliser celui du service. Le contact est introuvable. L'appel est introuvable. Cet appel a déjà un déploiement. diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.it.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.it.resx index 48ffe374d..99c5590eb 100644 --- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.it.resx @@ -41,7 +41,7 @@ Paese ospitante Stato / provincia ospitante Fuso orario - Fuso orario locale dell'incidente per i rapporti delle ore; vuoto usa quello del dipartimento. + Fuso orario per la contabilità del dispiegamento. Le date e gli orari di queste pagine usano il fuso orario del dipartimento. Valuta Fuori provincia / stato Viaggio in aereo @@ -134,7 +134,7 @@ Nessun riposo di 8 ore (lo straordinario passa al turno successivo) Sospensione per condizioni non sicure Voci di tempo - orari in ora locale dell'incidente + orari nel fuso orario del dipartimento Soggetto Tipo di voce Inizio @@ -215,6 +215,7 @@ Scegli una modalità finanziaria. La fine non può precedere l'inizio. Lo sconto deve essere compreso tra 0 e 100 per cento. + Il fuso orario non è riconosciuto. Inserisci un identificatore di fuso orario Windows come "Pacific Standard Time" oppure lascia vuoto per usare quello del dipartimento. Contatto non trovato. Chiamata non trovata. Quella chiamata ha già un impiego. diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.pl.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.pl.resx index 1e8f5623e..c730e9163 100644 --- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.pl.resx @@ -41,7 +41,7 @@ Kraj przyjmujący Stan / województwo przyjmujące Strefa czasowa - Lokalna strefa czasowa zdarzenia dla raportów czasu; puste oznacza strefę jednostki organizacyjnej. + Strefa czasowa do rozliczania działań. Daty i godziny na tych stronach są wyświetlane w strefie czasowej jednostki. Waluta Poza województwem / stanem Podróż samolotem @@ -134,7 +134,7 @@ Brak 8 godzin odpoczynku (nadgodziny przechodzą na następną zmianę) Wstrzymanie z powodu niebezpiecznych warunków Wpisy czasu - czasy w lokalnej strefie zdarzenia + godziny w strefie czasowej jednostki Podmiot Typ wpisu Początek @@ -215,6 +215,7 @@ Wybierz tryb finansowy. Koniec nie może być wcześniejszy niż początek. Rabat musi mieścić się między 0 a 100 procent. + Strefa czasowa nie została rozpoznana. Wpisz identyfikator strefy czasowej Windows, np. "Pacific Standard Time", albo pozostaw puste, aby użyć strefy jednostki organizacyjnej. Nie znaleziono kontaktu. Nie znaleziono zgłoszenia. To zgłoszenie ma już dyslokację. diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.resx index 227e7f5c0..6c0a0820f 100644 --- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.resx +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.resx @@ -41,7 +41,7 @@ Host country Host state / province Time zone - Incident-local time zone for time reports; blank uses the department's. + Time zone for deployment accounting. Dates and times on these pages use the department's time zone. Currency Out of province / state Travel via air @@ -134,7 +134,7 @@ No clear 8 (OT carries into the next shift) Unsafe conditions stand-down Time entries - times are incident-local + times use the department time zone Subject Entry type Start @@ -215,6 +215,7 @@ Choose a finance mode. The end must not be before the start. The discount must be between 0 and 100 percent. + The time zone is not recognized. Enter a Windows time zone id such as "Pacific Standard Time", or leave it blank to use the department's. The contact was not found. The call was not found. That call already has a deployment. diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.sv.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.sv.resx index 89ec4a1a2..8295aed13 100644 --- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.sv.resx @@ -41,7 +41,7 @@ Värdland Värdstat / -provins Tidszon - Händelsens lokala tidszon för tidrapporter; tomt använder avdelningens. + Tidszon för insatsens redovisning. Datum och tider på dessa sidor använder avdelningens tidszon. Valuta Utanför provinsen / delstaten Resa med flyg @@ -134,7 +134,7 @@ Ingen 8 timmars vila (övertid förs över till nästa pass) Avbrott på grund av osäkra förhållanden Tidposter - tider i händelsens lokala tid + tider i avdelningens tidszon Objekt Posttyp Start @@ -215,6 +215,7 @@ Välj ett ekonomiläge. Slutet får inte ligga före starten. Rabatten måste vara mellan 0 och 100 procent. + Tidszonen känns inte igen. Ange ett Windows-tidszons-id som "Pacific Standard Time" eller lämna tomt för att använda avdelningens. Kontakten hittades inte. Larmet hittades inte. Det larmet har redan en insats. diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.uk.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.uk.resx index 6a999cb0d..02e0c4a45 100644 --- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.uk.resx @@ -41,7 +41,7 @@ Країна приймання Штат / область приймання Часовий пояс - Місцевий часовий пояс події для звітів про час; порожнє значення використовує пояс підрозділу. + Часовий пояс для обліку розгортання. Дати й час на цих сторінках використовують часовий пояс підрозділу. Валюта За межами області / штату Подорож літаком @@ -134,7 +134,7 @@ Без 8 годин відпочинку (понаднормові переходять у наступну зміну) Зупинка через небезпечні умови Записи часу - час у місцевому часі події + час у часовому поясі підрозділу Суб'єкт Тип запису Початок @@ -215,6 +215,7 @@ Виберіть фінансовий режим. Кінець не може бути раніше початку. Знижка має бути від 0 до 100 відсотків. + Часовий пояс не розпізнано. Введіть ідентифікатор часового поясу Windows, наприклад "Pacific Standard Time", або залиште порожнім, щоб використати пояс підрозділу. Контакт не знайдено. Виклик не знайдено. Цей виклик уже має розгортання. diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx index 51b8609ff..638a5ea84 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx @@ -2005,9 +2005,9 @@ اتركه فارغًا لحفظ كل ما يطابق تعريفًا ونطاقًا زمنيًا بدلًا من ذلك. نطاق التعريف حادث وتحليل NERIS - لنطاق التعريف، اترك كلا التاريخين فارغين لحفظ جميع التواريخ. الأوقات بتوقيت UTC. - بداية الفترة (UTC) - نهاية الفترة (UTC) + لنطاق التعريف، اترك كلا التاريخين فارغين لحفظ جميع التواريخ. الأوقات بتوقيت التوقيت المحلي للقسم. + بداية الفترة + نهاية الفترة أساس الحفظ تقاضٍ تحقيق @@ -2038,9 +2038,9 @@ اختر قناة يتطلب وصولًا مقيّدًا لن تُسجَّل أي أدلة لمصدر غير متاح. - بداية التغطية (UTC) - نهاية التغطية (UTC) - وقت الحادث لتحديد الصلاحية (UTC) + بداية التغطية + نهاية التغطية + وقت الحادث لتحديد الصلاحية الأفراد الرسائل لا توجد عناصر متاحة في هذه الصفحة. @@ -2056,7 +2056,7 @@ لم يُسجَّل معرّف آخر مشكلة سجل التسليم والاستجابة - الأوقات بتوقيت UTC. تبقى الاستجابات السابقة متاحة بعد الرفض وإعادة المحاولة والتصحيح. + الأوقات بتوقيت التوقيت المحلي للقسم. تبقى الاستجابات السابقة متاحة بعد الرفض وإعادة المحاولة والتصحيح. لم يُسجَّل سجل تبادل لهذا الإيداع القديم. العملية والمرحلة استجابة الوجهة diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx index 5e21b26ba..3017dfc4c 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx @@ -2005,9 +2005,9 @@ Leer lassen, um stattdessen alles zu sperren, was Definition und Zeitraum entspricht. Definitionsbereich NERIS-Einsatz und Analyse - Bei einem Definitionsbereich beide Daten leer lassen, um alle Zeiträume aufzubewahren. Zeiten sind UTC. - Beginn des Zeitraums (UTC) - Ende des Zeitraums (UTC) + Bei einem Definitionsbereich beide Daten leer lassen, um alle Zeiträume aufzubewahren. Zeiten sind Ortszeit der Abteilung. + Beginn des Zeitraums + Ende des Zeitraums Grundlage der Sperre Rechtsstreit Ermittlung @@ -2038,9 +2038,9 @@ Kanal auswählen eingeschränkter Zugriff erforderlich Für eine nicht verfügbare Quelle werden keine Nachweise erfasst. - Beginn des Erfassungszeitraums (UTC) - Ende des Erfassungszeitraums (UTC) - Einsatzzeitpunkt für die Gültigkeit (UTC) + Beginn des Erfassungszeitraums + Ende des Erfassungszeitraums + Einsatzzeitpunkt für die Gültigkeit Einsatzkräfte Nachrichten Auf dieser Seite sind keine zugänglichen Einträge vorhanden. @@ -2056,7 +2056,7 @@ Keine Kennung erfasst Letztes Problem Zustell- und Antwortverlauf - Zeiten in UTC. Frühere Antworten bleiben nach Ablehnung, Wiederholung und Korrektur verfügbar. + Zeiten in Ortszeit der Abteilung. Frühere Antworten bleiben nach Ablehnung, Wiederholung und Korrektur verfügbar. Für diese Alt-Übermittlung wurde kein Austauschverlauf erfasst. Vorgang und Phase Antwort des Ziels diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx index 51b26a749..125abea26 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx @@ -2005,9 +2005,9 @@ Αφήστε κενό για να δεσμεύσετε ό,τι ταιριάζει σε ορισμό και εύρος ημερομηνιών. Εύρος ορισμού Συμβάν και ανάλυση NERIS - Για εύρος ορισμού, αφήστε και τις δύο ημερομηνίες κενές για να διατηρηθούν όλες. Ώρες σε UTC. - Έναρξη περιόδου (UTC) - Λήξη περιόδου (UTC) + Για εύρος ορισμού, αφήστε και τις δύο ημερομηνίες κενές για να διατηρηθούν όλες. Ώρες σε τοπική ώρα του τμήματος. + Έναρξη περιόδου + Λήξη περιόδου Βάση της δέσμευσης Δικαστική διαφορά Έρευνα @@ -2038,9 +2038,9 @@ Επιλέξτε κανάλι απαιτείται περιορισμένη πρόσβαση Δεν καταγράφονται τεκμήρια για μη διαθέσιμη πηγή. - Έναρξη κάλυψης (UTC) - Λήξη κάλυψης (UTC) - Ώρα συμβάντος για την ισχύ (UTC) + Έναρξη κάλυψης + Λήξη κάλυψης + Ώρα συμβάντος για την ισχύ Προσωπικό Μηνύματα Δεν υπάρχουν προσβάσιμα στοιχεία σε αυτή τη σελίδα. @@ -2056,7 +2056,7 @@ Δεν καταγράφηκε αναγνωριστικό Τελευταίο ζήτημα Ιστορικό παράδοσης και απόκρισης - Ώρες σε UTC. Οι προηγούμενες αποκρίσεις παραμένουν διαθέσιμες μετά από απόρριψη, επανάληψη και διόρθωση. + Ώρες σε τοπική ώρα του τμήματος. Οι προηγούμενες αποκρίσεις παραμένουν διαθέσιμες μετά από απόρριψη, επανάληψη και διόρθωση. Δεν καταγράφηκε ιστορικό ανταλλαγής για αυτή την παλαιά υποβολή. Λειτουργία και στάδιο Απόκριση προορισμού diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx index f6c8bfb12..bb783300e 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx @@ -2005,9 +2005,9 @@ Leave blank to hold everything matching a definition and date scope instead. Definition scope NERIS incident and analysis - For a definition scope, leave both dates blank to preserve every date. Times are UTC. - Period start (UTC) - Period end (UTC) + For a definition scope, leave both dates blank to preserve every date. Times are department local time. + Period start + Period end Basis for the hold Litigation Investigation @@ -2038,9 +2038,9 @@ Choose a channel restricted access required No evidence will be recorded for an unavailable source. - Coverage start (UTC) - Coverage end (UTC) - Incident time for validity (UTC) + Coverage start + Coverage end + Incident time for validity Personnel Messages No accessible items on this page. @@ -2056,7 +2056,7 @@ No identifier recorded Latest issue Delivery and response history - Times are UTC. Earlier responses stay available after a rejection, a retry and a correction. + Times are department local time. Earlier responses stay available after a rejection, a retry and a correction. No exchange history was recorded for this legacy submission. Operation and stage Destination response diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx index 7126f5820..2d722fd2d 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx @@ -2005,9 +2005,9 @@ Déjelo en blanco para retener todo lo que coincida con una definición y un rango de fechas. Alcance por definición Incidente y análisis NERIS - Para un alcance por definición, deje ambas fechas vacías para conservar todas. Las horas son UTC. - Inicio del periodo (UTC) - Fin del periodo (UTC) + Para un alcance por definición, deje ambas fechas vacías para conservar todas. Las horas son hora local del departamento. + Inicio del periodo + Fin del periodo Base de la retención Litigio Investigación @@ -2038,9 +2038,9 @@ Elija un canal requiere acceso restringido No se registrará evidencia para un origen no disponible. - Inicio de la cobertura (UTC) - Fin de la cobertura (UTC) - Hora del incidente para la validez (UTC) + Inicio de la cobertura + Fin de la cobertura + Hora del incidente para la validez Personal Mensajes No hay elementos accesibles en esta página. @@ -2056,7 +2056,7 @@ No se registró identificador Último problema Historial de entrega y respuesta - Las horas son UTC. Las respuestas anteriores siguen disponibles tras un rechazo, un reintento y una corrección. + Las horas son hora local del departamento. Las respuestas anteriores siguen disponibles tras un rechazo, un reintento y una corrección. No se registró historial de intercambio para este envío heredado. Operación y fase Respuesta del destino diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx index 02d5278b5..6351f0623 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx @@ -2005,9 +2005,9 @@ Laissez vide pour conserver tout ce qui correspond à une définition et une plage de dates. Portée par définition Intervention et analyse NERIS - Pour une portée par définition, laissez les deux dates vides pour tout conserver. Heures en UTC. - Début de la période (UTC) - Fin de la période (UTC) + Pour une portée par définition, laissez les deux dates vides pour tout conserver. Heures en heure locale du département. + Début de la période + Fin de la période Fondement de la conservation Contentieux Enquête @@ -2038,9 +2038,9 @@ Choisir un canal accès restreint requis Aucune preuve ne sera enregistrée pour une source indisponible. - Début de la couverture (UTC) - Fin de la couverture (UTC) - Heure de l'intervention pour la validité (UTC) + Début de la couverture + Fin de la couverture + Heure de l'intervention pour la validité Personnels Messages du canal Aucun élément accessible sur cette page. @@ -2056,7 +2056,7 @@ Aucun identifiant enregistré Dernier incident Historique d'envoi et de réponse - Heures en UTC. Les réponses antérieures restent disponibles après un rejet, une nouvelle tentative et une correction. + Heures en heure locale du département. Les réponses antérieures restent disponibles après un rejet, une nouvelle tentative et une correction. Aucun historique d'échange n'a été enregistré pour cet envoi hérité. Opération et étape Réponse de la destination diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx index d6e0707ee..af351d252 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx @@ -2005,9 +2005,9 @@ Lascia vuoto per bloccare tutto ciò che corrisponde a una definizione e a un intervallo di date. Ambito per definizione Intervento e analisi NERIS - Per un ambito per definizione, lascia vuote entrambe le date per conservare tutto. Orari in UTC. - Inizio periodo (UTC) - Fine periodo (UTC) + Per un ambito per definizione, lascia vuote entrambe le date per conservare tutto. Orari in ora locale del dipartimento. + Inizio periodo + Fine periodo Base del blocco Contenzioso Indagine @@ -2038,9 +2038,9 @@ Scegli un canale richiede accesso riservato Non verrà registrata alcuna prova per un'origine non disponibile. - Inizio della copertura (UTC) - Fine della copertura (UTC) - Orario dell'intervento per la validità (UTC) + Inizio della copertura + Fine della copertura + Orario dell'intervento per la validità Personale Messaggi Nessun elemento accessibile in questa pagina. @@ -2056,7 +2056,7 @@ Nessun identificatore registrato Ultimo problema Storico consegne e risposte - Orari in UTC. Le risposte precedenti restano disponibili dopo un rifiuto, un nuovo tentativo e una correzione. + Orari in ora locale del dipartimento. Le risposte precedenti restano disponibili dopo un rifiuto, un nuovo tentativo e una correzione. Per questo invio legacy non è stato registrato alcuno storico di scambio. Operazione e fase Risposta della destinazione diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx index 7471f5fb6..6842a01c3 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx @@ -2005,9 +2005,9 @@ Pozostaw puste, aby objąć wszystko pasujące do definicji i zakresu dat. Zakres definicji Zdarzenie i analiza NERIS - Dla zakresu definicji pozostaw obie daty puste, aby zachować wszystkie. Czas w UTC. - Początek okresu (UTC) - Koniec okresu (UTC) + Dla zakresu definicji pozostaw obie daty puste, aby zachować wszystkie. Czas w czas lokalny jednostki. + Początek okresu + Koniec okresu Podstawa blokady Spór sądowy Dochodzenie @@ -2038,9 +2038,9 @@ Wybierz kanał wymaga dostępu ograniczonego Dla niedostępnego źródła nie zostaną zapisane żadne dowody. - Początek zakresu (UTC) - Koniec zakresu (UTC) - Czas zdarzenia dla ważności (UTC) + Początek zakresu + Koniec zakresu + Czas zdarzenia dla ważności Personel Wiadomości Brak dostępnych elementów na tej stronie. @@ -2056,7 +2056,7 @@ Nie zapisano identyfikatora Ostatni problem Historia dostarczeń i odpowiedzi - Czas w UTC. Wcześniejsze odpowiedzi pozostają dostępne po odrzuceniu, ponowieniu i korekcie. + Czas w czas lokalny jednostki. Wcześniejsze odpowiedzi pozostają dostępne po odrzuceniu, ponowieniu i korekcie. Dla tej starszej wysyłki nie zapisano historii wymiany. Operacja i etap Odpowiedź celu diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx index ddbf1705f..2d19477e9 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx @@ -2005,9 +2005,9 @@ Lämna tomt för att i stället spärra allt som matchar en definition och ett datumintervall. Definitionsomfattning NERIS-händelse och analys - För en definitionsomfattning, lämna båda datumen tomma för att bevara alla. Tider i UTC. - Periodens start (UTC) - Periodens slut (UTC) + För en definitionsomfattning, lämna båda datumen tomma för att bevara alla. Tider i avdelningens lokala tid. + Periodens start + Periodens slut Grund för spärren Rättstvist Utredning @@ -2038,9 +2038,9 @@ Välj en kanal kräver begränsad behörighet Inga bevis registreras för en otillgänglig källa. - Täckningens start (UTC) - Täckningens slut (UTC) - Händelsetid för giltighet (UTC) + Täckningens start + Täckningens slut + Händelsetid för giltighet Personal Meddelanden Inga åtkomliga poster på den här sidan. @@ -2056,7 +2056,7 @@ Ingen identifierare registrerad Senaste problemet Leverans- och svarshistorik - Tider i UTC. Tidigare svar finns kvar efter avslag, nytt försök och rättelse. + Tider i avdelningens lokala tid. Tidigare svar finns kvar efter avslag, nytt försök och rättelse. Ingen utbyteshistorik registrerades för den här äldre inlämningen. Åtgärd och steg Destinationens svar diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx index 4ecdd4c98..b270b59cb 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx @@ -2005,9 +2005,9 @@ Залиште порожнім, щоб утримати все, що відповідає визначенню та діапазону дат. Обсяг за визначенням Подія та аналіз NERIS - Для обсягу за визначенням залиште обидві дати порожніми, щоб зберегти всі. Час у UTC. - Початок періоду (UTC) - Кінець періоду (UTC) + Для обсягу за визначенням залиште обидві дати порожніми, щоб зберегти всі. Час у місцевий час підрозділу. + Початок періоду + Кінець періоду Підстава утримання Судовий спір Розслідування @@ -2038,9 +2038,9 @@ Оберіть канал потрібен обмежений доступ Для недоступного джерела докази не записуються. - Початок охоплення (UTC) - Кінець охоплення (UTC) - Час події для чинності (UTC) + Початок охоплення + Кінець охоплення + Час події для чинності Особовий склад Повідомлення На цій сторінці немає доступних елементів. @@ -2056,7 +2056,7 @@ Ідентифікатор не записано Остання проблема Історія доставки та відповідей - Час у UTC. Попередні відповіді залишаються доступними після відмови, повтору та виправлення. + Час у місцевий час підрозділу. Попередні відповіді залишаються доступними після відмови, повтору та виправлення. Для цього застарілого подання історію обміну не записано. Операція та етап Відповідь призначення diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.ar.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.ar.resx index 006cabe9a..fa1099c81 100644 --- a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.ar.resx @@ -264,7 +264,7 @@ سجل الخدمة أُنشئ من (UTC، شامل) أُنشئ قبل (UTC، غير شامل) - وقت الحساب (UTC) + وقت الحساب إجمالي أوامر العمل أوامر العمل المفتوحة أوامر العمل المتأخرة @@ -290,7 +290,7 @@ المراجعة هذا التقرير كبير جدًا. قلّل النطاق الزمني أو حدّد الأهداف بدقة أكبر. اختر نطاقًا زمنيًا صالحًا؛ تدعم تقارير لوحة المعلومات مدة تصل إلى ٣٦٦ يومًا. - الحالة الحالية لأوامر العمل المنشأة ضمن النطاق المحدد بالتوقيت UTC. تشمل الأوامر المفتوحة الأعمال المكتملة التي تنتظر الإغلاق. متوسط زمن الإصلاح هو المدة من أول بدء إلى آخر إكمال، بما فيها فترات التعليق وإعادة الفتح؛ تُستبعد الأوقات غير الصالحة. تُقرّب التكاليف لكل سجل وتُفصل حسب العملة، وتُستبعد القطع الملغاة أو غير المصروفة. تبقى التكاليف وعملات العمل القديمة غير المعروفة كذلك؛ لا تُفترض أسعار صرف. + الحالة الحالية لأوامر العمل المنشأة ضمن النطاق المحدد بالتوقيت. تشمل الأوامر المفتوحة الأعمال المكتملة التي تنتظر الإغلاق. متوسط زمن الإصلاح هو المدة من أول بدء إلى آخر إكمال، بما فيها فترات التعليق وإعادة الفتح؛ تُستبعد الأوقات غير الصالحة. تُقرّب التكاليف لكل سجل وتُفصل حسب العملة، وتُستبعد القطع الملغاة أو غير المصروفة. تبقى التكاليف وعملات العمل القديمة غير المعروفة كذلك؛ لا تُفترض أسعار صرف. سجل الخدمة الكامل ما لم تُحدّد تواريخ. يمثّل كل صف في CSV أمر عمل وعملة واحدة. تستبعد التكاليف المعروفة القطع الملغاة أو غير المصروفة؛ تُعرض الأسعار المفقودة وعملات العمل القديمة على أنها غير معروفة. حالة أمر العمل وقت البلاغ، مع مراجع مراجعات غير قابلة للتغيير في البيان. تُدرج أعمال الصيانة المفتوحة الأقدم؛ وتخضع الأعمال المكتملة لفترة الاسترجاع. تُعلّم الحالة التاريخية التي يتعذّر استعادتها بأنها غير متاحة. @@ -313,9 +313,9 @@ تواريخ العطلات (YYYY-MM-DD) الوقت المستهدف للاستجابة (دقائق العمل) الوقت المستهدف للإصلاح (دقائق العمل) -الموعد النهائي للاستجابة (UTC) -الموعد النهائي للإصلاح (UTC) -وقت الاستجابة (UTC) +الموعد النهائي للاستجابة +الموعد النهائي للإصلاح +وقت الاستجابة رسوم الموردين إضافة رسوم مورد إلغاء الرسوم @@ -374,8 +374,8 @@ تجاوز هدف خدمة أمر العمل تغيرت رسوم مورد أمر العمل تغيرت سياسة عمليات أوامر العمل -الموعد النهائي للاستجابة (UTC) -الموعد النهائي للإصلاح (UTC) +الموعد النهائي للاستجابة +الموعد النهائي للإصلاح أنشئ أوامر الإصلاح وتابع من ينفّذها واطّلع على تكلفة الصيانة. إغلاق متابعة @@ -397,4 +397,12 @@ إلى من يذهب أمر العمل المُنشأ، وإلى من يُصعَّد إن لم يتولّه أحد. يحدّد وسم التأخر وعدّاد مستوى الخدمة. الخطوات التي ينفّذها الفني. بعض تغييرات الحالة تتطلب إتمامها جميعًا. + المستخدمون + الأدوار + حدد مستخدمًا واحدًا أو أكثر و/أو أدوارًا. اضغط باستمرار على Ctrl (Windows) أو Command (Mac) لتحديد عدة عناصر. يمكن لأي مستخدم محدد أو عضو في دور محدد قبول أمر العمل. + إعدادات أوامر العمل + اضبط العملة والموافقات على الإنفاق وساعات العمل والعطلات وأهداف الخدمة مرة واحدة للقسم. + تُدار العملة في إعدادات أوامر العمل. تحتفظ الأوامر الحالية بعملتها المسجلة. + ينطبق على أوامر العمل الجديدة ورسوم مورديها. تحتفظ التكاليف وحدود الموافقة الحالية بعملتها الأصلية. إذا استخدم قالب الصيانة عملة أخرى، تُترك التكلفة المقدرة في الأوامر الجديدة فارغة للمراجعة. + أدخل حد الموافقة بعملة القسم المحددة أعلاه. تُحفظ حدود الأوامر الحالية بالعملات الأخرى. diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.de.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.de.resx index 58c369dde..cd69f8958 100644 --- a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.de.resx +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.de.resx @@ -262,9 +262,9 @@ Fälligkeit verschoben Wartungsberichte Wartungsverlauf - Erstellt ab (UTC, einschließlich) - Erstellt vor (UTC, ausschließlich) - Berechnet am (UTC) + Erstellt ab (einschließlich) + Erstellt vor (ausschließlich) + Berechnet am Arbeitsaufträge insgesamt Offene Arbeitsaufträge Überfällige Arbeitsaufträge @@ -290,7 +290,7 @@ Überarbeitungsstand Dieser Bericht ist zu groß. Schränken Sie den Zeitraum oder die Zielfilter ein. Wählen Sie einen gültigen Zeitraum; Übersichtsberichte unterstützen bis zu 366 Tage. - Aktueller Stand der im gewählten UTC-Zeitraum erstellten Arbeitsaufträge. Offen umfasst abgeschlossene Arbeiten ohne endgültigen Abschluss. Die mittlere Reparaturzeit reicht vom ersten Beginn bis zur letzten Fertigstellung, einschließlich Unterbrechungen und Wiedereröffnungen; ungültige Zeiten werden ausgeschlossen. Kosten werden je Eintrag gerundet und nach Währung getrennt; stornierte oder nicht ausgegebene Teile zählen nicht. Unbekannte Kosten und frühere Arbeitswährungen bleiben unbekannt; Wechselkurse werden nicht angenommen. + Aktueller Stand der im gewählten Zeitraum erstellten Arbeitsaufträge. Offen umfasst abgeschlossene Arbeiten ohne endgültigen Abschluss. Die mittlere Reparaturzeit reicht vom ersten Beginn bis zur letzten Fertigstellung, einschließlich Unterbrechungen und Wiedereröffnungen; ungültige Zeiten werden ausgeschlossen. Kosten werden je Eintrag gerundet und nach Währung getrennt; stornierte oder nicht ausgegebene Teile zählen nicht. Unbekannte Kosten und frühere Arbeitswährungen bleiben unbekannt; Wechselkurse werden nicht angenommen. Gesamter Wartungsverlauf, sofern kein Zeitraum ausgewählt ist. Jede CSV-Zeile steht für einen Auftrag und eine Währung. Bekannte Kosten schließen stornierte oder nicht ausgegebene Teile aus; fehlende Preise und frühere Arbeitswährungen werden als unbekannt ausgewiesen. Stand des Arbeitsauftrags zum Einsatzzeitpunkt mit unveränderlichen Versionsreferenzen im Manifest. Ältere offene Wartungen sind enthalten; abgeschlossene Arbeiten berücksichtigen den Rückblickzeitraum. Nicht rekonstruierbare historische Zustände werden als nicht verfügbar gekennzeichnet. @@ -313,9 +313,9 @@ Arbeitsfreie Tage (YYYY-MM-DD) Reaktionsziel (Arbeitsminuten) Reparaturziel (Arbeitsminuten) -Reaktionsfrist (UTC) -Reparaturfrist (UTC) -Reagiert am (UTC) +Reaktionsfrist +Reparaturfrist +Reagiert am Lieferantenkosten Lieferantenkosten hinzufügen Kosten stornieren @@ -374,8 +374,8 @@ Serviceziel des Arbeitsauftrags verfehlt Lieferantenkosten des Arbeitsauftrags geändert Vorgangsrichtlinie für Arbeitsaufträge geändert -Reaktionsfrist (UTC) -Reparaturfrist (UTC) +Reaktionsfrist +Reparaturfrist Reparaturen anlegen, Zuständigkeiten verfolgen und die Wartungskosten sehen. Schließen Weiter @@ -397,4 +397,12 @@ An wen der erzeugte Arbeitsauftrag geht und an wen eskaliert wird, wenn ihn niemand übernimmt. Steuert die Überfälligkeitsmarkierung und die Service-Level-Uhr. Die Schritte, die der Techniker abarbeitet. Manche Statuswechsel setzen voraus, dass alle erledigt sind. + Benutzer + Rollen + Wählen Sie einen oder mehrere Benutzer und/oder Rollen. Halten Sie Strg (Windows) oder Command (Mac) gedrückt, um mehrere Einträge auszuwählen. Jeder ausgewählte Benutzer oder jedes Mitglied einer ausgewählten Rolle kann den Arbeitsauftrag annehmen. + Arbeitsauftragseinstellungen + Währung, Ausgabenfreigaben, Geschäftszeiten, Feiertage und Serviceziele einmal für die Abteilung festlegen. + Die Währung wird in den Arbeitsauftragseinstellungen verwaltet. Bestehende Aufträge behalten ihre erfasste Währung. + Gilt für neue Arbeitsaufträge und deren Lieferantenkosten. Bestehende Kosten und Freigabegrenzen behalten ihre ursprüngliche Währung. Verwendet eine Wartungsvorlage eine andere Währung, bleibt der Kostenvoranschlag bei neuen Aufträgen zur Prüfung leer. + Die Freigabegrenze in der oben ausgewählten Abteilungswährung eingeben. Grenzen für bestehende Aufträge in anderen Währungen bleiben erhalten. diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.el.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.el.resx index 8e800c6d0..8135f787a 100644 --- a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.el.resx +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.el.resx @@ -262,9 +262,9 @@ Αναβολή προθεσμίας Αναφορές συντήρησης Ιστορικό συντήρησης - Δημιουργία από (UTC, συμπεριλαμβάνεται) - Δημιουργία πριν από (UTC, εξαιρείται) - Χρόνος υπολογισμού (UTC) + Δημιουργία από (συμπεριλαμβάνεται) + Δημιουργία πριν από (εξαιρείται) + Χρόνος υπολογισμού Σύνολο εντολών εργασίας Ανοιχτές εντολές εργασίας Εκπρόθεσμες εντολές εργασίας @@ -290,7 +290,7 @@ Αναθεώρηση Η αναφορά είναι πολύ μεγάλη. Περιορίστε το χρονικό διάστημα ή τα φίλτρα στόχων. Επιλέξτε έγκυρο χρονικό διάστημα· οι αναφορές του πίνακα υποστηρίζουν έως 366 ημέρες. - Τρέχουσα κατάσταση των εντολών που δημιουργήθηκαν στο επιλεγμένο διάστημα UTC. Οι ανοιχτές περιλαμβάνουν ολοκληρωμένες εργασίες που αναμένουν κλείσιμο. Ο μέσος χρόνος επισκευής μετρά από την πρώτη έναρξη έως την τελευταία ολοκλήρωση, μαζί με αναστολές και επανανοίγματα· οι μη έγκυροι χρόνοι εξαιρούνται. Τα κόστη στρογγυλοποιούνται ανά εγγραφή και χωρίζονται ανά νόμισμα, χωρίς ακυρωμένα ή μη χορηγημένα ανταλλακτικά. Τα άγνωστα κόστη και τα παλαιά νομίσματα εργασίας παραμένουν άγνωστα· δεν υποτίθενται ισοτιμίες. + Τρέχουσα κατάσταση των εντολών που δημιουργήθηκαν στο επιλεγμένο διάστημα. Οι ανοιχτές περιλαμβάνουν ολοκληρωμένες εργασίες που αναμένουν κλείσιμο. Ο μέσος χρόνος επισκευής μετρά από την πρώτη έναρξη έως την τελευταία ολοκλήρωση, μαζί με αναστολές και επανανοίγματα· οι μη έγκυροι χρόνοι εξαιρούνται. Τα κόστη στρογγυλοποιούνται ανά εγγραφή και χωρίζονται ανά νόμισμα, χωρίς ακυρωμένα ή μη χορηγημένα ανταλλακτικά. Τα άγνωστα κόστη και τα παλαιά νομίσματα εργασίας παραμένουν άγνωστα· δεν υποτίθενται ισοτιμίες. Πλήρες ιστορικό συντήρησης, εκτός αν επιλεγούν ημερομηνίες. Κάθε γραμμή CSV αντιστοιχεί σε μία εντολή και ένα νόμισμα. Τα γνωστά κόστη εξαιρούν ακυρωμένα ή μη χορηγημένα ανταλλακτικά· οι ελλείπουσες τιμές και τα παλαιά νομίσματα εργασίας αναφέρονται ως άγνωστα. Κατάσταση εντολής κατά τον χρόνο του συμβάντος, με αμετάβλητες αναφορές αναθεωρήσεων στο αρχείο τεκμηρίωσης. Περιλαμβάνονται παλαιότερες ανοιχτές συντηρήσεις· οι ολοκληρωμένες εργασίες περιορίζονται στο διάστημα αναδρομής. Η ιστορική κατάσταση που δεν ανακτάται επισημαίνεται ως μη διαθέσιμη. @@ -313,9 +313,9 @@ Μη εργάσιμες ημερομηνίες (YYYY-MM-DD) Στόχος απόκρισης (εργάσιμα λεπτά) Στόχος επισκευής (εργάσιμα λεπτά) -Προθεσμία απόκρισης (UTC) -Προθεσμία επισκευής (UTC) -Απόκριση στις (UTC) +Προθεσμία απόκρισης +Προθεσμία επισκευής +Απόκριση στις Χρεώσεις προμηθευτών Προσθήκη χρέωσης προμηθευτή Ακύρωση χρέωσης @@ -374,8 +374,8 @@ Υπέρβαση στόχου υπηρεσίας εντολής εργασίας Αλλαγή χρέωσης προμηθευτή εντολής εργασίας Αλλαγή πολιτικής λειτουργιών εντολών εργασίας -Προθεσμία απόκρισης (UTC) -Προθεσμία επισκευής (UTC) +Προθεσμία απόκρισης +Προθεσμία επισκευής Δημιουργήστε επισκευές, παρακολουθήστε ποιος τις εκτελεί και δείτε το κόστος συντήρησης. Κλείσιμο Συνέχεια @@ -397,4 +397,12 @@ Σε ποιον πηγαίνει η εντολή που δημιουργείται και σε ποιον κλιμακώνεται αν δεν την αναλάβει κανείς. Καθορίζει την ένδειξη καθυστέρησης και το χρονόμετρο επιπέδου υπηρεσίας. Τα βήματα που ακολουθεί ο τεχνικός. Ορισμένες μεταβάσεις απαιτούν να ολοκληρωθούν όλα. + Χρήστες + Ρόλοι + Επιλέξτε έναν ή περισσότερους χρήστες ή/και ρόλους. Κρατήστε πατημένο το Ctrl (Windows) ή το Command (Mac) για πολλαπλή επιλογή. Κάθε επιλεγμένος χρήστης ή μέλος επιλεγμένου ρόλου μπορεί να αποδεχτεί την εντολή εργασίας. + Ρυθμίσεις εντολών εργασίας + Ρυθμίστε μία φορά για το τμήμα το νόμισμα, τις εγκρίσεις δαπανών, το ωράριο, τις αργίες και τους στόχους εξυπηρέτησης. + Το νόμισμα ορίζεται στις ρυθμίσεις εντολών εργασίας. Οι υπάρχουσες εντολές διατηρούν το καταγεγραμμένο νόμισμά τους. + Ισχύει για νέες εντολές και τις χρεώσεις προμηθευτών τους. Τα υπάρχοντα κόστη και όρια έγκρισης διατηρούν το αρχικό νόμισμά τους. Αν ένα πρότυπο συντήρησης χρησιμοποιεί άλλο νόμισμα, το εκτιμώμενο κόστος στις νέες εντολές παραμένει κενό για έλεγχο. + Εισαγάγετε το όριο έγκρισης στο νόμισμα του τμήματος που επιλέχθηκε παραπάνω. Διατηρούνται τα όρια των υπαρχουσών εντολών σε άλλα νομίσματα. diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.en.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.en.resx index 0ec8758d1..649493777 100644 --- a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.en.resx +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.en.resx @@ -262,9 +262,9 @@ Due date deferred Maintenance reports Service history - Created from (UTC, inclusive) - Created before (UTC, exclusive) - Calculated at (UTC) + Created from (inclusive) + Created before (exclusive) + Calculated at Total work orders Open work orders Overdue work orders @@ -290,7 +290,7 @@ Revision This report is too large. Narrow the date range or target filters. Choose a valid date range; dashboard reports support up to 366 days. - Current state of work orders created in the selected UTC range. Open includes completed work awaiting closure. MTTR is elapsed time from first start to latest completion, including holds and reopens; invalid times are excluded. Costs are rounded per entry, separated by currency, and exclude voided or unissued parts. Unknown costs and legacy labor currency remain unknown; no exchange rates are assumed. + Current state of work orders created in the selected range. Open includes completed work awaiting closure. MTTR is elapsed time from first start to latest completion, including holds and reopens; invalid times are excluded. Costs are rounded per entry, separated by currency, and exclude voided or unissued parts. Unknown costs and legacy labor currency remain unknown; no exchange rates are assumed. All-time service history unless dates are selected. Each CSV row represents one order and currency. Known costs exclude voided or unissued parts; missing prices and legacy labor currency are reported as unknown. Work-order state at the call time, with immutable revision references in the manifest. Older open maintenance is included; completed work uses the lookback window. Unrecoverable historical state is marked unavailable. @@ -313,9 +313,9 @@ Nonworking dates (YYYY-MM-DD) Response target (working minutes) Repair target (working minutes) -Response deadline (UTC) -Repair deadline (UTC) -Responded at (UTC) +Response deadline +Repair deadline +Responded at Vendor charges Add vendor charge Void charge @@ -374,8 +374,8 @@ Work order service target missed Work order vendor charge changed Work order operations policy changed -Response deadline (UTC) -Repair deadline (UTC) +Response deadline +Repair deadline Raise repairs, track who is doing them, and see what maintenance has cost. Close Continue @@ -397,4 +397,12 @@ Who the generated work order goes to, and who it escalates to if nobody picks it up. Drives the overdue flag and the service level clock. The checks the technician works down. Some transitions require them all to be done. + Users + Roles + Select one or more users and/or roles. Hold Ctrl (Windows) or Command (Mac) to select multiple entries. Any selected user or member of a selected role can accept the work order. + Work Order Settings + Configure currency, spending approvals, business hours, holidays and service targets once for the department. + Currency is managed in Work Order Settings. Existing orders keep their recorded currency. + Applies to new work orders and vendor charges on those orders. Existing costs and approval thresholds keep their original currency. If a maintenance template uses another currency, its estimated cost is left blank on new orders for review. + Enter the approval threshold in the department currency selected above. Thresholds for existing orders in other currencies are retained. diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.es.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.es.resx index 7115b5882..801479b6a 100644 --- a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.es.resx +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.es.resx @@ -262,9 +262,9 @@ Fecha aplazada Informes de mantenimiento Historial de servicio - Creado desde (UTC, inclusive) - Creado antes de (UTC, sin incluir) - Calculado a las (UTC) + Creado desde (inclusive) + Creado antes de (sin incluir) + Calculado a las Órdenes de trabajo totales Órdenes de trabajo abiertas Órdenes de trabajo vencidas @@ -290,7 +290,7 @@ Revisión Este informe es demasiado grande. Reduzca el intervalo de fechas o los filtros de destino. Elija un intervalo de fechas válido; los informes del panel admiten hasta 366 días. - Estado actual de las órdenes creadas en el intervalo UTC seleccionado. Las abiertas incluyen trabajos completados pendientes de cierre. El tiempo medio de reparación va desde el primer inicio hasta la última finalización, incluidas pausas y reaperturas; se excluyen los tiempos no válidos. Los costes se redondean por registro y se separan por moneda; se excluyen piezas anuladas o no entregadas. Los costes y las monedas antiguas de mano de obra desconocidos siguen siendo desconocidos; no se suponen tipos de cambio. + Estado actual de las órdenes creadas en el intervalo seleccionado. Las abiertas incluyen trabajos completados pendientes de cierre. El tiempo medio de reparación va desde el primer inicio hasta la última finalización, incluidas pausas y reaperturas; se excluyen los tiempos no válidos. Los costes se redondean por registro y se separan por moneda; se excluyen piezas anuladas o no entregadas. Los costes y las monedas antiguas de mano de obra desconocidos siguen siendo desconocidos; no se suponen tipos de cambio. Historial completo salvo que se seleccionen fechas. Cada fila CSV representa una orden y una moneda. Los costes conocidos excluyen piezas anuladas o no entregadas; los precios ausentes y las monedas antiguas de mano de obra se indican como desconocidos. Estado de la orden en el momento del incidente, con referencias inmutables de revisión en el manifiesto. Se incluye el mantenimiento abierto anterior; los trabajos completados usan el periodo retrospectivo. El estado histórico irrecuperable se indica como no disponible. @@ -313,9 +313,9 @@ Fechas no laborables (YYYY-MM-DD) Objetivo de respuesta (minutos laborables) Objetivo de reparación (minutos laborables) -Fecha límite de respuesta (UTC) -Fecha límite de reparación (UTC) -Respuesta registrada (UTC) +Fecha límite de respuesta +Fecha límite de reparación +Respuesta registrada Cargos de proveedores Añadir cargo de proveedor Anular cargo @@ -374,8 +374,8 @@ Objetivo de servicio de orden de trabajo incumplido Cargo de proveedor de orden de trabajo modificado Política de operaciones de órdenes de trabajo modificada -Fecha límite de respuesta (UTC) -Fecha límite de reparación (UTC) +Fecha límite de respuesta +Fecha límite de reparación Cree reparaciones, controle quién las realiza y vea el coste del mantenimiento. Cerrar Continuar @@ -397,4 +397,12 @@ A quién va la orden generada y a quién se escala si nadie la asume. Determina el indicador de retraso y el reloj del nivel de servicio. Los pasos que sigue el técnico. Algunas transiciones exigen que estén todos hechos. + Usuarios + Roles + Seleccione uno o varios usuarios y/o roles. Mantenga pulsado Ctrl (Windows) o Command (Mac) para seleccionar varias entradas. Cualquier usuario seleccionado o miembro de un rol seleccionado puede aceptar la orden de trabajo. + Configuración de órdenes de trabajo + Configure una sola vez para el departamento la moneda, las aprobaciones de gastos, el horario laboral, los festivos y los objetivos de servicio. + La moneda se administra en la configuración de órdenes de trabajo. Las órdenes existentes conservan su moneda registrada. + Se aplica a las nuevas órdenes y sus cargos de proveedores. Los costos y límites de aprobación existentes conservan su moneda original. Si una plantilla de mantenimiento usa otra moneda, el costo estimado de las nuevas órdenes queda vacío para su revisión. + Introduzca el límite de aprobación en la moneda del departamento seleccionada arriba. Se conservan los límites de las órdenes existentes en otras monedas. diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.fr.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.fr.resx index bd782c754..a96517869 100644 --- a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.fr.resx @@ -262,9 +262,9 @@ Échéance reportée Rapports de maintenance Historique d’entretien - Créé à partir du (UTC, inclus) - Créé avant le (UTC, exclu) - Calculé le (UTC) + Créé à partir du (inclus) + Créé avant le (exclu) + Calculé le Total des ordres de travail Ordres de travail ouverts Ordres de travail en retard @@ -290,7 +290,7 @@ Révision Ce rapport est trop volumineux. Réduisez la période ou précisez les filtres de cible. Choisissez une période valide ; les rapports du tableau de bord acceptent jusqu’à 366 jours. - État actuel des ordres créés dans la période UTC sélectionnée. Les ordres ouverts incluent les travaux terminés en attente de clôture. La durée moyenne de réparation va du premier début à la dernière fin, y compris les suspensions et réouvertures ; les horaires invalides sont exclus. Les coûts sont arrondis par ligne et séparés par devise, hors pièces annulées ou non délivrées. Les coûts et anciennes devises de main-d’œuvre inconnus le restent ; aucun taux de change n’est supposé. + État actuel des ordres créés dans la période sélectionnée. Les ordres ouverts incluent les travaux terminés en attente de clôture. La durée moyenne de réparation va du premier début à la dernière fin, y compris les suspensions et réouvertures ; les horaires invalides sont exclus. Les coûts sont arrondis par ligne et séparés par devise, hors pièces annulées ou non délivrées. Les coûts et anciennes devises de main-d’œuvre inconnus le restent ; aucun taux de change n’est supposé. Historique complet sauf si des dates sont sélectionnées. Chaque ligne CSV représente un ordre et une devise. Les coûts connus excluent les pièces annulées ou non délivrées ; les prix manquants et anciennes devises de main-d’œuvre sont signalés comme inconnus. État de l’ordre au moment de l’intervention, avec des références de révision immuables dans le manifeste. Les anciennes maintenances ouvertes sont incluses ; les travaux terminés utilisent la période rétrospective. Tout état historique irrécupérable est signalé comme indisponible. @@ -313,9 +313,9 @@ Dates chômées (YYYY-MM-DD) Délai de réponse (minutes ouvrées) Délai de réparation (minutes ouvrées) -Échéance de réponse (UTC) -Échéance de réparation (UTC) -Réponse enregistrée (UTC) +Échéance de réponse +Échéance de réparation +Réponse enregistrée Frais des fournisseurs Ajouter des frais de fournisseur Annuler les frais @@ -374,8 +374,8 @@ Objectif de service d’ordre de travail dépassé Frais fournisseur d’ordre de travail modifiés Politique des opérations des ordres de travail modifiée -Échéance de réponse (UTC) -Échéance de réparation (UTC) +Échéance de réponse +Échéance de réparation Créez des réparations, suivez qui les réalise et consultez le coût de la maintenance. Fermer Continuer @@ -397,4 +397,12 @@ À qui va l'ordre de travail généré, et vers qui il remonte si personne ne le prend. Détermine l'indicateur de retard et le compteur de niveau de service. Les étapes que suit le technicien. Certaines transitions exigent qu'elles soient toutes faites. + Utilisateurs + Rôles + Sélectionnez un ou plusieurs utilisateurs et/ou rôles. Maintenez Ctrl (Windows) ou Command (Mac) pour sélectionner plusieurs entrées. Tout utilisateur sélectionné ou membre d’un rôle sélectionné peut accepter l’ordre de travail. + Paramètres des ordres de travail + Définissez une seule fois pour le service la devise, les approbations de dépenses, les horaires, les jours fériés et les objectifs de service. + La devise est gérée dans les paramètres des ordres de travail. Les ordres existants conservent leur devise enregistrée. + S’applique aux nouveaux ordres et à leurs frais fournisseurs. Les coûts et seuils d’approbation existants conservent leur devise d’origine. Si un modèle de maintenance utilise une autre devise, le coût estimé des nouveaux ordres reste vide pour vérification. + Saisissez le seuil d’approbation dans la devise du service sélectionnée ci-dessus. Les seuils des ordres existants dans d’autres devises sont conservés. diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.it.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.it.resx index 793e98bf0..72fb567f2 100644 --- a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.it.resx +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.it.resx @@ -262,9 +262,9 @@ Scadenza rinviata Rapporti di manutenzione Storico degli interventi - Creato dal (UTC, incluso) - Creato prima del (UTC, escluso) - Calcolato il (UTC) + Creato dal (incluso) + Creato prima del (escluso) + Calcolato il Ordini di lavoro totali Ordini di lavoro aperti Ordini di lavoro scaduti @@ -290,7 +290,7 @@ Revisione Il rapporto è troppo grande. Restringi l’intervallo di date o i filtri delle risorse. Scegli un intervallo di date valido; i rapporti del pannello supportano fino a 366 giorni. - Stato attuale degli ordini creati nell’intervallo UTC selezionato. Gli ordini aperti includono i lavori completati in attesa di chiusura. Il tempo medio di riparazione va dal primo avvio all’ultimo completamento, incluse sospensioni e riaperture; gli orari non validi sono esclusi. I costi sono arrotondati per voce e separati per valuta, escludendo ricambi annullati o non consegnati. Costi e valute storiche della manodopera sconosciuti restano tali; non si presumono tassi di cambio. + Stato attuale degli ordini creati nell’intervallo selezionato. Gli ordini aperti includono i lavori completati in attesa di chiusura. Il tempo medio di riparazione va dal primo avvio all’ultimo completamento, incluse sospensioni e riaperture; gli orari non validi sono esclusi. I costi sono arrotondati per voce e separati per valuta, escludendo ricambi annullati o non consegnati. Costi e valute storiche della manodopera sconosciuti restano tali; non si presumono tassi di cambio. Storico completo, salvo selezione di date. Ogni riga CSV rappresenta un ordine e una valuta. I costi noti escludono i ricambi annullati o non consegnati; prezzi mancanti e valute storiche della manodopera sono indicati come sconosciuti. Stato dell’ordine al momento dell’intervento, con riferimenti di revisione immutabili nel manifesto. Sono incluse le manutenzioni precedenti ancora aperte; i lavori completati usano il periodo retrospettivo. Lo stato storico non recuperabile è indicato come non disponibile. @@ -313,9 +313,9 @@ Date non lavorative (YYYY-MM-DD) Obiettivo di risposta (minuti lavorativi) Obiettivo di riparazione (minuti lavorativi) -Scadenza di risposta (UTC) -Scadenza di riparazione (UTC) -Risposta registrata (UTC) +Scadenza di risposta +Scadenza di riparazione +Risposta registrata Addebiti dei fornitori Aggiungi addebito del fornitore Annulla addebito @@ -374,8 +374,8 @@ Obiettivo di servizio dell’ordine non rispettato Addebito del fornitore dell’ordine modificato Regole operative degli ordini di lavoro modificate -Scadenza di risposta (UTC) -Scadenza di riparazione (UTC) +Scadenza di risposta +Scadenza di riparazione Apri riparazioni, controlla chi le esegue e verifica quanto è costata la manutenzione. Chiudi Continua @@ -397,4 +397,12 @@ A chi va l'ordine generato e a chi viene inoltrato se nessuno lo prende in carico. Determina l'indicatore di ritardo e il conteggio del livello di servizio. I passi che il tecnico esegue. Alcune transizioni richiedono che siano tutti completati. + Utenti + Ruoli + Seleziona uno o più utenti e/o ruoli. Tieni premuto Ctrl (Windows) o Command (Mac) per selezionare più voci. Qualsiasi utente selezionato o membro di un ruolo selezionato può accettare l’ordine di lavoro. + Impostazioni ordini di lavoro + Configura una sola volta per il reparto valuta, approvazioni di spesa, orari lavorativi, festività e obiettivi di servizio. + La valuta viene gestita nelle impostazioni degli ordini di lavoro. Gli ordini esistenti mantengono la valuta registrata. + Si applica ai nuovi ordini e ai relativi addebiti dei fornitori. I costi e le soglie esistenti mantengono la valuta originale. Se un modello di manutenzione usa un’altra valuta, il costo stimato nei nuovi ordini resta vuoto per la verifica. + Inserisci la soglia di approvazione nella valuta del reparto selezionata sopra. Le soglie degli ordini esistenti in altre valute vengono mantenute. diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.pl.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.pl.resx index 2d2e85bba..7c82b5c74 100644 --- a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.pl.resx @@ -262,9 +262,9 @@ Termin odroczony Raporty konserwacji Historia serwisowa - Utworzono od (UTC, włącznie) - Utworzono przed (UTC, bez tej chwili) - Obliczono o (UTC) + Utworzono od (włącznie) + Utworzono przed (bez tej chwili) + Obliczono o Wszystkie zlecenia pracy Otwarte zlecenia pracy Przeterminowane zlecenia pracy @@ -290,7 +290,7 @@ Wersja Ten raport jest za duży. Zawęź zakres dat lub filtry zasobów. Wybierz prawidłowy zakres dat; raporty panelu obsługują do 366 dni. - Bieżący stan zleceń utworzonych w wybranym zakresie UTC. Otwarte obejmują prace zakończone oczekujące na zamknięcie. Średni czas naprawy biegnie od pierwszego rozpoczęcia do ostatniego zakończenia, wraz ze wstrzymaniami i ponownymi otwarciami; nieprawidłowe czasy są pomijane. Koszty są zaokrąglane dla każdego wpisu i rozdzielane według waluty; anulowane lub niewydane części są wykluczone. Nieznane koszty i dawne waluty robocizny pozostają nieznane; nie zakłada się kursów wymiany. + Bieżący stan zleceń utworzonych w wybranym zakresie. Otwarte obejmują prace zakończone oczekujące na zamknięcie. Średni czas naprawy biegnie od pierwszego rozpoczęcia do ostatniego zakończenia, wraz ze wstrzymaniami i ponownymi otwarciami; nieprawidłowe czasy są pomijane. Koszty są zaokrąglane dla każdego wpisu i rozdzielane według waluty; anulowane lub niewydane części są wykluczone. Nieznane koszty i dawne waluty robocizny pozostają nieznane; nie zakłada się kursów wymiany. Pełna historia serwisowa, chyba że wybrano daty. Każdy wiersz CSV odpowiada jednemu zleceniu i jednej walucie. Znane koszty nie obejmują anulowanych lub niewydanych części; brakujące ceny i dawne waluty robocizny są oznaczone jako nieznane. Stan zlecenia w chwili zdarzenia, z niezmiennymi odwołaniami do wersji w manifeście. Uwzględnia wcześniejsze otwarte prace konserwacyjne; zakończone prace obejmują okres retrospektywny. Niemożliwy do odtworzenia stan historyczny jest oznaczony jako niedostępny. @@ -313,9 +313,9 @@ Dni wolne (YYYY-MM-DD) Docelowy czas reakcji (minuty robocze) Docelowy czas naprawy (minuty robocze) -Termin reakcji (UTC) -Termin naprawy (UTC) -Czas reakcji (UTC) +Termin reakcji +Termin naprawy +Czas reakcji Koszty dostawców Dodaj koszt dostawcy Anuluj koszt @@ -374,8 +374,8 @@ Przekroczono docelowy termin zlecenia pracy Zmieniono koszt dostawcy w zleceniu pracy Zmieniono zasady operacyjne zleceń pracy -Termin reakcji (UTC) -Termin naprawy (UTC) +Termin reakcji +Termin naprawy Zgłaszaj naprawy, śledź wykonawców i sprawdzaj koszty utrzymania. Zamknij Dalej @@ -397,4 +397,12 @@ Do kogo trafia wygenerowane zlecenie i do kogo jest eskalowane, gdy nikt go nie podejmie. Steruje oznaczeniem opóźnienia i zegarem poziomu usług. Kroki, które wykonuje technik. Niektóre zmiany statusu wymagają ukończenia wszystkich. + Użytkownicy + Role + Wybierz jednego lub więcej użytkowników i/lub ról. Przytrzymaj Ctrl (Windows) lub Command (Mac), aby wybrać wiele pozycji. Każdy wybrany użytkownik lub członek wybranej roli może przyjąć zlecenie pracy. + Ustawienia zleceń pracy + Skonfiguruj raz dla jednostki walutę, zatwierdzanie wydatków, godziny pracy, dni wolne i cele obsługi. + Walutą zarządza się w ustawieniach zleceń pracy. Istniejące zlecenia zachowują zapisaną walutę. + Dotyczy nowych zleceń i ich opłat dostawców. Istniejące koszty i progi zatwierdzania zachowują pierwotną walutę. Jeśli szablon konserwacji używa innej waluty, szacowany koszt nowych zleceń pozostaje pusty do sprawdzenia. + Wprowadź próg zatwierdzania w wybranej powyżej walucie jednostki. Progi istniejących zleceń w innych walutach zostają zachowane. diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.resx index 0ec8758d1..649493777 100644 --- a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.resx +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.resx @@ -262,9 +262,9 @@ Due date deferred Maintenance reports Service history - Created from (UTC, inclusive) - Created before (UTC, exclusive) - Calculated at (UTC) + Created from (inclusive) + Created before (exclusive) + Calculated at Total work orders Open work orders Overdue work orders @@ -290,7 +290,7 @@ Revision This report is too large. Narrow the date range or target filters. Choose a valid date range; dashboard reports support up to 366 days. - Current state of work orders created in the selected UTC range. Open includes completed work awaiting closure. MTTR is elapsed time from first start to latest completion, including holds and reopens; invalid times are excluded. Costs are rounded per entry, separated by currency, and exclude voided or unissued parts. Unknown costs and legacy labor currency remain unknown; no exchange rates are assumed. + Current state of work orders created in the selected range. Open includes completed work awaiting closure. MTTR is elapsed time from first start to latest completion, including holds and reopens; invalid times are excluded. Costs are rounded per entry, separated by currency, and exclude voided or unissued parts. Unknown costs and legacy labor currency remain unknown; no exchange rates are assumed. All-time service history unless dates are selected. Each CSV row represents one order and currency. Known costs exclude voided or unissued parts; missing prices and legacy labor currency are reported as unknown. Work-order state at the call time, with immutable revision references in the manifest. Older open maintenance is included; completed work uses the lookback window. Unrecoverable historical state is marked unavailable. @@ -313,9 +313,9 @@ Nonworking dates (YYYY-MM-DD) Response target (working minutes) Repair target (working minutes) -Response deadline (UTC) -Repair deadline (UTC) -Responded at (UTC) +Response deadline +Repair deadline +Responded at Vendor charges Add vendor charge Void charge @@ -374,8 +374,8 @@ Work order service target missed Work order vendor charge changed Work order operations policy changed -Response deadline (UTC) -Repair deadline (UTC) +Response deadline +Repair deadline Raise repairs, track who is doing them, and see what maintenance has cost. Close Continue @@ -397,4 +397,12 @@ Who the generated work order goes to, and who it escalates to if nobody picks it up. Drives the overdue flag and the service level clock. The checks the technician works down. Some transitions require them all to be done. + Users + Roles + Select one or more users and/or roles. Hold Ctrl (Windows) or Command (Mac) to select multiple entries. Any selected user or member of a selected role can accept the work order. + Work Order Settings + Configure currency, spending approvals, business hours, holidays and service targets once for the department. + Currency is managed in Work Order Settings. Existing orders keep their recorded currency. + Applies to new work orders and vendor charges on those orders. Existing costs and approval thresholds keep their original currency. If a maintenance template uses another currency, its estimated cost is left blank on new orders for review. + Enter the approval threshold in the department currency selected above. Thresholds for existing orders in other currencies are retained. diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.sv.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.sv.resx index b857335a6..954f55f18 100644 --- a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.sv.resx @@ -262,9 +262,9 @@ Förfallodatum uppskjutet Underhållsrapporter Servicehistorik - Skapad från (UTC, inklusive) - Skapad före (UTC, exklusive) - Beräknad vid (UTC) + Skapad från (inklusive) + Skapad före (exklusive) + Beräknad vid Totalt antal arbetsorder Öppna arbetsorder Försenade arbetsorder @@ -290,7 +290,7 @@ Revisionsnummer Rapporten är för stor. Begränsa datumintervallet eller målfiltren. Välj ett giltigt datumintervall; översiktsrapporter stöder upp till 366 dagar. - Aktuellt tillstånd för arbetsorder skapade inom valt UTC-intervall. Öppna omfattar slutfört arbete som väntar på stängning. Genomsnittlig reparationstid räknas från första start till senaste slutförande, inklusive uppehåll och återöppningar; ogiltiga tider utesluts. Kostnader avrundas per post och delas upp efter valuta; makulerade eller ej utlämnade delar utesluts. Okända kostnader och äldre arbetsvalutor förblir okända; inga växelkurser antas. + Aktuellt tillstånd för arbetsorder skapade inom valt intervall. Öppna omfattar slutfört arbete som väntar på stängning. Genomsnittlig reparationstid räknas från första start till senaste slutförande, inklusive uppehåll och återöppningar; ogiltiga tider utesluts. Kostnader avrundas per post och delas upp efter valuta; makulerade eller ej utlämnade delar utesluts. Okända kostnader och äldre arbetsvalutor förblir okända; inga växelkurser antas. Fullständig servicehistorik om inga datum väljs. Varje CSV-rad motsvarar en order och en valuta. Kända kostnader utesluter makulerade eller ej utlämnade delar; saknade priser och äldre arbetsvalutor anges som okända. Arbetsorderns tillstånd vid larmtillfället, med oföränderliga revisionsreferenser i manifestet. Äldre öppet underhåll ingår; slutfört arbete använder tillbakablicksperioden. Historiska tillstånd som inte kan återskapas markeras som otillgängliga. @@ -313,9 +313,9 @@ Arbetsfria datum (YYYY-MM-DD) Svarsmål (arbetsminuter) Reparationsmål (arbetsminuter) -Sista svarstid (UTC) -Sista reparationstid (UTC) -Svar registrerat (UTC) +Sista svarstid +Sista reparationstid +Svar registrerat Leverantörskostnader Lägg till leverantörskostnad Makulera kostnad @@ -374,8 +374,8 @@ Arbetsorderns servicemål missat Arbetsorderns leverantörskostnad ändrad Reglerna för arbetsorder ändrade -Sista svarstid (UTC) -Sista reparationstid (UTC) +Sista svarstid +Sista reparationstid Skapa reparationer, följ vem som utför dem och se vad underhållet kostat. Stäng Fortsätt @@ -397,4 +397,12 @@ Vem den skapade arbetsordern går till, och vem den eskaleras till om ingen tar den. Styr förseningsmarkeringen och servicenivåklockan. Stegen teknikern arbetar igenom. Vissa statusbyten kräver att alla är klara. + Användare + Roller + Välj en eller flera användare och/eller roller. Håll ned Ctrl (Windows) eller Command (Mac) för att välja flera poster. Alla valda användare och medlemmar i valda roller kan acceptera arbetsordern. + Inställningar för arbetsorder + Ställ in valuta, utgiftsgodkännanden, arbetstider, helgdagar och servicemål en gång för avdelningen. + Valutan hanteras i inställningarna för arbetsorder. Befintliga order behåller sin registrerade valuta. + Gäller nya arbetsorder och deras leverantörsavgifter. Befintliga kostnader och godkännandegränser behåller sin ursprungliga valuta. Om en underhållsmall använder en annan valuta lämnas den uppskattade kostnaden tom på nya order för granskning. + Ange godkännandegränsen i avdelningens valuta som valts ovan. Gränser för befintliga order i andra valutor behålls. diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.uk.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.uk.resx index 79404883a..ecb0f4011 100644 --- a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.uk.resx @@ -262,9 +262,9 @@ Термін відкладено Звіти з технічного обслуговування Історія обслуговування - Створено від (UTC, включно) - Створено до (UTC, не включно) - Час розрахунку (UTC) + Створено від (включно) + Створено до (не включно) + Час розрахунку Усього нарядів на роботи Відкриті наряди на роботи Прострочені наряди на роботи @@ -290,7 +290,7 @@ Редакція Цей звіт завеликий. Звузьте діапазон дат або фільтри ресурсів. Виберіть коректний діапазон дат; звіти панелі підтримують до 366 днів. - Поточний стан нарядів, створених у вибраному діапазоні UTC. Відкриті включають завершені роботи, що очікують закриття. Середній час ремонту вимірюється від першого початку до останнього завершення, включно з призупиненнями та повторними відкриттями; некоректні значення часу виключаються. Витрати округлюються для кожного запису й розділяються за валютою, без скасованих або невиданих запчастин. Невідомі витрати й давні валюти оплати праці залишаються невідомими; курси обміну не припускаються. + Поточний стан нарядів, створених у вибраному діапазоні. Відкриті включають завершені роботи, що очікують закриття. Середній час ремонту вимірюється від першого початку до останнього завершення, включно з призупиненнями та повторними відкриттями; некоректні значення часу виключаються. Витрати округлюються для кожного запису й розділяються за валютою, без скасованих або невиданих запчастин. Невідомі витрати й давні валюти оплати праці залишаються невідомими; курси обміну не припускаються. Повна історія обслуговування, якщо не вибрано дати. Кожен рядок CSV відповідає одному наряду й одній валюті. Відомі витрати не включають скасовані або невидані запчастини; відсутні ціни та давні валюти оплати праці позначаються як невідомі. Стан наряду на момент виклику з незмінними посиланнями на редакції в маніфесті. Включено давніші відкриті роботи з обслуговування; завершені роботи охоплюють ретроспективний період. Історичний стан, який неможливо відновити, позначається як недоступний. @@ -313,9 +313,9 @@ Неробочі дати (YYYY-MM-DD) Цільовий час реагування (робочі хвилини) Цільовий час ремонту (робочі хвилини) -Кінцевий термін реагування (UTC) -Кінцевий термін ремонту (UTC) -Час реагування (UTC) +Кінцевий термін реагування +Кінцевий термін ремонту +Час реагування Витрати на постачальників Додати витрати на постачальника Анулювати витрати @@ -374,8 +374,8 @@ Цільовий строк обслуговування наряду порушено Витрати на постачальника в наряді змінено Правила операцій нарядів змінено -Кінцевий термін реагування (UTC) -Кінцевий термін ремонту (UTC) +Кінцевий термін реагування +Кінцевий термін ремонту Створюйте ремонти, стежте за виконавцями та дивіться вартість обслуговування. Закрити Продовжити @@ -397,4 +397,12 @@ Кому надходить створений наряд і кому ескалюється, якщо ніхто його не взяв. Визначає позначку прострочення та відлік рівня обслуговування. Кроки, які виконує технік. Деякі переходи вимагають завершення всіх. + Користувачі + Ролі + Виберіть одного чи кількох користувачів та/або ролі. Утримуйте Ctrl (Windows) або Command (Mac), щоб вибрати кілька записів. Будь-який вибраний користувач або учасник вибраної ролі може прийняти наряд на роботу. + Налаштування нарядів на роботи + Налаштуйте один раз для підрозділу валюту, погодження витрат, робочі години, свята та цілі обслуговування. + Валюта визначається в налаштуваннях нарядів на роботи. Наявні наряди зберігають записану валюту. + Застосовується до нових нарядів та їхніх витрат на постачальників. Наявні витрати й пороги погодження зберігають початкову валюту. Якщо шаблон обслуговування використовує іншу валюту, очікувана вартість нових нарядів залишається порожньою для перевірки. + Введіть поріг погодження у валюті підрозділу, вибраній вище. Пороги наявних нарядів в інших валютах зберігаються. diff --git a/Core/Resgrid.Localization/Common.ar.resx b/Core/Resgrid.Localization/Common.ar.resx index 35ed0cc22..1973c0a76 100644 --- a/Core/Resgrid.Localization/Common.ar.resx +++ b/Core/Resgrid.Localization/Common.ar.resx @@ -311,4 +311,6 @@ العودة إلى الملف الشخصي رمز الربط جاهز. في Discord، استخدم /resgrid وأدخل LINK متبوعًا بالرمز في خيار message. استخدم /resgrid أيضًا للأوامر اللاحقة. + القوى العاملة وعمليات الأعمال + الجاهزية diff --git a/Core/Resgrid.Localization/Common.de.resx b/Core/Resgrid.Localization/Common.de.resx index b25bf4d47..fc464edba 100644 --- a/Core/Resgrid.Localization/Common.de.resx +++ b/Core/Resgrid.Localization/Common.de.resx @@ -744,4 +744,6 @@ Zurück zum Profil Ihr Verknüpfungscode ist bereit. Verwenden Sie in Discord /resgrid und geben Sie LINK gefolgt vom Code in der Option message ein. Nutzen Sie /resgrid auch für weitere Befehle. + Personal & Betriebsverwaltung + Einsatzbereitschaft diff --git a/Core/Resgrid.Localization/Common.el.resx b/Core/Resgrid.Localization/Common.el.resx index 1bd5d38aa..978f8a128 100644 --- a/Core/Resgrid.Localization/Common.el.resx +++ b/Core/Resgrid.Localization/Common.el.resx @@ -796,4 +796,6 @@ Επιστροφή στο προφίλ Ο κωδικός σύνδεσής σας είναι έτοιμος. Στο Discord, χρησιμοποιήστε /resgrid και εισαγάγετε LINK μαζί με τον κωδικό στην επιλογή message. Χρησιμοποιήστε /resgrid και για τις επόμενες εντολές. + Ανθρώπινο δυναμικό και διοίκηση + Ετοιμότητα diff --git a/Core/Resgrid.Localization/Common.en.resx b/Core/Resgrid.Localization/Common.en.resx index 9e70c2d86..99b980e98 100644 --- a/Core/Resgrid.Localization/Common.en.resx +++ b/Core/Resgrid.Localization/Common.en.resx @@ -796,4 +796,6 @@ Back to profile Your linking code is ready. For Discord, use /resgrid and enter LINK followed by your code in the message option. Use /resgrid for subsequent commands too. + Workforce & Business Ops + Readiness diff --git a/Core/Resgrid.Localization/Common.es.resx b/Core/Resgrid.Localization/Common.es.resx index 313da640e..11a48548e 100644 --- a/Core/Resgrid.Localization/Common.es.resx +++ b/Core/Resgrid.Localization/Common.es.resx @@ -784,4 +784,6 @@ Volver al perfil Tu código de vinculación está listo. En Discord, usa /resgrid e introduce LINK seguido de tu código en la opción message. Usa /resgrid también para los comandos siguientes. + Personal y operaciones + Preparación diff --git a/Core/Resgrid.Localization/Common.fr.resx b/Core/Resgrid.Localization/Common.fr.resx index ac31dff8a..dfb622efe 100644 --- a/Core/Resgrid.Localization/Common.fr.resx +++ b/Core/Resgrid.Localization/Common.fr.resx @@ -744,4 +744,6 @@ Retour au profil Votre code de liaison est prêt. Sur Discord, utilisez /resgrid et saisissez LINK suivi du code dans l’option message. Utilisez aussi /resgrid pour les commandes suivantes. + Effectifs et gestion + Préparation opérationnelle diff --git a/Core/Resgrid.Localization/Common.it.resx b/Core/Resgrid.Localization/Common.it.resx index e1b02cd06..da1ed1b73 100644 --- a/Core/Resgrid.Localization/Common.it.resx +++ b/Core/Resgrid.Localization/Common.it.resx @@ -744,4 +744,6 @@ Torna al profilo Il codice di collegamento è pronto. Su Discord, usa /resgrid e inserisci LINK seguito dal codice nell’opzione message. Usa /resgrid anche per i comandi successivi. + Personale e gestione + Prontezza operativa diff --git a/Core/Resgrid.Localization/Common.pl.resx b/Core/Resgrid.Localization/Common.pl.resx index 136630d0e..36a732f03 100644 --- a/Core/Resgrid.Localization/Common.pl.resx +++ b/Core/Resgrid.Localization/Common.pl.resx @@ -744,4 +744,6 @@ Powrót do profilu Kod łączenia jest gotowy. W Discord użyj /resgrid i wpisz LINK oraz kod w opcji message. Kolejne polecenia również wysyłaj przez /resgrid. + Kadry i zarządzanie + Gotowość diff --git a/Core/Resgrid.Localization/Common.sv.resx b/Core/Resgrid.Localization/Common.sv.resx index 1518f221b..15e2647eb 100644 --- a/Core/Resgrid.Localization/Common.sv.resx +++ b/Core/Resgrid.Localization/Common.sv.resx @@ -744,4 +744,6 @@ Tillbaka till profilen Din länkningskod är klar. I Discord använder du /resgrid och skriver LINK följt av koden i alternativet message. Använd även /resgrid för efterföljande kommandon. + Personal och verksamhet + Beredskap diff --git a/Core/Resgrid.Localization/Common.uk.resx b/Core/Resgrid.Localization/Common.uk.resx index 5bfbfe7cb..476cdfa71 100644 --- a/Core/Resgrid.Localization/Common.uk.resx +++ b/Core/Resgrid.Localization/Common.uk.resx @@ -744,4 +744,6 @@ Назад до профілю Ваш код прив’язування готовий. У Discord використайте /resgrid і введіть LINK та код у полі message. Для наступних команд також використовуйте /resgrid. + Персонал і бізнес-операції + Готовність diff --git a/Core/Resgrid.Model/Helpers/TimeConverterHelper.cs b/Core/Resgrid.Model/Helpers/TimeConverterHelper.cs index bd47b5a70..81bb934b9 100644 --- a/Core/Resgrid.Model/Helpers/TimeConverterHelper.cs +++ b/Core/Resgrid.Model/Helpers/TimeConverterHelper.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Diagnostics; using System.Globalization; using System.Linq; @@ -28,10 +28,11 @@ public static DateTime TimeConverter(this DateTime timestamp, Department departm // runs in globalization-invariant mode, where TimeZoneInfo cannot map a Windows // zone id and throws TimeZoneNotFoundException. NodaTime carries its own tzdb and // needs neither ICU nor the OS /usr/share/zoneinfo files. Mirrors TimeConverterToString. - var ianaTz = TZConvert.WindowsToIana(DateTimeHelpers.ConvertTimeZoneString(timeZone)); + var id = DateTimeHelpers.ConvertTimeZoneString(timeZone); + var zone = DateTimeZoneProviders.Tzdb.GetZoneOrNull(id) ?? DateTimeZoneProviders.Tzdb[TZConvert.WindowsToIana(id)]; var instant = Instant.FromDateTimeUtc(DateTime.SpecifyKind(timestamp, DateTimeKind.Utc)); - return instant.InZone(DateTimeZoneProviders.Tzdb[ianaTz]).ToDateTimeUnspecified(); + return instant.InZone(zone).ToDateTimeUnspecified(); } catch (Exception ex) { @@ -43,45 +44,10 @@ public static DateTime TimeConverter(this DateTime timestamp, Department departm } public static string TimeConverterToString(this DateTime timestamp, Department department) - { - //DateTime newTime = timestamp; - //TimeZoneInfo timeZoneInfo = null; - - try - { - //if (!String.IsNullOrEmpty(department.TimeZone)) - // timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(DateTimeHelpers.ConvertTimeZoneString(department.TimeZone)); - //else - // timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time"); // Default to Pacific as it's better then UTC - - //if (timeZoneInfo != null) - //{ - // newTime = TimeZoneInfo.ConvertTimeFromUtc(timestamp, timeZoneInfo); - //} - - //return newTime.FormatForDepartment(department); - - string timeZone = "Pacific Standard Time"; - - if (!String.IsNullOrEmpty(department.TimeZone)) - timeZone = department.TimeZone; - - var ianaTz = TZConvert.WindowsToIana(timeZone); - - var localTime = Instant.FromDateTimeUtc(DateTime.SpecifyKind(timestamp, DateTimeKind.Utc)); //LocalDateTime.FromDateTime(timestamp); - //var zonedDateTime = localTime.InZoneLeniently(DateTimeZoneProviders.Tzdb[ianaTz]); - var zonedDateTime = localTime.InZone(DateTimeZoneProviders.Tzdb[ianaTz]); - - return zonedDateTime.ToDateTimeUnspecified().FormatForDepartment(department); - } - catch (Exception ex) - { - var method = new StackTrace().GetFrame(1).GetMethod(); - Framework.Logging.LogError(String.Format("TimeConverterToString error called from '{0}' of class '{1}' error {2}", method.Name, method.DeclaringType, ex.ToString())); - - return timestamp.FormatForDepartment(department); - } - } + { + department ??= new Department(); + return timestamp.TimeConverter(department).FormatForDepartment(department); + } public static string FormatForDepartment(this DateTime timestamp, Department department, bool dropSeconds = false) { @@ -110,10 +76,11 @@ public static TimeSpan GetOffsetForDepartment(Department department) // NodaTime tzdb (no ICU / OS tzdata dependency, unlike TimeZoneInfo). GetUtcOffset // already folds the active DST rule into the returned offset for the given instant. - var ianaTz = TZConvert.WindowsToIana(DateTimeHelpers.ConvertTimeZoneString(timeZone)); + var id = DateTimeHelpers.ConvertTimeZoneString(timeZone); + var zone = DateTimeZoneProviders.Tzdb.GetZoneOrNull(id) ?? DateTimeZoneProviders.Tzdb[TZConvert.WindowsToIana(id)]; var instant = Instant.FromDateTimeUtc(DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Utc)); - timeSpan = DateTimeZoneProviders.Tzdb[ianaTz].GetUtcOffset(instant).ToTimeSpan(); + timeSpan = zone.GetUtcOffset(instant).ToTimeSpan(); } catch (Exception ex) { diff --git a/Core/Resgrid.Model/Inventories/InventoryContracts.cs b/Core/Resgrid.Model/Inventories/InventoryContracts.cs index d919d3c4e..5a89b6095 100644 --- a/Core/Resgrid.Model/Inventories/InventoryContracts.cs +++ b/Core/Resgrid.Model/Inventories/InventoryContracts.cs @@ -22,7 +22,7 @@ public sealed class InventoryLocationInput { public string Id { get; set; } publ public sealed class InventoryAssetInput { public string Id { get; set; } public string RequestId { get; set; } public string ItemId { get; set; } public string LocationId { get; set; } public string LotId { get; set; } public DateTime? ExpiresOn { get; set; } public InventoryAssetContent Details { get; set; } = new(); } public sealed class InventoryPosting { - [Newtonsoft.Json.JsonProperty(NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] public int? WorkOrderPartMovementId { get; set; } + [Newtonsoft.Json.JsonProperty(NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] public string WorkOrderPartMovementId { get; set; } public string ItemId { get; set; } public string AssetId { get; set; } public string LotId { get; set; } @@ -40,7 +40,7 @@ public sealed class InventoryPosting [Newtonsoft.Json.JsonProperty(NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] public InventoryUsageType? UsageType { get; set; } [Newtonsoft.Json.JsonProperty(NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] public string PurchaseOrderItemId { get; set; } [Newtonsoft.Json.JsonProperty(NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] public string CountItemId { get; set; } - [Newtonsoft.Json.JsonProperty(NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] public int? WorkOrderPartId { get; set; } + [Newtonsoft.Json.JsonProperty(NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] public string WorkOrderPartId { get; set; } public string Note { get; set; } public decimal? UnitCost { get; set; } } diff --git a/Core/Resgrid.Model/Inventories/InventoryModels.cs b/Core/Resgrid.Model/Inventories/InventoryModels.cs index cef418c0b..fa8e537b7 100644 --- a/Core/Resgrid.Model/Inventories/InventoryModels.cs +++ b/Core/Resgrid.Model/Inventories/InventoryModels.cs @@ -72,7 +72,7 @@ public sealed class InventoryAsset : InventoryMutableRow /// EntryId is the bigint database primary key; Id is the stable GUID exposed to integration consumers. Append-only. public sealed class InventoryTransaction : InventoryRow { - public int? WorkOrderPartMovementId { get; set; } + public string WorkOrderPartMovementId { get; set; } public long EntryId { get; set; } public string OperationId { get; set; } public int LineNumber { get; set; } @@ -95,7 +95,7 @@ public sealed class InventoryTransaction : InventoryRow public string IssuanceId { get; set; } public string PurchaseOrderItemId { get; set; } public string CountItemId { get; set; } - public int? WorkOrderPartId { get; set; } + public string WorkOrderPartId { get; set; } public int? LegacyInventoryId { get; set; } public DateTime OccurredOn { get; set; } } diff --git a/Core/Resgrid.Model/Repositories/IWorkOrderRepository.cs b/Core/Resgrid.Model/Repositories/IWorkOrderRepository.cs index 72a3444a3..44544be64 100644 --- a/Core/Resgrid.Model/Repositories/IWorkOrderRepository.cs +++ b/Core/Resgrid.Model/Repositories/IWorkOrderRepository.cs @@ -9,17 +9,17 @@ public interface IWorkOrderRepository Task LockDepartmentAsync(int departmentId); Task ClaimNotificationAsync(WorkOrderNotification row, System.DateTime now); Task FinishNotificationAsync(WorkOrderNotification row, int state, System.DateTime now); - Task GetAsync(int departmentId, int id, bool includeData = true) where T : WorkOrderRow; + Task GetAsync(int departmentId, string id, bool includeData = true) where T : WorkOrderRow; Task RequestAsync(int departmentId, string requestId); Task> ListAsync(int departmentId, WorkOrderReadScope scope, WorkOrderFilter filter); - Task> ReportOrdersAsync(int departmentId, WorkOrderReadScope scope, WorkOrderReportQuery query, int take, int[] ids = null); - Task> ReportChildrenAsync(int departmentId, int[] orderIds, int afterId, int take) where T : WorkOrderRow; - Task CaptureReportSnapshotAsync(WorkOrder row, System.DateTime recordedOn, int? activityId); - Task> ReportSnapshotsAsync(int departmentId, int[] orderIds, System.DateTime asOf); - Task> ReportSnapshotHistoryAsync(int departmentId, int orderId, long afterId); - Task> ReportPacketOrdersAsync(int departmentId, WorkOrderReadScope scope, System.DateTime asOf, int[] unitIds, string[] assetIds, int afterId); + Task> ReportOrdersAsync(int departmentId, WorkOrderReadScope scope, WorkOrderReportQuery query, int take, string[] ids = null); + Task> ReportChildrenAsync(int departmentId, string[] orderIds, string afterId, int take) where T : WorkOrderRow; + Task CaptureReportSnapshotAsync(WorkOrder row, System.DateTime recordedOn, string activityId); + Task> ReportSnapshotsAsync(int departmentId, string[] orderIds, System.DateTime asOf); + Task> ReportSnapshotHistoryAsync(int departmentId, string orderId, string afterId); + Task> ReportPacketOrdersAsync(int departmentId, WorkOrderReadScope scope, System.DateTime asOf, int[] unitIds, string[] assetIds, string afterId); Task HasUnrecoverableReportHistoryAsync(int departmentId, WorkOrderReadScope scope, System.DateTime asOf); - Task> ChildrenAsync(int departmentId, int orderId, int skip = 0) where T : WorkOrderRow; + Task> ChildrenAsync(int departmentId, string orderId, int skip = 0) where T : WorkOrderRow; Task NextNumberAsync(int departmentId, int year); Task AllocateAsync(T row) where T : WorkOrderRow; Task WriteAsync(T row) where T : WorkOrderRow; diff --git a/Core/Resgrid.Model/Search/SearchProjection.cs b/Core/Resgrid.Model/Search/SearchProjection.cs index 1715531eb..69dd7d8af 100644 --- a/Core/Resgrid.Model/Search/SearchProjection.cs +++ b/Core/Resgrid.Model/Search/SearchProjection.cs @@ -44,7 +44,7 @@ public class SearchProjection : IEntity public string OwnerUserId { get; set; } - /// Comma-separated user ids that may see the row regardless of other rules (message recipients). + /// Comma-separated user ids that may see the row regardless of other rules (message recipients, deployment roster). public string ParticipantUserIds { get; set; } public bool IsAdminOnly { get; set; } diff --git a/Core/Resgrid.Model/Services/ISearchServices.cs b/Core/Resgrid.Model/Services/ISearchServices.cs index eac32c303..41dc72f9b 100644 --- a/Core/Resgrid.Model/Services/ISearchServices.cs +++ b/Core/Resgrid.Model/Services/ISearchServices.cs @@ -101,6 +101,13 @@ public class GlobalSearchQuery public string Text { get; set; } public List EntityTypes { get; set; } public string ViewerUserId { get; set; } + /// + /// Families the index returns only when is the projection's owner or a participant, in + /// addition to Message (always viewer-scoped). The orchestrator names the families whose per-hit rule is + /// membership for this caller — deployments for a member without the Deployments/View claim — so rows the caller + /// can never see do not consume the candidate window or suppress the total. + /// + public List ViewerScopedEntityTypes { get; set; } public bool IncludeAdminOnly { get; set; } public bool Prefix { get; set; } public int Skip { get; set; } diff --git a/Core/Resgrid.Model/Services/IWorkOrderNotificationService.cs b/Core/Resgrid.Model/Services/IWorkOrderNotificationService.cs index 1a36c67f4..69774cbb0 100644 --- a/Core/Resgrid.Model/Services/IWorkOrderNotificationService.cs +++ b/Core/Resgrid.Model/Services/IWorkOrderNotificationService.cs @@ -14,7 +14,7 @@ public sealed class WorkOrderNotification public string EventId { get; set; } public string UserId { get; set; } public int DepartmentId { get; set; } - public int WorkOrderId { get; set; } + public string WorkOrderId { get; set; } public int State { get; set; } public string LeaseOwner { get; set; } public DateTime? LeaseExpiresOn { get; set; } diff --git a/Core/Resgrid.Model/Services/IWorkOrdersService.cs b/Core/Resgrid.Model/Services/IWorkOrdersService.cs index 9d600d4f5..b60be319c 100644 --- a/Core/Resgrid.Model/Services/IWorkOrdersService.cs +++ b/Core/Resgrid.Model/Services/IWorkOrdersService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Resgrid.Model.Checklists; using Resgrid.Model.WorkOrders; @@ -9,19 +10,19 @@ namespace Resgrid.Model.Services public interface IWorkOrdersService { Task ListAsync(ChecklistActor actor, WorkOrderFilter filter); - Task GetAsync(ChecklistActor actor, int id); + Task GetAsync(ChecklistActor actor, string id); Task CreateAsync(ChecklistActor actor, WorkOrderInput input); - Task UpdateAsync(ChecklistActor actor, int id, WorkOrderInput input); - Task TransitionAsync(ChecklistActor actor, int id, WorkOrderTransition input); - Task AssignAsync(ChecklistActor actor, int id, WorkOrderAssignment input); - Task AcceptAssignmentAsync(ChecklistActor actor, int id, int revision); - Task CommentAsync(ChecklistActor actor, int id, int revision, string note); - Task AddLaborAsync(ChecklistActor actor, int id, WorkOrderLaborInput input); - Task AddPartAsync(ChecklistActor actor, int id, WorkOrderPartInput input); - Task VoidPartAsync(ChecklistActor actor, int id, int partId, int revision, string reason); - Task AddFileAsync(ChecklistActor actor, int id, int revision, string filename, string contentType, byte[] data); - Task GetFileAsync(ChecklistActor actor, int id); - Task WithdrawFileAsync(ChecklistActor actor, int id, int fileId, int revision, string reason); + Task UpdateAsync(ChecklistActor actor, string id, WorkOrderInput input); + Task TransitionAsync(ChecklistActor actor, string id, WorkOrderTransition input); + Task AssignAsync(ChecklistActor actor, string id, WorkOrderAssignment input); + Task AcceptAssignmentAsync(ChecklistActor actor, string id, int revision); + Task CommentAsync(ChecklistActor actor, string id, int revision, string note); + Task AddLaborAsync(ChecklistActor actor, string id, WorkOrderLaborInput input); + Task AddPartAsync(ChecklistActor actor, string id, WorkOrderPartInput input); + Task VoidPartAsync(ChecklistActor actor, string id, string partId, int revision, string reason); + Task AddFileAsync(ChecklistActor actor, string id, int revision, string filename, string contentType, byte[] data); + Task GetFileAsync(ChecklistActor actor, string id); + Task WithdrawFileAsync(ChecklistActor actor, string id, string fileId, int revision, string reason); Task ChoicesAsync(ChecklistActor actor); } public interface IWorkOrderAuthorizationService @@ -62,7 +63,7 @@ public sealed class WorkOrderReadScope public bool All { get; set; } public int? GroupId { get; set; } public int[] RoleIds { get; set; } = Array.Empty(); - public bool Allows(WorkOrder row) => All || row.CreatedBy == UserId || row.AssignedToUserId == UserId || GroupId.HasValue && row.TargetGroupId == GroupId || row.AssignedToRoleId.HasValue && Array.IndexOf(RoleIds ?? Array.Empty(), row.AssignedToRoleId.Value) >= 0; + public bool Allows(WorkOrder row) => All || row.CreatedBy == UserId || row.AssignedToUserIds.Contains(UserId) || GroupId.HasValue && row.TargetGroupId == GroupId || row.AssignedToRoleIds.Intersect(RoleIds ?? Array.Empty()).Any(); } public sealed class WorkOrderInput { @@ -81,7 +82,7 @@ public sealed class WorkOrderTransition public int Revision { get; set; } public WorkOrderStatus Status { get; set; } public string Reason { get; set; } - public int? DuplicateOfId { get; set; } + public string DuplicateOfId { get; set; } public string Resolution { get; set; } public string Cause { get; set; } public string VerificationEvidence { get; set; } @@ -93,7 +94,7 @@ public sealed class WorkOrderAssignment { public int Revision { get; set; } publ public sealed class WorkOrderSummary { public WorkOrderStatus? QuickStatus { get; set; } - public int Id { get; set; } + public string Id { get; set; } public string Number { get; set; } public string Title { get; set; } public WorkOrderStatus Status { get; set; } @@ -104,6 +105,8 @@ public sealed class WorkOrderSummary public DateTime? DueOn { get; set; } public string AssignedToUserId { get; set; } public int? AssignedToRoleId { get; set; } + public List AssignedToUserIds { get; set; } = new(); + public List AssignedToRoleIds { get; set; } = new(); public int? UnitId { get; set; } public int? GroupId { get; set; } public string AssetId { get; set; } @@ -136,13 +139,14 @@ public sealed class WorkOrderDetail public List Parts { get; set; } = new List(); public List Files { get; set; } = new List(); } - public sealed class WorkOrderActivityView { public DateTime? OriginalDueOn { get; set; } public DateTime? RevisedDueOn { get; set; } public WorkOrderContent Snapshot { get; set; } public string AssignedToUserId { get; set; } public int? AssignedToRoleId { get; set; } public int Id { get; set; } public WorkOrderActivityType Type { get; set; } public string UserId { get; set; } public DateTime CreatedOn { get; set; } public string Note { get; set; } public int? OldStatus { get; set; } public int? NewStatus { get; set; } } - public sealed class WorkOrderLaborView { public int Id { get; set; } public string UserId { get; set; } public DateTime WorkDate { get; set; } public WorkOrderLaborContent Content { get; set; } } - public sealed class WorkOrderPartView { public bool Staged { get; set; } public decimal ReservedQuantity { get; set; } public decimal IssuedQuantity { get; set; } public decimal ConsumedQuantity { get; set; } public decimal ReturnedQuantity { get; set; } public string InventoryWitnessRequestId { get; set; } public string InventoryItemId { get; set; } public string InventoryTransactionId { get; set; } public string InventoryOperationId { get; set; } public bool AwaitingWitness { get; set; } public int Id { get; set; } public DateTime? VoidedOn { get; set; } public WorkOrderPartContent Content { get; set; } } - public sealed class WorkOrderFileView { public int Id { get; set; } public string Name { get; set; } public string ContentType { get; set; } public int Size { get; set; } public DateTime? WithdrawnOn { get; set; } } + public sealed class WorkOrderActivityView { public DateTime? OriginalDueOn { get; set; } public DateTime? RevisedDueOn { get; set; } public WorkOrderContent Snapshot { get; set; } public string AssignedToUserId { get; set; } public int? AssignedToRoleId { get; set; } public string Id { get; set; } public WorkOrderActivityType Type { get; set; } public string UserId { get; set; } public DateTime CreatedOn { get; set; } public string Note { get; set; } public int? OldStatus { get; set; } public int? NewStatus { get; set; } } + public sealed class WorkOrderLaborView { public string Id { get; set; } public string UserId { get; set; } public DateTime WorkDate { get; set; } public WorkOrderLaborContent Content { get; set; } } + public sealed class WorkOrderPartView { public bool Staged { get; set; } public decimal ReservedQuantity { get; set; } public decimal IssuedQuantity { get; set; } public decimal ConsumedQuantity { get; set; } public decimal ReturnedQuantity { get; set; } public string InventoryWitnessRequestId { get; set; } public string InventoryItemId { get; set; } public string InventoryTransactionId { get; set; } public string InventoryOperationId { get; set; } public bool AwaitingWitness { get; set; } public string Id { get; set; } public DateTime? VoidedOn { get; set; } public WorkOrderPartContent Content { get; set; } } + public sealed class WorkOrderFileView { public string Id { get; set; } public string Name { get; set; } public string ContentType { get; set; } public int Size { get; set; } public DateTime? WithdrawnOn { get; set; } } public sealed class WorkOrderChoice { public string Id { get; set; } public string Name { get; set; } } public sealed class WorkOrderChoices { + public string Currency { get; set; } = "USD"; public List Users { get; set; } = new List(); public List Roles { get; set; } = new List(); public List Units { get; set; } = new List(); diff --git a/Core/Resgrid.Model/WorkOrders/WorkOrderAssignees.cs b/Core/Resgrid.Model/WorkOrders/WorkOrderAssignees.cs new file mode 100644 index 000000000..f6930f269 --- /dev/null +++ b/Core/Resgrid.Model/WorkOrders/WorkOrderAssignees.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; + +namespace Resgrid.Model.WorkOrders +{ + /// Assignment routing metadata; legacy single assignments remain readable. + public static class WorkOrderAssignees + { + public static List Users(string json, string legacy) => + (json == null ? new List() : JsonConvert.DeserializeObject>(json) ?? new()) + .Concat(string.IsNullOrEmpty(legacy) ? System.Array.Empty() : new[] { legacy }).Distinct().ToList(); + + public static List Roles(string json, int? legacy) => + (json == null ? new List() : JsonConvert.DeserializeObject>(json) ?? new()) + .Concat(legacy.HasValue ? new[] { legacy.Value } : System.Array.Empty()).Distinct().ToList(); + } +} diff --git a/Core/Resgrid.Model/WorkOrders/WorkOrderCurrencies.cs b/Core/Resgrid.Model/WorkOrders/WorkOrderCurrencies.cs new file mode 100644 index 000000000..71469d791 --- /dev/null +++ b/Core/Resgrid.Model/WorkOrders/WorkOrderCurrencies.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +namespace Resgrid.Model.WorkOrders +{ + public static class WorkOrderCurrencies + { + // Use the runtime's region catalog for both the selector and server validation. + public static IReadOnlyDictionary Options { get; } = CultureInfo.GetCultures(CultureTypes.SpecificCultures) + .Select(c => new RegionInfo(c.Name)) + .Where(r => r.ISOCurrencySymbol != "XXX") + .GroupBy(r => r.ISOCurrencySymbol, StringComparer.Ordinal) + .OrderBy(g => g.Key, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.First().CurrencyEnglishName, StringComparer.Ordinal); + + public static bool IsSupported(string currency) => currency != null && Options.ContainsKey(currency); + } +} diff --git a/Core/Resgrid.Model/WorkOrders/WorkOrderMaintenance.cs b/Core/Resgrid.Model/WorkOrders/WorkOrderMaintenance.cs index ae4b52fab..543ecc060 100644 --- a/Core/Resgrid.Model/WorkOrders/WorkOrderMaintenance.cs +++ b/Core/Resgrid.Model/WorkOrders/WorkOrderMaintenance.cs @@ -46,12 +46,16 @@ public sealed class WorkOrderHoldView { public WorkOrderSafetyHold Hold { get; s public sealed class WorkOrderRecurrence : WorkOrderRow { public string RequestId { get; set; } - public int CurrentVersionId { get; set; } + public string CurrentVersionId { get; set; } public int? TargetUnitId { get; set; } public int? TargetGroupId { get; set; } public string InventoryAssetId { get; set; } public string AssignedToUserId { get; set; } public int? AssignedToRoleId { get; set; } + public string AssignedToUserIdsJson { get; set; } + public string AssignedToRoleIdsJson { get; set; } + [System.ComponentModel.DataAnnotations.Schema.NotMapped] public List AssignedToUserIds { get => WorkOrderAssignees.Users(AssignedToUserIdsJson, AssignedToUserId); set => AssignedToUserIdsJson = value == null ? null : Newtonsoft.Json.JsonConvert.SerializeObject(value); } + [System.ComponentModel.DataAnnotations.Schema.NotMapped] public List AssignedToRoleIds { get => WorkOrderAssignees.Roles(AssignedToRoleIdsJson, AssignedToRoleId); set => AssignedToRoleIdsJson = value == null ? null : Newtonsoft.Json.JsonConvert.SerializeObject(value); } public int Priority { get; set; } public bool IsActive { get; set; } public int Calendar { get; set; } @@ -68,7 +72,7 @@ public sealed class WorkOrderRecurrence : WorkOrderRow public DateTime? BlackoutUntil { get; set; } public DateTime? NextDueOn { get; set; } public long Cycle { get; set; } - public int? PendingWorkOrderId { get; set; } + public string PendingWorkOrderId { get; set; } public bool ReadingDue { get; set; } public DateTime? ReadingDueOn { get; set; } public int MeterUnit { get; set; } @@ -84,23 +88,25 @@ public sealed class WorkOrderRecurrence : WorkOrderRow public int? EscalationRoleId { get; set; } } /// Immutable protected instructions/configuration; generated orders retain this identity. - public sealed class WorkOrderRecurrenceVersion : WorkOrderRow { public int RecurrenceId { get; set; } } + public sealed class WorkOrderRecurrenceVersion : WorkOrderRow { public string RecurrenceId { get; set; } } public sealed class WorkOrderMeterReading : WorkOrderRow { - public int RecurrenceId { get; set; } + public string RecurrenceId { get; set; } public string RequestId { get; set; } public int MeterEpoch { get; set; } public DateTime ObservedOn { get; set; } } - public sealed class WorkOrderRecurrenceChange : WorkOrderRow { public int RecurrenceId { get; set; } public int ChangeType { get; set; } public DateTime? OriginalDueOn { get; set; } public DateTime? RevisedDueOn { get; set; } } + public sealed class WorkOrderRecurrenceChange : WorkOrderRow { public string RecurrenceId { get; set; } public int ChangeType { get; set; } public DateTime? OriginalDueOn { get; set; } public DateTime? RevisedDueOn { get; set; } } public sealed class WorkOrderRecurrenceInput { - public int Id { get; set; } + public string Id { get; set; } public int Revision { get; set; } public bool IsActive { get; set; } = true; public WorkOrderInput Template { get; set; } = new(); public string AssignedToUserId { get; set; } public int? AssignedToRoleId { get; set; } + public List AssignedToUserIds { get; set; } + public List AssignedToRoleIds { get; set; } public MaintenanceCalendar Calendar { get; set; } = MaintenanceCalendar.Monthly; public int Interval { get; set; } = 1; public string TimeZoneId { get; set; } = "UTC"; @@ -136,27 +142,27 @@ namespace Resgrid.Model.Services public interface IWorkOrderMaintenanceService { Task> InventoryChoicesAsync(ChecklistActor actor, string kind, string itemId = null, int page = 0); - Task> HoldsAsync(ChecklistActor actor, int orderId); - Task CancelPartWitnessAsync(ChecklistActor actor, int orderId, int partId, int revision, string reason); - Task AddHoldAsync(ChecklistActor actor, int orderId, WorkOrderHoldInput input); - Task ReleaseHoldAsync(ChecklistActor actor, int holdId, WorkOrderReleaseInput input); + Task> HoldsAsync(ChecklistActor actor, string orderId); + Task CancelPartWitnessAsync(ChecklistActor actor, string orderId, string partId, int revision, string reason); + Task AddHoldAsync(ChecklistActor actor, string orderId, WorkOrderHoldInput input); + Task ReleaseHoldAsync(ChecklistActor actor, string holdId, WorkOrderReleaseInput input); Task> RecurrencesAsync(ChecklistActor actor, int page = 0); - Task RecurrenceAsync(ChecklistActor actor, int id, int historyPage = 0); - Task SaveRecurrenceAsync(ChecklistActor actor, WorkOrderRecurrenceInput input); - Task RecordReadingAsync(ChecklistActor actor, int id, WorkOrderReadingInput input); - Task DeferAsync(ChecklistActor actor, int orderId, WorkOrderDeferralInput input); + Task RecurrenceAsync(ChecklistActor actor, string id, int historyPage = 0); + Task SaveRecurrenceAsync(ChecklistActor actor, WorkOrderRecurrenceInput input); + Task RecordReadingAsync(ChecklistActor actor, string id, WorkOrderReadingInput input); + Task DeferAsync(ChecklistActor actor, string orderId, WorkOrderDeferralInput input); Task GenerateMaintenanceAsync(int departmentId); Task EscalateMaintenanceAsync(int departmentId); Task ValidateFailureOptionsAsync(ChecklistActor actor, ChecklistForm form); Task RecordFailureIntentAsync(ChecklistActor actor, ChecklistCompletion completion, ChecklistDefinitionVersion version, ChecklistItem item); - Task CompleteInventoryPartAsync(InventoryActor actor, int partId, string transactionId, bool reversal, List events); + Task CompleteInventoryPartAsync(InventoryActor actor, string partId, string transactionId, bool reversal, List events); } /// Inventory owns its permissions, controlled witnesses, ledger and asset state. Caller owns the shared transaction. public interface IInventoryWorkOrderAdapter { - Task QuotePartAsync(InventoryActor actor, int orderId, WorkOrderPartInput input); - Task PostPartAsync(InventoryActor actor, int partId, InventoryCommand command); - Task CancelPendingPartAsync(InventoryActor actor, int partId, string operationId); - Task ApplyHoldAsync(InventoryActor actor, int orderId, string assetId, int? restoreState = null, int? expectedRevision = null, bool safetyRelease = false); + Task QuotePartAsync(InventoryActor actor, string orderId, WorkOrderPartInput input); + Task PostPartAsync(InventoryActor actor, string partId, InventoryCommand command); + Task CancelPendingPartAsync(InventoryActor actor, string partId, string operationId); + Task ApplyHoldAsync(InventoryActor actor, string orderId, string assetId, int? restoreState = null, int? expectedRevision = null, bool safetyRelease = false); } } diff --git a/Core/Resgrid.Model/WorkOrders/WorkOrderModels.cs b/Core/Resgrid.Model/WorkOrders/WorkOrderModels.cs index 53a98c0c7..ef7c72b8b 100644 --- a/Core/Resgrid.Model/WorkOrders/WorkOrderModels.cs +++ b/Core/Resgrid.Model/WorkOrders/WorkOrderModels.cs @@ -9,17 +9,17 @@ public enum WorkOrderStatus { Requested = 0, Accepted = 1, Assigned = 2, InProgr public enum WorkOrderPriority { Low = 0, Normal = 1, High = 2, Emergency = 3 } public enum WorkOrderType { Corrective = 0, Preventive = 1, Inspection = 2, Facility = 3, Other = 4 } public enum WorkOrderActivityType { Created = 0, Updated = 1, StatusChanged = 2, Assigned = 3, AssignmentAccepted = 4, Comment = 5, LaborAdded = 6, PartAdded = 7, PartVoided = 8, FileAdded = 9, FileWithdrawn = 10, SafetyHold = 11, SafetyReleased = 12, Deferred = 13, Escalated = 14 } - /// Free text, monetary details and file names live only in cataloged Content. Numeric identities are allocated with empty content before sealing. + /// Free text, monetary details and file names live only in cataloged Content. GUID identities are allocated with empty content before sealing. public abstract class WorkOrderRow : IEntity { - [NotMapped, Newtonsoft.Json.JsonIgnore] public object IdValue { get => Id; set => Id = Convert.ToInt32(value); } + [NotMapped, Newtonsoft.Json.JsonIgnore] public object IdValue { get => Id; set => Id = (string)value; } [NotMapped, Newtonsoft.Json.JsonIgnore] public string TableName => WorkOrderTables.All[GetType()]; [NotMapped, Newtonsoft.Json.JsonIgnore] public string IdName => "Id"; - [NotMapped, Newtonsoft.Json.JsonIgnore] public int IdType => 0; + [NotMapped, Newtonsoft.Json.JsonIgnore] public int IdType => 1; [NotMapped, Newtonsoft.Json.JsonIgnore] public IEnumerable IgnoredProperties => new[] { "IdValue", "TableName", "IdName", "IdType", "IgnoredProperties" }; - public int Id { get; set; } + public string Id { get; set; } public int DepartmentId { get; set; } - public int? WorkOrderId { get; set; } + public string WorkOrderId { get; set; } public string Content { get; set; } public int Revision { get; set; } = 1; public DateTime CreatedOn { get; set; } @@ -29,6 +29,8 @@ public abstract class WorkOrderRow : IEntity } public sealed class WorkOrder : WorkOrderRow { + // Pins department currency at creation, including worker-generated orders with deferred content. + public string CurrencyCode { get; set; } public DateTime? ResponseDueOn { get; set; } public DateTime? RepairDueOn { get; set; } public DateTime? ResponseOn { get; set; } @@ -50,6 +52,10 @@ public sealed class WorkOrder : WorkOrderRow public string InventoryAssetId { get; set; } public string AssignedToUserId { get; set; } public int? AssignedToRoleId { get; set; } + public string AssignedToUserIdsJson { get; set; } + public string AssignedToRoleIdsJson { get; set; } + [NotMapped] public List AssignedToUserIds { get => WorkOrderAssignees.Users(AssignedToUserIdsJson, AssignedToUserId); set => AssignedToUserIdsJson = value == null ? null : Newtonsoft.Json.JsonConvert.SerializeObject(value); } + [NotMapped] public List AssignedToRoleIds { get => WorkOrderAssignees.Roles(AssignedToRoleIdsJson, AssignedToRoleId); set => AssignedToRoleIdsJson = value == null ? null : Newtonsoft.Json.JsonConvert.SerializeObject(value); } public DateTime? DueOn { get; set; } public DateTime? TriagedOn { get; set; } public DateTime? AssignedOn { get; set; } @@ -60,12 +66,12 @@ public sealed class WorkOrder : WorkOrderRow public string CompletedBy { get; set; } public DateTime? ClosedOn { get; set; } public string VerifiedBy { get; set; } - public int? DuplicateOfId { get; set; } + public string DuplicateOfId { get; set; } public bool SetUnitOutOfService { get; set; } public bool RestoreUnitStateOnClose { get; set; } public int? PreviousUnitStateType { get; set; } public string WorkOrderRecurrenceId { get; set; } - public int? RecurrenceVersionId { get; set; } + public string RecurrenceVersionId { get; set; } public long? RecurrenceCycle { get; set; } public DateTime? OriginalDueOn { get; set; } public DateTime? EscalatedOn { get; set; } diff --git a/Core/Resgrid.Model/WorkOrders/WorkOrderOperations.cs b/Core/Resgrid.Model/WorkOrders/WorkOrderOperations.cs index 613160d57..bdf2e1255 100644 --- a/Core/Resgrid.Model/WorkOrders/WorkOrderOperations.cs +++ b/Core/Resgrid.Model/WorkOrders/WorkOrderOperations.cs @@ -33,12 +33,16 @@ public sealed class WorkOrderBusinessCalendar } public sealed class WorkOrderPolicy : WorkOrderRow { + // A validated currency code is routing metadata; amounts remain in protected Content. + public string CurrencyCode { get; set; } // Equipment service clocks contain only validated time zones, dates and durations. public string CalendarJson { get; set; } } public sealed class WorkOrderPolicyInput { public int Revision { get; set; } + public string Currency { get; set; } + public decimal? SpendingThreshold { get; set; } public bool ApprovalsEnabled { get; set; } public List SpendingRules { get; set; } = new(); public WorkOrderBusinessCalendar Calendar { get; set; } = new(); @@ -49,12 +53,12 @@ public sealed class WorkOrderBulkRow { public int RowNumber { get; set; } public string ParseError { get; set; } - public int? WorkOrderId { get; set; } + public string WorkOrderId { get; set; } public WorkOrderInput Import { get; set; } public WorkOrderAssignment Assignment { get; set; } } public sealed class WorkOrderBulkInput { public string RequestId { get; set; } public string PreviewHash { get; set; } public List Rows { get; set; } = new(); } - public sealed class WorkOrderBulkRowResult { public int RowNumber { get; set; } public int? WorkOrderId { get; set; } public string Title { get; set; } public string ErrorCode { get; set; } public bool Applied { get; set; } } + public sealed class WorkOrderBulkRowResult { public int RowNumber { get; set; } public string WorkOrderId { get; set; } public string Title { get; set; } public string ErrorCode { get; set; } public bool Applied { get; set; } } public sealed class WorkOrderBulkResult { public string PreviewHash { get; set; } public List Rows { get; set; } = new(); } public sealed class WorkOrderVendorCharge : WorkOrderRow { public string RequestId { get; set; } public DateTime? VoidedOn { get; set; } } public sealed class WorkOrderVendorChargeContent @@ -69,10 +73,10 @@ public sealed class WorkOrderVendorChargeContent public string RequestHash { get; set; } } public sealed class WorkOrderVendorChargeInput { public int Revision { get; set; } public string RequestId { get; set; } public WorkOrderVendorChargeContent Content { get; set; } = new(); } - public sealed class WorkOrderVendorChargeView { public int Id { get; set; } public DateTime? VoidedOn { get; set; } public WorkOrderVendorChargeContent Content { get; set; } } + public sealed class WorkOrderVendorChargeView { public string Id { get; set; } public DateTime? VoidedOn { get; set; } public WorkOrderVendorChargeContent Content { get; set; } } public sealed class WorkOrderPartMovement : WorkOrderRow { - public int PartId { get; set; } + public string PartId { get; set; } public string RequestId { get; set; } public int Kind { get; set; } public decimal Quantity { get; set; } @@ -95,14 +99,14 @@ public interface IWorkOrderOperationsService Task SavePolicyAsync(ChecklistActor actor, WorkOrderPolicyInput input); Task PreviewBulkAsync(ChecklistActor actor, WorkOrderBulkInput input); Task ApplyBulkAsync(ChecklistActor actor, WorkOrderBulkInput input); - Task RequestApprovalAsync(ChecklistActor actor, int id, WorkOrderApprovalInput input); - Task DecideApprovalAsync(ChecklistActor actor, int id, WorkOrderApprovalInput input); - Task AddVendorChargeAsync(ChecklistActor actor, int id, WorkOrderVendorChargeInput input); - Task VoidVendorChargeAsync(ChecklistActor actor, int id, int chargeId, int revision, string reason); - Task> VendorChargesAsync(ChecklistActor actor, int id, int afterId = 0); - Task ReservePartAsync(ChecklistActor actor, int id, WorkOrderPartInput input); - Task MovePartAsync(ChecklistActor actor, int id, int partId, WorkOrderPartMovementInput input); - Task CancelPartMovementAsync(ChecklistActor actor, int id, int movementId, int revision, string reason); - Task> PartMovementsAsync(ChecklistActor actor, int id, int afterId = 0); + Task RequestApprovalAsync(ChecklistActor actor, string id, WorkOrderApprovalInput input); + Task DecideApprovalAsync(ChecklistActor actor, string id, WorkOrderApprovalInput input); + Task AddVendorChargeAsync(ChecklistActor actor, string id, WorkOrderVendorChargeInput input); + Task VoidVendorChargeAsync(ChecklistActor actor, string id, string chargeId, int revision, string reason); + Task> VendorChargesAsync(ChecklistActor actor, string id, string afterId = null); + Task ReservePartAsync(ChecklistActor actor, string id, WorkOrderPartInput input); + Task MovePartAsync(ChecklistActor actor, string id, string partId, WorkOrderPartMovementInput input); + Task CancelPartMovementAsync(ChecklistActor actor, string id, string movementId, int revision, string reason); + Task> PartMovementsAsync(ChecklistActor actor, string id, string afterId = null); } } diff --git a/Core/Resgrid.Model/WorkOrders/WorkOrderReporting.cs b/Core/Resgrid.Model/WorkOrders/WorkOrderReporting.cs index c59c58300..e10a5c11d 100644 --- a/Core/Resgrid.Model/WorkOrders/WorkOrderReporting.cs +++ b/Core/Resgrid.Model/WorkOrders/WorkOrderReporting.cs @@ -15,7 +15,7 @@ public sealed class WorkOrderReportQuery public int? UnitId { get; set; } public int? GroupId { get; set; } public string AssetId { get; set; } - public int AfterId { get; set; } + public string AfterId { get; set; } } public sealed class WorkOrderCostTotal { @@ -45,12 +45,12 @@ public sealed class WorkOrderReportEntry public sealed class WorkOrderHistoryPage { public List Items { get; set; } = new(); - public int? NextAfterId { get; set; } + public string NextAfterId { get; set; } } public sealed class WorkOrderEvidencePage { public List Items { get; set; } = new(); - public int? NextAfterId { get; set; } + public string NextAfterId { get; set; } } public sealed class WorkOrderStats { @@ -82,9 +82,9 @@ public sealed class WorkOrderStats /// referenced immutable activity/template through ADP; ciphertext is never copied between rows. public sealed class WorkOrderReportSnapshot { - public long Id { get; set; } + public string Id { get; set; } = Guid.NewGuid().ToString("D"); public int DepartmentId { get; set; } - public int WorkOrderId { get; set; } + public string WorkOrderId { get; set; } public int Revision { get; set; } public DateTime? ResponseDueOn { get; set; } public DateTime? RepairDueOn { get; set; } @@ -93,8 +93,8 @@ public sealed class WorkOrderReportSnapshot public DateTime? RepairBreachedOn { get; set; } public int? SlaPolicyRevision { get; set; } public DateTime RecordedOn { get; set; } - public int? SourceActivityId { get; set; } - public int? RecurrenceVersionId { get; set; } + public string SourceActivityId { get; set; } + public string RecurrenceVersionId { get; set; } public int SourceType { get; set; } public int Status { get; set; } public int Priority { get; set; } @@ -108,11 +108,11 @@ public sealed class WorkOrderReportSnapshot } public sealed class ReadinessWorkOrderEvidence { - public int WorkOrderId { get; set; } + public string WorkOrderId { get; set; } public int Revision { get; set; } - public long? SnapshotId { get; set; } - public int? SourceActivityId { get; set; } - public int? RecurrenceVersionId { get; set; } + public string SnapshotId { get; set; } + public string SourceActivityId { get; set; } + public string RecurrenceVersionId { get; set; } public DateTime RecordedOn { get; set; } public string Title { get; set; } public int Status { get; set; } @@ -122,7 +122,7 @@ public sealed class ReadinessWorkOrderEvidence public DateTime? DueOn { get; set; } public string ChecklistCompletionId { get; set; } public string ChecklistItemId { get; set; } - public List ActiveSafetyHoldIds { get; set; } = new(); + public List ActiveSafetyHoldIds { get; set; } = new(); } public sealed class ReadinessWorkOrderSection { @@ -135,8 +135,8 @@ namespace Resgrid.Model.Services { public interface IWorkOrderReportingService { - Task> GetWorkOrderActivityAsync(ChecklistActor actor, int id, int afterId = 0); - Task> GetWorkOrderHoldsAsync(ChecklistActor actor, int id, int afterId = 0); + Task> GetWorkOrderActivityAsync(ChecklistActor actor, string id, string afterId = null); + Task> GetWorkOrderHoldsAsync(ChecklistActor actor, string id, string afterId = null); Task GetWorkOrderStatsAsync(ChecklistActor actor, WorkOrders.WorkOrderReportQuery query); Task GetWorkOrderHistoryAsync(ChecklistActor actor, WorkOrders.WorkOrderReportQuery query); Task ExportWorkOrdersAsync(ChecklistActor actor, WorkOrders.WorkOrderReportQuery query); diff --git a/Core/Resgrid.Model/WorkOrders/WorkOrderWorkflowPayload.cs b/Core/Resgrid.Model/WorkOrders/WorkOrderWorkflowPayload.cs index 1a4ea3bb9..5fffaecea 100644 --- a/Core/Resgrid.Model/WorkOrders/WorkOrderWorkflowPayload.cs +++ b/Core/Resgrid.Model/WorkOrders/WorkOrderWorkflowPayload.cs @@ -15,9 +15,10 @@ public static class WorkOrderWorkflowPayload public static string Routing(JObject payload) { var safe = new JObject(); - foreach (var field in new[] { "WorkOrderId", "Revision", "Status", "Priority", "TargetUnitId", "TargetGroupId", "AssignedToRoleId", "RecurrenceId", "HoldId", "OldStatus", "PolicyId", "ApprovalState" }) + foreach (var field in new[] { "Revision", "Status", "Priority", "TargetUnitId", "TargetGroupId", "AssignedToRoleId", "OldStatus", "ApprovalState" }) if (payload[field]?.Type == JTokenType.Integer && payload[field].Value() >= 0 && payload[field].Value() <= int.MaxValue) safe[field] = payload[field].DeepClone(); - if (payload["InventoryAssetId"]?.Type == JTokenType.String && Guid.TryParseExact(payload["InventoryAssetId"].Value(), "D", out var asset)) safe["InventoryAssetId"] = asset.ToString("D"); + foreach (var field in new[] { "WorkOrderId", "RecurrenceId", "HoldId", "PolicyId", "InventoryAssetId" }) + if (payload[field]?.Type == JTokenType.String && Guid.TryParseExact(payload[field].Value(), "D", out var id)) safe[field] = id.ToString("D"); foreach (var field in new[] { "DueOn", "ResponseDueOn", "RepairDueOn", "ResponseBreachedOn", "RepairBreachedOn" }) { if (payload[field] is JValue { Type: JTokenType.Date } date) diff --git a/Core/Resgrid.Search/LuceneGlobalSearchService.cs b/Core/Resgrid.Search/LuceneGlobalSearchService.cs index 96db6494c..d4867a2fc 100644 --- a/Core/Resgrid.Search/LuceneGlobalSearchService.cs +++ b/Core/Resgrid.Search/LuceneGlobalSearchService.cs @@ -193,10 +193,22 @@ public static Query BuildQuery(int departmentId, GlobalSearchQuery request) query.Add(types, Occur.MUST); } - // Messages: only the sender or a recipient may see them. (+Message +(owner OR participant)) OR (NOT Message). + // Viewer-scoped families: only the owner or a participant may see them. Messages always (sender or + // recipient); the orchestrator adds the families that are membership-scoped for this caller, e.g. + // deployments for a member without the claim (rostered user ids are the participants). + // (+scopedType +(owner OR participant)) OR (NOT scopedType). + var scopedTypes = new HashSet(StringComparer.OrdinalIgnoreCase) { SearchEntityTypes.Message }; + foreach (var type in request.ViewerScopedEntityTypes ?? Enumerable.Empty()) + if (!string.IsNullOrWhiteSpace(type)) scopedTypes.Add(type.Trim()); + BooleanQuery ScopedTypes() + { + var types = new BooleanQuery(); + foreach (var type in scopedTypes) types.Add(new TermQuery(new Term(GlobalIndexFields.EntityType, type)), Occur.SHOULD); + return types; + } var viewer = request.ViewerUserId ?? string.Empty; - var messageScope = new BooleanQuery(); - var messageForViewer = new BooleanQuery { { new TermQuery(new Term(GlobalIndexFields.EntityType, SearchEntityTypes.Message)), Occur.MUST } }; + var viewerScope = new BooleanQuery(); + var scopedForViewer = new BooleanQuery { { ScopedTypes(), Occur.MUST } }; var viewerMatch = new BooleanQuery(); if (viewer.Length > 0) { @@ -207,14 +219,14 @@ public static Query BuildQuery(int departmentId, GlobalSearchQuery request) { viewerMatch.Add(new TermQuery(new Term(GlobalIndexFields.Key, " none")), Occur.SHOULD); } - messageForViewer.Add(viewerMatch, Occur.MUST); - messageScope.Add(messageForViewer, Occur.SHOULD); - messageScope.Add(new BooleanQuery + scopedForViewer.Add(viewerMatch, Occur.MUST); + viewerScope.Add(scopedForViewer, Occur.SHOULD); + viewerScope.Add(new BooleanQuery { { new MatchAllDocsQuery(), Occur.MUST }, - { new TermQuery(new Term(GlobalIndexFields.EntityType, SearchEntityTypes.Message)), Occur.MUST_NOT } + { ScopedTypes(), Occur.MUST_NOT } }, Occur.SHOULD); - query.Add(messageScope, Occur.MUST); + query.Add(viewerScope, Occur.MUST); if (!request.IncludeAdminOnly) query.Add(new TermQuery(new Term(GlobalIndexFields.IsAdminOnly, "1")), Occur.MUST_NOT); diff --git a/Core/Resgrid.Services/AdpTableBindings.cs b/Core/Resgrid.Services/AdpTableBindings.cs index 486c83321..ac082d554 100644 --- a/Core/Resgrid.Services/AdpTableBindings.cs +++ b/Core/Resgrid.Services/AdpTableBindings.cs @@ -606,7 +606,7 @@ AdpColumnSpec Companion(string table, string column, bool boolean = false) => }) with { ProtectedMarkerColumn = "IsProtected" } }; bindings.AddRange(Resgrid.Model.WorkOrders.WorkOrderTables.All.Values.Select(table => - AdpTableBinding.Direct(table, "Id", true, "DepartmentId", table == "WorkOrderFiles" ? new[] { Text(table, "Content"), Binary(table, "Data") } : new[] { Text(table, "Content") }) with { ProtectedMarkerColumn = "IsProtected" })); + AdpTableBinding.Direct(table, "Id", false, "DepartmentId", table == "WorkOrderFiles" ? new[] { Text(table, "Content"), Binary(table, "Data") } : new[] { Text(table, "Content") }) with { ProtectedMarkerColumn = "IsProtected" })); // Inventory transactions use their stable GUID public Id for field AAD, independently of the bigint EntryId ledger key. bindings.AddRange(Resgrid.Model.Inventories.InventoryTables.All.Values.Select(table => AdpTableBinding.Direct(table, "Id", false, "DepartmentId", new[] { Text(table, "Content") }) with { ProtectedMarkerColumn = "IsProtected" })); diff --git a/Core/Resgrid.Services/ChecklistReportDocuments.cs b/Core/Resgrid.Services/ChecklistReportDocuments.cs index 342fd8b73..9e155a3b2 100644 --- a/Core/Resgrid.Services/ChecklistReportDocuments.cs +++ b/Core/Resgrid.Services/ChecklistReportDocuments.cs @@ -37,14 +37,19 @@ public static string ComplianceBody(ChecklistComplianceSummary report, bool miss var html = Compliance(report, missedOnly); return html.Substring(html.IndexOf("", StringComparison.Ordinal) + 6).Replace("", ""); } - private static string Entries(IEnumerable entries) + private static string Entries(IEnumerable entries, Func format = null) { + format ??= utc => utc?.ToString("u"); var body = new StringBuilder("" + Head("Name", "Target", "ScheduledFor", "WindowEnds", "CompletedChecks", "MissedChecks", "Result", "Score")); - foreach (var entry in entries) body.Append("").Append(Cell(entry.Name)).Append(Cell(entry.Target.Name)).Append(Cell(entry.StartUtc.ToString("u"))).Append(Cell(entry.DueUtc?.ToString("u"))).Append(Cell(Text(entry.Completed ? "Yes" : "No"))).Append(Cell(Text(entry.Missed ? "Yes" : "No"))).Append(Cell(entry.Passed.HasValue ? Text(entry.Passed.Value ? "Passed" : "Failed") : "—")).Append(Cell(entry.Score)).Append(""); + foreach (var entry in entries) body.Append("").Append(Cell(entry.Name)).Append(Cell(entry.Target.Name)).Append(Cell(format(entry.StartUtc))).Append(Cell(format(entry.DueUtc))).Append(Cell(Text(entry.Completed ? "Yes" : "No"))).Append(Cell(Text(entry.Missed ? "Yes" : "No"))).Append(Cell(entry.Passed.HasValue ? Text(entry.Passed.Value ? "Passed" : "Failed") : "—")).Append(Cell(entry.Score)).Append(""); return body.Append("
").ToString(); } private static string Unavailable(IEnumerable sources) => "
    " + string.Concat(sources.Select(s => "
  • " + H(Text(s)) + "
  • ")) + "
"; - public static string Packet(ReadinessEvidenceManifestV1 packet) => Page("ReadinessPacketReport", "

" + H(Text("CallId")) + ": " + H(packet.CallId) + "

" + H(packet.CoverageStartUtc.ToString("u")) + " — " + H(packet.CoverageEndUtc.ToString("u")) + "

" + H(Text("PacketMethod")) + "

" + H(Text("DispatchedUnits")) + "

    " + string.Concat(packet.Units.Select(u => "
  • " + H(u.Name) + " (#" + H(u.UnitId) + "; " + H(u.DispatchedUtc.ToString("u")) + ")
  • ")) + "

" + H(Text("IssuedEquipment")) + "

    " + string.Concat(packet.Assets.Select(a => "
  • " + H(a.Name) + " (" + H(a.AssetId) + ")
  • ")) + "
" + Entries(packet.Checklists) + WorkOrderReportDocuments.PacketSection(packet.WorkOrders) + Unavailable(packet.UnavailableSources)); + public static string Packet(ReadinessEvidenceManifestV1 packet, Func format = null) + { + format ??= utc => utc?.ToString("u"); + return Page("ReadinessPacketReport", "

" + H(Text("CallId")) + ": " + H(packet.CallId) + "

" + H(format(packet.CoverageStartUtc)) + " — " + H(format(packet.CoverageEndUtc)) + "

" + H(Text("PacketMethod")) + "

" + H(Text("DispatchedUnits")) + "

    " + string.Concat(packet.Units.Select(u => "
  • " + H(u.Name) + " (#" + H(u.UnitId) + "; " + H(format(u.DispatchedUtc)) + ")
  • ")) + "

" + H(Text("IssuedEquipment")) + "

    " + string.Concat(packet.Assets.Select(a => "
  • " + H(a.Name) + " (" + H(a.AssetId) + ")
  • ")) + "
" + Entries(packet.Checklists, format) + WorkOrderReportDocuments.PacketSection(packet.WorkOrders, format) + Unavailable(packet.UnavailableSources)); + } public static string Sha256(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); public static void EnsureStillAuthorized(ReadinessEvidenceManifestV1 captured, ReadinessEvidenceManifestV1 current) { @@ -55,10 +60,10 @@ public static void EnsureStillAuthorized(ReadinessEvidenceManifestV1 captured, R || captured.Assets.Any(a => !current.Assets.Any(c => c.AssetId == a.AssetId && c.SourceId == a.SourceId))) throw new ChecklistException(403, "The request could not be completed."); } - public static ReadinessEvidencePackage Package(ReadinessEvidenceManifestV1 manifest, IPdfProvider pdf) + public static ReadinessEvidencePackage Package(ReadinessEvidenceManifestV1 manifest, IPdfProvider pdf, Func format = null) { var json = JsonConvert.SerializeObject(manifest, Formatting.None, new JsonSerializerSettings { DateTimeZoneHandling = DateTimeZoneHandling.Utc, Culture = CultureInfo.InvariantCulture }); - var bytes = pdf.ConvertHtmlToPdf(Packet(manifest)); + var bytes = pdf.ConvertHtmlToPdf(Packet(manifest, format)); if (bytes == null || bytes.Length < 5 || Encoding.ASCII.GetString(bytes, 0, 5) != "%PDF-") throw new InvalidOperationException("Checklist PDF generation failed."); if (bytes.Length > 16 * 1024 * 1024) throw new ChecklistException(400, "ReportTooLarge"); return new ReadinessEvidencePackage { ManifestJson = json, ManifestSha256 = Sha256(Encoding.UTF8.GetBytes(json)), Pdf = bytes, PdfSha256 = Sha256(bytes) }; diff --git a/Core/Resgrid.Services/CommunicationService.cs b/Core/Resgrid.Services/CommunicationService.cs index 6868f6024..cb8434874 100644 --- a/Core/Resgrid.Services/CommunicationService.cs +++ b/Core/Resgrid.Services/CommunicationService.cs @@ -879,6 +879,10 @@ public async Task SendTroubleAlertAsync(TroubleAlertEvent troubleAlertEven var emailSanitized = call != null ? !ReferenceEquals(emailCall, call) : await _protectedProjectionService.IsChannelSanitizedAsync(departmentId, ProtectedDataEgressChannel.Email); + // A trouble alert can carry personnel and locations even without a call, so the chat + // channel decision is made unconditionally; like the three above it is resolved once for + // the whole fan-out rather than per recipient. + var chatSanitized = await _protectedProjectionService.IsChannelSanitizedAsync(departmentId, ProtectedDataEgressChannel.ChatPlatform); var emailEvent = troubleAlertEvent; if (emailSanitized) @@ -902,9 +906,6 @@ public async Task SendTroubleAlertAsync(TroubleAlertEvent troubleAlertEven { try { - // A trouble alert can carry personnel and locations even without a call. - var chatSanitized = await _protectedProjectionService.IsChannelSanitizedAsync( - departmentId, ProtectedDataEgressChannel.ChatPlatform); await _chatbotOutboundService.SendToUserAsync(recipient.UserId, departmentId, new ChatbotOutboundMessage { diff --git a/Core/Resgrid.Services/CostRecovery/CalOesMarsService.WorkItems.cs b/Core/Resgrid.Services/CostRecovery/CalOesMarsService.WorkItems.cs index 983828e82..7693e4b22 100644 --- a/Core/Resgrid.Services/CostRecovery/CalOesMarsService.WorkItems.cs +++ b/Core/Resgrid.Services/CostRecovery/CalOesMarsService.WorkItems.cs @@ -1,4 +1,5 @@ using System; +using Resgrid.Model.Helpers; using System.Collections.Generic; using System.IO; using System.IO.Compression; @@ -513,6 +514,8 @@ public async Task OpenPortalHandoffAsync(string workI private async Task BuildManifestAsync(CalOesMarsWorkItem item, int departmentId, string userId) { var authority = CalOesMarsAuthorityProfile.Get(item.AuthorityProfileCode) ?? CalOesMarsAuthorityProfile.Current; + var department = await _departmentsService.GetDepartmentByIdAsync(departmentId, false) ?? new Department(); + string When(DateTime? value) => value?.TimeConverterToString(department); var manifest = new CalOesMarsHandoffManifest { WorkItemId = item.CalOesMarsWorkItemId, RecordType = item.RecordType, AuthorityProfileCode = item.AuthorityProfileCode, RateProfileVersion = item.RateProfileVersion, AgreementSnapshotId = item.AgreementSnapshotId, @@ -526,13 +529,13 @@ private async Task BuildManifestAsync(CalOesMarsWorkI var values = new Dictionary { ["agency"] = Join(s.MacsDesignator, s.AgencyName), ["incident"] = Join(s.IncidentName, s.IncidentNumber), ["order"] = s.OrderNumber, ["request"] = Join(s.RequestNumber, s.ParentRequestNumber), - ["resource"] = Join(s.ResourceType ?? s.ResourceKind, s.StrikeTeamOrTaskForce, s.OverheadPosition), ["dispatch"] = s.DispatchedOn?.ToString("yyyy-MM-dd HH:mm"), - ["return"] = s.ReturnedOn?.ToString("yyyy-MM-dd HH:mm") ?? (s.ReleasedOn.HasValue ? "Released " + s.ReleasedOn.Value.ToString("yyyy-MM-dd HH:mm") : null), + ["resource"] = Join(s.ResourceType ?? s.ResourceKind, s.StrikeTeamOrTaskForce, s.OverheadPosition), ["dispatch"] = When(s.DispatchedOn), + ["return"] = When(s.ReturnedOn) ?? (s.ReleasedOn.HasValue ? "Released " + When(s.ReleasedOn) : null), ["apparatus"] = string.Join("; ", s.Vehicles.Select(v => $"{v.Kind} {v.Designator} {v.ResourceCode} {v.LicensePlate} {v.CommittedHours}h".Trim())), ["personnel"] = string.Join("; ", s.Personnel.Select(p => $"{p.Name} ({p.Rank ?? p.ClassificationCode}) {(p.ActualHours.Count > 0 ? p.ActualHours.Sum(h => h.Hours) + "h actual" : p.CommittedHours + "h committed")}")), ["rotation"] = s.Rotations.Count == 0 ? null : string.Join("; ", s.Rotations.Select(r => r.On.ToString("yyyy-MM-dd"))), - ["comments"] = Join(s.Comments, s.LossDamage, s.SupplyNumbers), ["responding-signature"] = Join(s.RespondingSignerName, s.RespondingSignedOn?.ToString("yyyy-MM-dd")), - ["incident-signature"] = Join(s.IncidentAuthorizerName, s.IncidentAuthorizedOn?.ToString("yyyy-MM-dd")), ["attachments"] = s.AttachmentIds.Count.ToString() + ["comments"] = Join(s.Comments, s.LossDamage, s.SupplyNumbers), ["responding-signature"] = Join(s.RespondingSignerName, When(s.RespondingSignedOn)), + ["incident-signature"] = Join(s.IncidentAuthorizerName, When(s.IncidentAuthorizedOn)), ["attachments"] = s.AttachmentIds.Count.ToString() }; foreach (var box in authority.F42Boxes) manifest.Fields.Add(new CalOesMarsHandoffField { Box = box.Id, Label = box.Label, Value = values.TryGetValue(box.Id, out var v) ? v : null, Source = box.Source }); if (!string.IsNullOrWhiteSpace(item.DeploymentId)) diff --git a/Core/Resgrid.Services/CostRecovery/CalOesMarsService.cs b/Core/Resgrid.Services/CostRecovery/CalOesMarsService.cs index d3cc7e673..1dc7c717a 100644 --- a/Core/Resgrid.Services/CostRecovery/CalOesMarsService.cs +++ b/Core/Resgrid.Services/CostRecovery/CalOesMarsService.cs @@ -681,6 +681,14 @@ public async Task SaveAgreementAsync(CalOesMarsAgre target.OvertimeMethod = agreement.OvertimeMethod; target.StartOn = agreement.StartOn; target.EndOn = agreement.EndOn; + // A referenced revision owns the dates from its start and the prior snapshot closes the day before it (below). With no + // submitted start the revision is effective today; left open-ended it would also cover the dates the prior snapshot + // keeps and, sharing its null start, outrank it on RowVersion. Ordinary open-ended agreements keep their null start. + if (referenced && !target.StartOn.HasValue) + { + target.StartOn = now.Date; + if (target.EndOn.HasValue && target.EndOn.Value.Date < target.StartOn.Value) throw new InvalidOperationException("calmars_dates_invalid"); + } target.AttachmentId = agreement.AttachmentId; target.AttachmentChecksum = Trim(agreement.AttachmentChecksum); target.SourceArtifact = Trim(agreement.SourceArtifact); diff --git a/Core/Resgrid.Services/InventoryAlertNotifications.cs b/Core/Resgrid.Services/InventoryAlertNotifications.cs index bdd9ffb31..8a053d6a9 100644 --- a/Core/Resgrid.Services/InventoryAlertNotifications.cs +++ b/Core/Resgrid.Services/InventoryAlertNotifications.cs @@ -4,12 +4,17 @@ using System.Resources; using System.Threading; using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Framework; using Resgrid.Localization; +using Resgrid.Model; +using Resgrid.Model.Inventories; +using Resgrid.Model.Repositories; using Resgrid.Model.Services; namespace Resgrid.Services { - /// Hands off generic notices using routing metadata, without reading Inventory content or obtaining a grant. + /// Identifies inventory needing attention, using only public identifiers when content is protected. public sealed class InventoryAlertNotifications { private readonly IInventoryAlertService _alerts; @@ -17,12 +22,15 @@ public sealed class InventoryAlertNotifications private readonly IUserProfileService _profiles; private readonly IDepartmentSettingsService _settings; private readonly ICommunicationService _communication; + private readonly IInventoryStore _store; + private readonly IDepartmentDataProtectionService _protection; private readonly TimeProvider _clock; private static readonly ResourceManager Strings = new ResourceManager("Resgrid.Localization.Areas.User.Inventory.Inventory", typeof(SupportedLocales).Assembly); public InventoryAlertNotifications(IInventoryAlertService alerts, IDepartmentsService departments, IUserProfileService profiles, - IDepartmentSettingsService settings, ICommunicationService communication, TimeProvider clock = null) - { _alerts = alerts; _departments = departments; _profiles = profiles; _settings = settings; _communication = communication; _clock = clock ?? TimeProvider.System; } + IDepartmentSettingsService settings, ICommunicationService communication, IInventoryStore store, + IDepartmentDataProtectionService protection, TimeProvider clock = null) + { _alerts = alerts; _departments = departments; _profiles = profiles; _settings = settings; _communication = communication; _store = store; _protection = protection; _clock = clock ?? TimeProvider.System; } public async Task ProcessDepartmentAsync(int departmentId, CancellationToken ct) { @@ -51,15 +59,19 @@ public async Task ProcessDepartmentAsync(int departmentId, CancellationToke var number = await _settings.GetTextToCallNumberForDepartmentAsync(departmentId); var culture = Culture(profile?.Language); var title = Strings.GetString("M5AlertNotificationTitle", culture) ?? "Inventory alerts"; - var message = (Strings.GetString("M5AlertNotificationMessage", culture) ?? "Inventory needs attention. Sign in to review current alerts.") - + " " + (Config.SystemBehaviorConfig.ResgridBaseUrl ?? "").TrimEnd('/') + "/User/Inventory/Operations?tab=Alerts"; + var alert = await _store.GetAsync(departmentId, delivery.AlertId); + var item = alert?.DepartmentId == departmentId ? await _store.GetAsync(departmentId, alert.ItemId) : null; + var location = alert?.DepartmentId == departmentId && alert.LocationId != null ? await _store.GetAsync(departmentId, alert.LocationId) : null; ct.ThrowIfCancellationRequested(); - // Slow routing/profile lookups precede the final current-state and permission gate. - var allowed = department?.DepartmentId == departmentId && await _alerts.CanReceiveAlertAsync(departmentId, user, delivery.AlertId); + // Lookups precede the final current-state, permission and protection gates. + var allowed = department?.DepartmentId == departmentId && item?.DepartmentId == departmentId + && await _alerts.CanReceiveAlertAsync(departmentId, user, delivery.AlertId); + var protectedContent = !allowed || await _protection.IsProtectionEnforcedAsync(departmentId); ct.ThrowIfCancellationRequested(); if (allowed && (!delivery.LeaseUntil.HasValue || delivery.LeaseUntil.Value <= _clock.GetUtcNow().UtcDateTime.AddSeconds(30))) throw new InvalidOperationException("Inventory alert notification lease is expiring."); - var sent = allowed && await _communication.SendNotificationAsync(user, departmentId, message, number, department, title, profile); + var sent = allowed && await _communication.SendNotificationAsync(user, departmentId, + Message(alert, item, location?.DepartmentId == departmentId ? location : null, culture, protectedContent), number, department, title, profile); // Persist an accepted handoff even if cancellation arrived during the provider call. await _alerts.FinishAlertAsync(departmentId, delivery.Id, delivery.ClaimToken, sent); if (sent) handedOff++; @@ -80,6 +92,34 @@ public async Task ProcessDepartmentAsync(int departmentId, CancellationToke return handedOff; } + private static string Message(InventoryAlert alert, InventoryItem item, InventoryLocation location, CultureInfo culture, bool protectedContent) + { + var content = NotificationContent(item, protectedContent); + var name = WithoutUrls(content?.Name); + var code = WithoutUrls(content?.Code); + var identifier = string.IsNullOrWhiteSpace(name) ? code : string.IsNullOrWhiteSpace(code) ? name : $"{name} ({code})"; + if (string.IsNullOrWhiteSpace(identifier)) identifier = item.Id; + var message = (Strings.GetString("M5AlertType" + alert.AlertType, culture) ?? Strings.GetString("M5AlertNotificationTitle", culture)) + + ": " + identifier; + if (alert.LocationId != null) + { + var locationName = WithoutUrls(NotificationContent(location, protectedContent)?.Name); + message += "; " + Strings.GetString("Location", culture) + ": " + (string.IsNullOrWhiteSpace(locationName) ? alert.LocationId : locationName); + } + return message + ". " + (Strings.GetString("M5AlertNotificationMessage", culture) ?? "Inventory needs attention. Sign in to review current alerts."); + } + + private static T NotificationContent(InventoryRow row, bool protectedContent) where T : class + { + // Never obtain a grant or decrypt content in this unattended sender, including during protection transitions. + if (protectedContent || row == null || row.IsProtected || string.IsNullOrWhiteSpace(row.Content) + || ProtectedDataEnvelope.HasEnvelopePrefix(row.Content) || row.Content == ProtectedDataEnvelope.RedactionValue) return null; + try { return JsonConvert.DeserializeObject(row.Content); } + catch (JsonException) { return null; } + } + + private static string WithoutUrls(string text) => SmsContentHelper.StripDisallowedUrls(text, Array.Empty())?.Trim(); + private static CultureInfo Culture(string language) { try diff --git a/Core/Resgrid.Services/InventoryPosting.cs b/Core/Resgrid.Services/InventoryPosting.cs index 0c1a5c3cb..ddb1356a3 100644 --- a/Core/Resgrid.Services/InventoryPosting.cs +++ b/Core/Resgrid.Services/InventoryPosting.cs @@ -217,7 +217,7 @@ public Task WitnessAsync(InventoryActor actor, string requestId await RequireCommandAccessAsync(actor, receipt.PendingCommand, joined: true); await ValidateCommandAsync(actor, receipt.PendingCommand, joined: true); result = await PostLinesAsync(actor, operation, receipt.PendingCommand, events, performer.UserId, actor.UserId, attestation); - await _workOrderMaintenance.Value.CompleteInventoryPartAsync(actor, receipt.PendingCommand.Lines[0].WorkOrderPartId.Value, result.TransactionIds.Single(), receipt.PendingCommand.Lines[0].ReversesTransactionId != null, events); + await _workOrderMaintenance.Value.CompleteInventoryPartAsync(actor, receipt.PendingCommand.Lines[0].WorkOrderPartId, result.TransactionIds.Single(), receipt.PendingCommand.Lines[0].ReversesTransactionId != null, events); break; case "Post": case "Transfer": @@ -265,7 +265,7 @@ private async Task PostLinesAsync(InventoryActor actor, Invento { var original = await GetAsync(actor, line.ReversesTransactionId); if (original.PurchaseOrderItemId != null) throw new InventoryException(409, "PurchaseReceiptImmutable"); - if (original.WorkOrderPartId.HasValue && original.WorkOrderPartId != line.WorkOrderPartId) throw new InventoryException(409, "WorkOrderPostingRequired"); + if (original.WorkOrderPartId != null && original.WorkOrderPartId != line.WorkOrderPartId) throw new InventoryException(409, "WorkOrderPostingRequired"); if (original.CountItemId != null) throw new InventoryException(409, "CountReceiptImmutable"); if (original.ReferenceType == (int)InventoryReferenceType.RmsRecord && (line.ReferenceType != InventoryReferenceType.RmsRecord || line.ReferenceId != original.ReferenceId)) throw new InventoryException(409, "RecordUsageCorrectionRequired"); if (original.ReversesTransactionId != null || original.ItemId != item.Id || original.AssetId != line.AssetId || original.LotId != line.LotId || original.Quantity != line.Quantity diff --git a/Core/Resgrid.Services/InventoryReferences.cs b/Core/Resgrid.Services/InventoryReferences.cs index 2eb23cf1e..2319afaa0 100644 --- a/Core/Resgrid.Services/InventoryReferences.cs +++ b/Core/Resgrid.Services/InventoryReferences.cs @@ -29,9 +29,9 @@ private async Task ValidateReferenceAsync(InventoryActor actor, InventoryPosting if (line.ReferenceType == InventoryReferenceType.RmsRecord && joined && Guid.TryParseExact(line.ReferenceId, "D", out _)) return; if (line.ReferenceType == InventoryReferenceType.WorkOrder) { - if (_workOrders == null || _workOrderAuthorization == null || !int.TryParse(line.ReferenceId, NumberStyles.None, CultureInfo.InvariantCulture, out var id) || id <= 0) + if (_workOrders == null || _workOrderAuthorization == null || !Guid.TryParseExact(line.ReferenceId, "D", out _)) throw new InventoryException(404, "ReferenceUnavailable"); - var order = await _workOrders.GetAsync(actor.DepartmentId, id, false); + var order = await _workOrders.GetAsync(actor.DepartmentId, line.ReferenceId, false); var principal = new ChecklistActor { DepartmentId = actor.DepartmentId, UserId = actor.UserId, GrantToken = actor.GrantToken }; if (order == null || order.IsDeleted || !await _workOrderAuthorization.Value.CanContributeAsync(principal, order)) throw new InventoryException(404, "ReferenceUnavailable"); if (requireOpen && order.Status >= (int)WorkOrderStatus.Completed && line.Type != InventoryTransactionType.Return) throw new InventoryException(409, "ReferenceClosed"); diff --git a/Core/Resgrid.Services/InventoryWorkOrderAllocations.cs b/Core/Resgrid.Services/InventoryWorkOrderAllocations.cs index 9de7cda05..0806ee80d 100644 --- a/Core/Resgrid.Services/InventoryWorkOrderAllocations.cs +++ b/Core/Resgrid.Services/InventoryWorkOrderAllocations.cs @@ -11,12 +11,12 @@ namespace Resgrid.Services { public sealed partial class InventoryModernizationService { - public async Task QuotePartAsync(InventoryActor actor, int orderId, WorkOrderPartInput input) + public async Task QuotePartAsync(InventoryActor actor, string orderId, WorkOrderPartInput input) { if (_uow.Transaction == null || _maintenanceOrders == null || _workOrders == null) throw new InvalidOperationException("Reservations require their source transaction."); await _store.LockDepartmentAsync(actor.DepartmentId); if (!await _store.HasLegacyMigrationAsync(actor.DepartmentId) || !await _auth.IsEnabledAsync(actor.DepartmentId)) throw new InventoryException(409, "InventoryDisabled"); - var command = new InventoryCommand { RequestId = input.RequestId, Lines = new() { new InventoryPosting { ItemId = input.InventoryItemId, AssetId = input.InventoryAssetId, LotId = input.InventoryLotId, FromLocationId = input.InventoryLocationId, Quantity = input.Content.Quantity, Type = InventoryTransactionType.Consume, ReferenceType = InventoryReferenceType.WorkOrder, ReferenceId = orderId.ToString(CultureInfo.InvariantCulture) } } }; + var command = new InventoryCommand { RequestId = input.RequestId, Lines = new() { new InventoryPosting { ItemId = input.InventoryItemId, AssetId = input.InventoryAssetId, LotId = input.InventoryLotId, FromLocationId = input.InventoryLocationId, Quantity = input.Content.Quantity, Type = InventoryTransactionType.Consume, ReferenceType = InventoryReferenceType.WorkOrder, ReferenceId = orderId } } }; await RequireCommandAccessAsync(actor, command, joined: true); await ValidateCommandAsync(actor, command, joined: true); var line = command.Lines[0]; var item = await GetAsync(actor, line.ItemId); var cost = await CostForPostingAsync(actor, item, line); @@ -39,7 +39,7 @@ private async Task AllocatedQuantityAsync(int departmentId, InventoryPo if (_maintenanceOrders == null || line.FromLocationId == null) return 0; var allocations = await _maintenanceOrders.AllocatedPartsAsync(departmentId, line.ItemId, line.FromLocationId, line.LotId, line.AssetId); var total = allocations.Sum(p => (p.ReservedLocationId == line.FromLocationId ? p.ReservedQuantity : 0) + (p.IssuedLocationId == line.FromLocationId ? p.IssuedQuantity : 0)); - if (line.WorkOrderPartMovementId.HasValue) + if (line.WorkOrderPartMovementId != null) { await ValidatePartMovementAsync(departmentId, line); total -= line.Quantity; // Only the validated source movement may use its own allocation. @@ -48,9 +48,9 @@ private async Task AllocatedQuantityAsync(int departmentId, InventoryPo } private async Task ValidatePartMovementAsync(int departmentId, InventoryPosting line) { - if (!line.WorkOrderPartId.HasValue || !line.WorkOrderPartMovementId.HasValue || line.ReversesTransactionId != null) throw new InventoryException(409, "WorkOrderPostingRequired"); - var part = await _workOrders.GetAsync(departmentId, line.WorkOrderPartId.Value); - var movement = await _workOrders.GetAsync(departmentId, line.WorkOrderPartMovementId.Value); + if (line.WorkOrderPartId == null || line.WorkOrderPartMovementId == null || line.ReversesTransactionId != null) throw new InventoryException(409, "WorkOrderPostingRequired"); + var part = await _workOrders.GetAsync(departmentId, line.WorkOrderPartId); + var movement = await _workOrders.GetAsync(departmentId, line.WorkOrderPartMovementId); if (part == null || !part.Staged || part.VoidedOn.HasValue || movement?.PartId != part.Id || movement.WorkOrderId != part.WorkOrderId || movement.Cancelled || movement.InventoryTransactionId != null || movement.Quantity != line.Quantity || movement.FromLocationId != line.FromLocationId || movement.ToLocationId != line.ToLocationId || line.AssetId != part.ReservedAssetId || line.LotId != part.ReservedLotId) throw new InventoryException(409, "ReferenceUnavailable"); var kind = (WorkOrderPartMovementKind)movement.Kind; diff --git a/Core/Resgrid.Services/InventoryWorkOrders.cs b/Core/Resgrid.Services/InventoryWorkOrders.cs index fbcdfca91..28cfee63f 100644 --- a/Core/Resgrid.Services/InventoryWorkOrders.cs +++ b/Core/Resgrid.Services/InventoryWorkOrders.cs @@ -10,7 +10,7 @@ namespace Resgrid.Services { public sealed partial class InventoryModernizationService { - public async Task CancelPendingPartAsync(InventoryActor actor, int partId, string operationId) + public async Task CancelPendingPartAsync(InventoryActor actor, string partId, string operationId) { if (_uow.Transaction == null) throw new InvalidOperationException("The source transaction owns cancellation."); await _store.LockDepartmentAsync(actor.DepartmentId); @@ -22,7 +22,7 @@ public async Task CancelPendingPartAsync(InventoryActor actor, int partId, strin await RequireCommandAccessAsync(actor, receipt.PendingCommand, joined: true); operation.State = 3; await SaveAsync(actor, operation, false); } - public async Task PostPartAsync(InventoryActor actor, int partId, InventoryCommand command) + public async Task PostPartAsync(InventoryActor actor, string partId, InventoryCommand command) { if (_uow.Transaction == null || _workOrders == null || _maintenanceOrders == null || _readinessAccess == null) throw new InvalidOperationException("Work-order inventory requires its source transaction."); await _store.LockDepartmentAsync(actor.DepartmentId); @@ -42,17 +42,17 @@ public async Task PostPartAsync(InventoryActor actor, int partI } private async Task RequireWorkOrderPartAsync(InventoryActor actor, InventoryPosting line) { - if (!line.WorkOrderPartId.HasValue || _workOrders == null || _workOrderAuthorization == null || _readinessAccess == null) throw new InventoryException(400, "ReferenceUnavailable"); + if (line.WorkOrderPartId == null || _workOrders == null || _workOrderAuthorization == null || _readinessAccess == null) throw new InventoryException(400, "ReferenceUnavailable"); if (!await _readinessAccess.CanUseMaintenanceAsync(actor.DepartmentId)) throw new InventoryException(402, "ReadinessProRequired"); - var part = await _workOrders.GetAsync(actor.DepartmentId, line.WorkOrderPartId.Value); - if (part?.WorkOrderId?.ToString(System.Globalization.CultureInfo.InvariantCulture) != line.ReferenceId || part.InventoryItemId != line.ItemId || part.VoidedOn.HasValue) throw new InventoryException(409, "ReferenceUnavailable"); + var part = await _workOrders.GetAsync(actor.DepartmentId, line.WorkOrderPartId); + if (part?.WorkOrderId != line.ReferenceId || part.InventoryItemId != line.ItemId || part.VoidedOn.HasValue) throw new InventoryException(409, "ReferenceUnavailable"); if (part.Staged) await ValidatePartMovementAsync(actor.DepartmentId, line); - else if (line.WorkOrderPartMovementId.HasValue || line.ReversesTransactionId == null && part.InventoryTransactionId != null || line.ReversesTransactionId != null && part.InventoryTransactionId != line.ReversesTransactionId) throw new InventoryException(409, "ReferenceUnavailable"); - var order = await _workOrders.GetAsync(actor.DepartmentId, part.WorkOrderId.Value); + else if (line.WorkOrderPartMovementId != null || line.ReversesTransactionId == null && part.InventoryTransactionId != null || line.ReversesTransactionId != null && part.InventoryTransactionId != line.ReversesTransactionId) throw new InventoryException(409, "ReferenceUnavailable"); + var order = await _workOrders.GetAsync(actor.DepartmentId, part.WorkOrderId); var principal = new ChecklistActor { DepartmentId = actor.DepartmentId, UserId = actor.UserId, GrantToken = actor.GrantToken }; if (order == null || order.Status >= 5 || !await _workOrderAuthorization.Value.CanContributeAsync(principal, order)) throw new InventoryException(409, "ReferenceClosed"); } - public async Task ApplyHoldAsync(InventoryActor actor, int orderId, string assetId, int? restoreState = null, int? expectedRevision = null, bool safetyRelease = false) + public async Task ApplyHoldAsync(InventoryActor actor, string orderId, string assetId, int? restoreState = null, int? expectedRevision = null, bool safetyRelease = false) { if (_uow.Transaction == null || _maintenanceOrders == null || _workOrders == null || _workOrderAuthorization == null) throw new InvalidOperationException("Safety changes require the work-order transaction."); await _store.LockDepartmentAsync(actor.DepartmentId); @@ -86,7 +86,7 @@ public async Task ApplyHoldAsync(InventoryActor actor, int var ledger = New(actor); ledger.ItemId = asset.ItemId; ledger.AssetId = asset.Id; ledger.LotId = asset.LotId; ledger.TransactionType = (int)InventoryTransactionType.StatusChange; ledger.OldStatus = old; ledger.NewStatus = asset.Status; ledger.FromLocationId = asset.CurrentLocationId; ledger.ToLocationId = asset.CurrentLocationId; ledger.OccurredOn = Now; - ledger.ReferenceType = (int)InventoryReferenceType.WorkOrder; ledger.ReferenceId = orderId.ToString(System.Globalization.CultureInfo.InvariantCulture); + ledger.ReferenceType = (int)InventoryReferenceType.WorkOrder; ledger.ReferenceId = orderId; await _store.InsertAsync(ledger); var events = new System.Collections.Generic.List(); await EventAsync(ledger, WorkflowTriggerEventType.InventoryAssetStatusChanged, events); return new WorkOrderAssetState { State = old, Revision = asset.Revision, OutboxIds = events }; diff --git a/Core/Resgrid.Services/Invoicing/DeploymentService.cs b/Core/Resgrid.Services/Invoicing/DeploymentService.cs index 340b34e4f..14498e4eb 100644 --- a/Core/Resgrid.Services/Invoicing/DeploymentService.cs +++ b/Core/Resgrid.Services/Invoicing/DeploymentService.cs @@ -203,6 +203,8 @@ public async Task SaveDeploymentAsync(Deployment deployment, string if (!Enum.IsDefined(typeof(DeploymentFinanceModes), deployment.FinanceMode)) throw new InvalidOperationException("deployments_finance_mode_invalid"); if (deployment.StartOn.HasValue && deployment.EndOn.HasValue && deployment.EndOn < deployment.StartOn) throw new InvalidOperationException("deployments_window_invalid"); if (deployment.DiscountPercent.HasValue && (deployment.DiscountPercent < 0 || deployment.DiscountPercent > 100)) throw new InvalidOperationException("deployments_discount_invalid"); + // The window, time reports and the contractor calendar all convert through this zone; an unknown id would silently shift every one of them. + if (!DateTimeHelpers.IsKnownTimeZone(deployment.LocalTimeZoneId)) throw new InvalidOperationException("deployments_timezone_invalid"); if (!string.IsNullOrWhiteSpace(deployment.ContactId)) { @@ -499,6 +501,9 @@ public async Task AddPersonnelAsync(string deploymentId, result.Personnel = await _personnel.SaveOrUpdateAsync(row, cancellationToken); result.Personnel.DisplayName = profile.FullName.AsFirstNameLastName; foreach (var warning in result.Warnings) warning.Blocking = false; + // The roster is the projection's participant set (search scopes deployments to it for members without the claim); + // removal keeps the row, so only an addition changes it. + if (_searchProjections?.Value != null) await _searchProjections.Value.ProjectDeploymentAsync(deployment, cancellationToken); Audit(departmentId, userId, AuditLogTypes.DeploymentRosterChanged, ipAddress, userAgent, null, result.Personnel); await PublishAsync(deployment, WorkflowTriggerEventType.DeploymentRosterChanged, subject: (DeploymentTimeSubjectTypes.Personnel, result.Personnel.DeploymentPersonnelId, result.Personnel.DisplayName, "Added"), subjectUserId: input.UserId, cancellationToken: cancellationToken); diff --git a/Core/Resgrid.Services/Records/RecordSavedReportsService.cs b/Core/Resgrid.Services/Records/RecordSavedReportsService.cs index 41be1fb41..bc63f5625 100644 --- a/Core/Resgrid.Services/Records/RecordSavedReportsService.cs +++ b/Core/Resgrid.Services/Records/RecordSavedReportsService.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using Resgrid.Model; +using Resgrid.Model.Helpers; using Resgrid.Model.Repositories; using Resgrid.Model.Services; @@ -40,9 +41,10 @@ public class RecordSavedReportsService : IRecordSavedReportsService private readonly IRmsRecordValueGroupsRepository _groups; private readonly IRecordsAuthorizationService _authorization; private readonly IRmsAccessAuditsRepository _audits; + private readonly IDepartmentsService _departments; public RecordSavedReportsService(IRmsSavedReportDefinitionsRepository reports, IRecordDefinitionsService definitions, IRecordsService records, IRmsOperationalRecordsRepository recordRows, - IRmsRecordValuesRepository values, IRmsRecordValueGroupsRepository groups, IRecordsAuthorizationService authorization, IRmsAccessAuditsRepository audits) + IRmsRecordValuesRepository values, IRmsRecordValueGroupsRepository groups, IRecordsAuthorizationService authorization, IRmsAccessAuditsRepository audits, IDepartmentsService departments) { _reports = reports; _definitions = definitions; @@ -52,6 +54,7 @@ public RecordSavedReportsService(IRmsSavedReportDefinitionsRepository reports, I _groups = groups; _authorization = authorization; _audits = audits; + _departments = departments; } public async Task> GetForDepartmentAsync(int departmentId) => (await _reports.GetForDepartmentAsync(departmentId))?.OrderBy(r => r.Name, StringComparer.OrdinalIgnoreCase).ToList() ?? new List(); @@ -254,14 +257,17 @@ public async Task RunAsync(int departmentId, string userId, Func<(RmsOperationalRecord Record, RecordValueSet Values, RecordDefinitionSchema Schema, Dictionary Map), object> key = s => { var c = Cell(s, spec.SortFieldKey); return c?.Number ?? (object)(c?.Value ?? BuiltIn(s.Record, spec.SortFieldKey) ?? string.Empty); }; ordered = spec.SortDescending ? matched.OrderByDescending(key, Comparer.Create(CompareValues)) : matched.OrderBy(key, Comparer.Create(CompareValues)); } - foreach (var item in ordered) - result.Rows.Add(spec.Columns.Select(c => BuiltInColumns.ContainsKey(c) ? BuiltIn(item.Record, c) : Cell(item, c)?.Display ?? string.Empty).ToList()); + var department = await _departments.GetDepartmentByIdAsync(departmentId, false) ?? new Department(); + string Display(RecordValueCell cell) => cell?.Type == RmsFieldType.DateTime && !cell.Withheld && DateTime.TryParse(cell.Value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var utc) + ? utc.TimeConverterToString(department) : cell?.Display; + foreach (var item in ordered) + result.Rows.Add(spec.Columns.Select(c => BuiltInColumns.ContainsKey(c) ? BuiltIn(item.Record, c, department) : Display(Cell(item, c)) ?? string.Empty).ToList()); if (!string.IsNullOrWhiteSpace(spec.GroupByFieldKey) || spec.Aggregates.Count > 0) { var groups = string.IsNullOrWhiteSpace(spec.GroupByFieldKey) ? new[] { new { Key = "(all)", Items = matched } }.Select(g => (g.Key, g.Items.AsEnumerable())) - : matched.GroupBy(s => BuiltInColumns.ContainsKey(spec.GroupByFieldKey) ? BuiltIn(s.Record, spec.GroupByFieldKey) ?? "(blank)" : Cell(s, spec.GroupByFieldKey)?.Display ?? "(blank)", StringComparer.OrdinalIgnoreCase).Select(g => (g.Key, g.AsEnumerable())); + : matched.GroupBy(s => BuiltInColumns.ContainsKey(spec.GroupByFieldKey) ? BuiltIn(s.Record, spec.GroupByFieldKey, department) ?? "(blank)" : Display(Cell(s, spec.GroupByFieldKey)) ?? "(blank)", StringComparer.OrdinalIgnoreCase).Select(g => (g.Key, g.AsEnumerable())); foreach (var (groupKey, items) in groups.OrderBy(g => g.Item1, StringComparer.OrdinalIgnoreCase)) { var list = items.ToList(); @@ -304,7 +310,7 @@ private static RecordValueCell Cell((RmsOperationalRecord Record, RecordValueSet return item.Values.Scalar(mapped) ?? item.Values.Sections.Where(s => s.Repeating).SelectMany(s => s.Rows).SelectMany(r => r.Cells).FirstOrDefault(c => string.Equals(c.FieldKey, mapped, StringComparison.OrdinalIgnoreCase)); } - private static string BuiltIn(RmsOperationalRecord record, string key) + private static string BuiltIn(RmsOperationalRecord record, string key, Department department = null) { switch ((key ?? string.Empty).ToLowerInvariant()) { @@ -312,9 +318,9 @@ private static string BuiltIn(RmsOperationalRecord record, string key) case "record.draft_reference": return record.DraftReference; case "record.state": return ((RmsRecordState)record.State).ToString(); case "record.definition_version": return record.DefinitionVersion.ToString(CultureInfo.InvariantCulture); - case "record.started_on": return record.StartedOn?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture); - case "record.ended_on": return record.EndedOn?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture); - case "record.finalized_on": return record.FinalizedOn?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture); + case "record.started_on": return department == null ? record.StartedOn?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) : record.StartedOn?.TimeConverterToString(department); + case "record.ended_on": return department == null ? record.EndedOn?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) : record.EndedOn?.TimeConverterToString(department); + case "record.finalized_on": return department == null ? record.FinalizedOn?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) : record.FinalizedOn?.TimeConverterToString(department); case "record.author": return record.AuthorUserId; case "record.group": return record.StationGroupId?.ToString(CultureInfo.InvariantCulture); case "record.call_id": return record.CallId?.ToString(CultureInfo.InvariantCulture); diff --git a/Core/Resgrid.Services/Records/RecordsAnalyticsService.Readiness.cs b/Core/Resgrid.Services/Records/RecordsAnalyticsService.Readiness.cs index 52604e59f..7c9f61a76 100644 --- a/Core/Resgrid.Services/Records/RecordsAnalyticsService.Readiness.cs +++ b/Core/Resgrid.Services/Records/RecordsAnalyticsService.Readiness.cs @@ -147,7 +147,9 @@ private async Task ChecklistMetricsAsync(Context c, Che m.ByStationGroup = groups.Where(g => g.Target.Type == ChecklistTargetType.Group).Select(g => Row(g, labels.Group(g.Target.Id))).OrderByDescending(r => r.Missed).ThenBy(r => r.Label, StringComparer.OrdinalIgnoreCase).ToList(); m.ByPerson = groups.Where(g => g.Target.Type == ChecklistTargetType.Personnel).Select(g => Row(g, g.Target.Name ?? g.Target.Id)).OrderByDescending(r => r.Missed).ThenBy(r => r.Label, StringComparer.OrdinalIgnoreCase).ToList(); m.ByAsset = groups.Where(g => g.Target.Type == ChecklistTargetType.InventoryAsset).Select(g => Row(g, g.Target.Name ?? g.Target.Id)).OrderByDescending(r => r.Missed).ThenBy(r => r.Label, StringComparer.OrdinalIgnoreCase).ToList(); - m.Trend = (summary.Trend ?? new List()).OrderBy(t => t.DayUtc).Select(t => new RecordsChecklistTrendPoint { Day = t.DayUtc, Expected = t.Expected, Missed = t.Missed }).ToList(); + m.Trend = (summary.Entries ?? new List()).Where(e => e.Expected && e.DueUtc.HasValue) + .GroupBy(e => Local(e.DueUtc.Value, c).Date).OrderBy(g => g.Key) + .Select(g => new RecordsChecklistTrendPoint { Day = g.Key, Expected = g.Count(), Missed = g.Count(e => e.Missed) }).ToList(); foreach (var g in groups.Where(g => g.Target.Type == ChecklistTargetType.Unit)) if (int.TryParse(g.Target.Id, out var unitId) && rows.TryGetValue(unitId, out var row)) @@ -177,7 +179,7 @@ private async Task WorkOrderMetricsAsync(Context c, Che // Per-unit roll-up from the module's paged history: the same window, the same scope, bounded by the page cap. var byUnit = new Dictionary(); - var query = new WorkOrderReportQuery { FromUtc = c.Start, UntilUtc = c.End, GroupId = c.StationGroupId, AfterId = 0 }; + var query = new WorkOrderReportQuery { FromUtc = c.Start, UntilUtc = c.End, GroupId = c.StationGroupId, AfterId = null }; for (var page = 0; ; page++) { if (page >= WorkOrderHistoryPageCap) { m.HistoryTruncated = true; c.Warnings.Add($"The per-unit work-order roll-up covers the first {WorkOrderHistoryPageCap * 50:N0} orders in the window; the module's totals are complete."); break; } @@ -195,7 +197,7 @@ private async Task WorkOrderMetricsAsync(Context c, Che if (order.Status == WorkOrderStatus.OnHold) { u.OnHold++; unitRow.OnHoldWorkOrders++; m.OnHold++; } } if (history?.NextAfterId == null) break; - query.AfterId = history.NextAfterId.Value; + query.AfterId = history.NextAfterId; } m.ByUnit = byUnit.Values.OrderByDescending(u => u.Overdue).ThenByDescending(u => u.Open).ThenBy(u => u.Label, StringComparer.OrdinalIgnoreCase).ToList(); return m; diff --git a/Core/Resgrid.Services/Records/RecordsAnalyticsService.cs b/Core/Resgrid.Services/Records/RecordsAnalyticsService.cs index 6db4ce118..b3e43a26d 100644 --- a/Core/Resgrid.Services/Records/RecordsAnalyticsService.cs +++ b/Core/Resgrid.Services/Records/RecordsAnalyticsService.cs @@ -166,7 +166,8 @@ private async Task BeginAsync(int departmentId, string userId, RecordsA context.Start = DateTime.SpecifyKind(start, DateTimeKind.Utc); context.End = DateTime.SpecifyKind(end, DateTimeKind.Utc); context.Visible = (await _authorization.GetVisibleGroupIdsAsync(userId, departmentId))?.ToList(); - try { context.TimeZone = (await _departmentsService.GetDepartmentByIdAsync(departmentId, false))?.TimeZone; } + try { context.TimeZone = (await _departmentsService.GetDepartmentByIdAsync(departmentId, false))?.TimeZone; + if (string.IsNullOrWhiteSpace(context.TimeZone)) context.TimeZone = "Pacific Standard Time"; } catch (Exception ex) { Logging.LogException(ex, $"Records analytics: department time zone unavailable for {departmentId}; bucketing in UTC."); } return context; } @@ -295,7 +296,7 @@ public static RecordsResponseTimeStat Stat(IEnumerable samples, int? ta private DateTime Local(DateTime utc, Context c) { if (string.IsNullOrWhiteSpace(c.TimeZone)) return utc; - try { return DateTimeHelpers.GetLocalDateTime(utc, c.TimeZone); } + try { return Resgrid.Model.Helpers.TimeConverterHelper.TimeConverter(utc, new Department { TimeZone = c.TimeZone }); } catch { return utc; } } diff --git a/Core/Resgrid.Services/Search/SearchProjectionService.cs b/Core/Resgrid.Services/Search/SearchProjectionService.cs index 3488c0aa0..8b5ee8f3d 100644 --- a/Core/Resgrid.Services/Search/SearchProjectionService.cs +++ b/Core/Resgrid.Services/Search/SearchProjectionService.cs @@ -34,11 +34,15 @@ public class SearchProjectionService : ISearchProjectionService private readonly ISearchProjectionsRepository _projections; private readonly IDepartmentDataProtectionService _dataProtection; + private readonly IDeploymentPersonnelRepository _deploymentPersonnel; - public SearchProjectionService(ISearchProjectionsRepository projections, IDepartmentDataProtectionService dataProtection) + /// Roster rows for the deployment projection's participants; optional only for hosts without the Business Operations repositories. + public SearchProjectionService(ISearchProjectionsRepository projections, IDepartmentDataProtectionService dataProtection, + IDeploymentPersonnelRepository deploymentPersonnel = null) { _projections = projections ?? throw new ArgumentNullException(nameof(projections)); _dataProtection = dataProtection ?? throw new ArgumentNullException(nameof(dataProtection)); + _deploymentPersonnel = deploymentPersonnel; } // ---- hooks ----------------------------------------------------------------------------------------------- @@ -431,9 +435,22 @@ public async Task BuildDeploymentAsync(Deployment deployment) p.OccurredOn = deployment.StartOn ?? (deployment.AddedOn == default ? DateTime.UtcNow : deployment.AddedOn); p.Url = $"/User/Deployments/View?id={Uri.EscapeDataString(deployment.DeploymentId)}"; p.MetadataJson = Json(new Dictionary { ["Status"] = status, ["FinanceMode"] = deployment.FinanceMode.ToString(), ["CallId"] = deployment.CallId?.ToString() }); + // The roster is the deployment's participant set: a member without Deployments/View reaches a deployment they are + // rostered on (any personnel row, removed or not — the deployment page's rule), and the index applies that scope for + // such a caller so unrostered deployments never enter their candidate window. Read here rather than from + // deployment.Personnel, which is only populated on the aggregate read paths. + p.ParticipantUserIds = await RosterAsync(deployment.DeploymentId); return p; } + private async Task RosterAsync(string deploymentId) + { + if (_deploymentPersonnel == null) return null; + var users = (await _deploymentPersonnel.GetByDeploymentAsync(deploymentId) ?? Enumerable.Empty()) + .Select(r => r?.UserId).Where(id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToList(); + return users.Count == 0 ? null : string.Join(",", users); + } + public async Task BuildCertificationTypeAsync(DepartmentCertificationType type) { if (type == null || type.DepartmentId <= 0 || type.DepartmentCertificationTypeId <= 0 || type.IsDeleted) diff --git a/Core/Resgrid.Services/Search/UnifiedSearchService.cs b/Core/Resgrid.Services/Search/UnifiedSearchService.cs index cce724190..c7766a13d 100644 --- a/Core/Resgrid.Services/Search/UnifiedSearchService.cs +++ b/Core/Resgrid.Services/Search/UnifiedSearchService.cs @@ -159,6 +159,7 @@ public async Task SearchAsync(UnifiedSearchRequest request, Text = text, EntityTypes = types, ViewerUserId = principal.UserId, + ViewerScopedEntityTypes = ViewerScopedTypes(principal), IncludeAdminOnly = principal.IsDepartmentAdmin, Prefix = request.Prefix, Skip = 0, @@ -280,6 +281,17 @@ void Add(string type, string resource, string module = null) return allowed; } + /// + /// Families the index must scope to the caller's own rows (owner or participant) on top of Message: deployments for a + /// member without Deployments/View, whose rule is roster membership. Asking the index for the caller's deployments only + /// keeps the unrostered ones — most of the department's, for a field member — out of the candidate window, so a + /// rostered deployment past the window is still found and an authorized total stays provable. + /// + private static List ViewerScopedTypes(SearchPrincipal principal) => + principal.IsDepartmentAdmin || principal.HasResourceClaim("Deployments", "View") + ? null + : new List { SearchEntityTypes.Deployment }; + private static UnifiedSearchHit Map(SearchProjection hit, float score) { IDictionary metadata = new Dictionary(); diff --git a/Core/Resgrid.Services/WorkOrderAuthorizationService.cs b/Core/Resgrid.Services/WorkOrderAuthorizationService.cs index 2bf765855..b37386d67 100644 --- a/Core/Resgrid.Services/WorkOrderAuthorizationService.cs +++ b/Core/Resgrid.Services/WorkOrderAuthorizationService.cs @@ -19,9 +19,10 @@ public sealed class WorkOrderAuthorizationService : IWorkOrderAuthorizationServi private readonly IAuthorizationService _resources; private readonly IChecklistAssignmentService _assignments; private readonly IChecklistAssetSource _assets; + private readonly IUserProfileService _profiles; public WorkOrderAuthorizationService(IDepartmentsService departments, IDepartmentGroupsService groups, IPersonnelRolesService roles, IPermissionsService permissions, - IUnitsService units, IAuthorizationService resources, IChecklistAssignmentService assignments, IChecklistAssetSource assets = null) - { _departments = departments; _groups = groups; _roles = roles; _permissions = permissions; _units = units; _resources = resources; _assignments = assignments; _assets = assets; } + IUnitsService units, IAuthorizationService resources, IChecklistAssignmentService assignments, IUserProfileService profiles, IChecklistAssetSource assets = null) + { _departments = departments; _groups = groups; _roles = roles; _permissions = permissions; _units = units; _resources = resources; _assignments = assignments; _profiles = profiles; _assets = assets; } public async Task RequireMemberAsync(ChecklistActor actor) => await MemberAsync(actor); private async Task MemberAsync(ChecklistActor actor) { @@ -68,8 +69,10 @@ public async Task CanContributeAsync(ChecklistActor actor, WorkOrder row) var context = await ContextAsync(actor); if (row?.DepartmentId != actor.DepartmentId) return false; if (await AllowedAsync(context, PermissionTypes.ManageWorkOrders, row.TargetGroupId)) return true; - if (row.AssignedToUserId != null) return row.AssignedToUserId == actor.UserId; - return row.AssignedToRoleId.HasValue && (await _assignments.MembersAsync(actor.DepartmentId, 2, row.AssignedToRoleId.Value.ToString())).Contains(actor.UserId); + if (row.AssignedToUserIds.Contains(actor.UserId)) return true; + foreach (var role in row.AssignedToRoleIds) + if ((await _assignments.MembersAsync(actor.DepartmentId, 2, role.ToString())).Contains(actor.UserId)) return true; + return false; } public async Task ValidateTargetAsync(ChecklistActor actor, WorkOrderInput input) { @@ -118,6 +121,14 @@ public async Task ChoicesAsync(ChecklistActor actor) if (c.Type == 3 && (context.Group?.DepartmentGroupId.ToString() == c.Id || await AllowedAsync(context, PermissionTypes.ManageWorkOrders, int.Parse(c.Id)))) result.Groups.Add(choice); if (c.Type == 4 && await _resources.CanUserViewUnitAsync(actor.UserId, int.Parse(c.Id))) result.Units.Add(choice); } + var profiles = await _profiles.GetSelectedUserProfilesAsync(result.Users.Select(u => u.Id).ToList()); + foreach (var user in result.Users) + { + var profile = profiles?.FirstOrDefault(p => p.UserId == user.Id); + var name = string.Join(" ", new[] { profile?.FirstName, profile?.LastName }.Where(n => !string.IsNullOrWhiteSpace(n)).Select(n => n.Trim())); + if (!string.IsNullOrWhiteSpace(name)) user.Name = name; + } + result.Users = result.Users.OrderBy(u => u.Name, StringComparer.CurrentCultureIgnoreCase).ToList(); try { if (_assets != null && await _assets.IsAvailableAsync(actor.DepartmentId)) result.Assets = (await _assets.ListAsync(actor)).Where(a => a.DepartmentId == actor.DepartmentId).Select(a => new WorkOrderChoice { Id = a.Id, Name = a.Name }).ToList(); @@ -128,7 +139,9 @@ public async Task ChoicesAsync(ChecklistActor actor) public async Task> RecipientsAsync(int departmentId, WorkOrder row) { if (row?.DepartmentId != departmentId) return new List(); - var members = row.AssignedToUserId != null ? await _assignments.MembersAsync(departmentId, 1, row.AssignedToUserId) : row.AssignedToRoleId.HasValue ? await _assignments.MembersAsync(departmentId, 2, row.AssignedToRoleId.Value.ToString()) : new HashSet(); + var members = new HashSet(StringComparer.Ordinal); + foreach (var user in row.AssignedToUserIds) members.UnionWith(await _assignments.MembersAsync(departmentId, 1, user)); + foreach (var role in row.AssignedToRoleIds) members.UnionWith(await _assignments.MembersAsync(departmentId, 2, role.ToString())); return members.OrderBy(id => id, StringComparer.Ordinal).ToList(); } } diff --git a/Core/Resgrid.Services/WorkOrderBulkOperations.cs b/Core/Resgrid.Services/WorkOrderBulkOperations.cs index 2290735ab..3a0c8d755 100644 --- a/Core/Resgrid.Services/WorkOrderBulkOperations.cs +++ b/Core/Resgrid.Services/WorkOrderBulkOperations.cs @@ -26,7 +26,7 @@ public async Task PreviewBulkAsync(ChecklistActor actor, Wo await BulkAccessAsync(actor); if (input?.Rows == null || input.Rows.Count is < 1 or > 200 || !Guid.TryParseExact(input.RequestId, "D", out _) || input.Rows.Any(r => r == null || r.RowNumber < 1) || input.Rows.Select(r => r.RowNumber).Distinct().Count() != input.Rows.Count) throw new WorkOrderException(400, "BulkInputInvalid"); - var result = new WorkOrderBulkResult(); var seen = new HashSet(); var captured = new Dictionary(); + var result = new WorkOrderBulkResult(); var seen = new HashSet(); var captured = new Dictionary(); foreach (var row in input.Rows) { var item = new WorkOrderBulkRowResult { RowNumber = row.RowNumber, WorkOrderId = row.WorkOrderId }; result.Rows.Add(item); @@ -35,14 +35,16 @@ public async Task PreviewBulkAsync(ChecklistActor actor, Wo if (row.ParseError != null) throw new WorkOrderException(400, "BulkCsvInvalid"); if (row.Import != null) { - if (row.WorkOrderId.HasValue || row.Assignment != null || row.Import.Content?.Approval != null) throw new WorkOrderException(400, "BulkInputInvalid"); + if (row.WorkOrderId != null || row.Assignment != null || row.Import.Content?.Approval != null) throw new WorkOrderException(400, "BulkInputInvalid"); row.Import.RequestId = BulkRowId(input, row); Validate(row.Import); await _authorization.ValidateTargetAsync(actor, row.Import); if (!await _authorization.CanManageAsync(actor, row.Import.TargetGroupId) && (row.Import.Content.ApprovedCost.HasValue || !string.IsNullOrEmpty(row.Import.Content.VerificationEvidence) || !string.IsNullOrEmpty(row.Import.Content.Resolution))) throw new WorkOrderException(403, "PermissionRequired"); if (row.Import.Content.ApprovedCost.HasValue && (await ReadPolicyAsync(actor)).ApprovalsEnabled) throw new WorkOrderException(409, "SpendingApprovalRequired"); var existing = await _store.RequestAsync(actor.DepartmentId, row.Import.RequestId); + row.Import.Content.Currency = await DepartmentCurrencyAsync(actor.DepartmentId); if (existing != null) { captured[existing.Id] = await ReadOrderAsync(actor, existing.Id); await RevealAsync(actor, existing); + row.Import.Content.Currency = Decode(existing.Content).Fields.Currency; if (existing.CreatedBy != actor.UserId || Decode(existing.Content).RequestHash != Fingerprint(row.Import)) throw new WorkOrderException(409, "Conflict"); item.WorkOrderId = existing.Id; item.Applied = true; } @@ -50,8 +52,8 @@ public async Task PreviewBulkAsync(ChecklistActor actor, Wo } else { - if (!row.WorkOrderId.HasValue || row.Assignment == null || !seen.Add(row.WorkOrderId.Value)) throw new WorkOrderException(400, "BulkInputInvalid"); - var order = await ReadOrderAsync(actor, row.WorkOrderId.Value); captured[order.Id] = order; + if (row.WorkOrderId == null || row.Assignment == null || !seen.Add(row.WorkOrderId)) throw new WorkOrderException(400, "BulkInputInvalid"); + var order = await ReadOrderAsync(actor, row.WorkOrderId); captured[order.Id] = order; if (!await _authorization.CanManageAsync(actor, order.TargetGroupId)) throw new WorkOrderException(403, "PermissionRequired"); var receipt = (await _maintenance.QueryMaintenanceAsync(actor.DepartmentId, "RequestId", BulkRowId(input, row))).SingleOrDefault(); if (receipt != null) { await ValidateBulkReceiptAsync(actor, input, row, receipt); item.Applied = true; } @@ -84,12 +86,12 @@ public async Task ApplyBulkAsync(ChecklistActor actor, Work var row = input.Rows.Single(r => r.RowNumber == item.RowNumber); try { - if (row.Import != null) item.WorkOrderId = (await CreateAsync(actor, row.Import)).Order.Id; + if (row.Import != null) item.WorkOrderId = (await CreateAsync(actor, row.Import, row.Import.Content.Currency)).Order.Id; else await TransactionAsync(actor, async events => { var receipt = (await _maintenance.QueryMaintenanceAsync(actor.DepartmentId, "RequestId", BulkRowId(input, row))).SingleOrDefault(); - if (receipt != null) { await ReadOrderAsync(actor, row.WorkOrderId.Value); await ValidateBulkReceiptAsync(actor, input, row, receipt); return true; } - await AssignWithinAsync(actor, row.WorkOrderId.Value, row.Assignment, events); + if (receipt != null) { await ReadOrderAsync(actor, row.WorkOrderId); await ValidateBulkReceiptAsync(actor, input, row, receipt); return true; } + await AssignWithinAsync(actor, row.WorkOrderId, row.Assignment, events); receipt = New(actor, row.WorkOrderId); receipt.Kind = 1; receipt.RequestId = BulkRowId(input, row); receipt.Content = JsonConvert.SerializeObject(new { input.RequestId, Row = row }); await SaveAsync(actor, receipt, true); return true; }); item.Applied = true; diff --git a/Core/Resgrid.Services/WorkOrderCsvImport.cs b/Core/Resgrid.Services/WorkOrderCsvImport.cs index 2018b5957..f6eef51e3 100644 --- a/Core/Resgrid.Services/WorkOrderCsvImport.cs +++ b/Core/Resgrid.Services/WorkOrderCsvImport.cs @@ -12,7 +12,8 @@ namespace Resgrid.Services public static class WorkOrderCsvImport { // Stable interchange keys; presentation labels and examples are localized by the UI. - public const string Header = "Title,Description,Type,Priority,UnitId,GroupId,AssetId,Location,Currency,EstimatedCost,DueUtc,CostCenter"; + public const string Header = "Title,Description,Type,Priority,UnitId,GroupId,AssetId,Location,EstimatedCost,DueUtc,CostCenter"; + public const string LegacyHeader = "Title,Description,Type,Priority,UnitId,GroupId,AssetId,Location,Currency,EstimatedCost,DueUtc,CostCenter"; public static WorkOrderBulkInput Parse(string text, string requestId) { if (text == null || Encoding.UTF8.GetByteCount(text) > 1024 * 1024) throw new WorkOrderException(400, "BulkCsvInvalid"); @@ -20,7 +21,8 @@ public static WorkOrderBulkInput Parse(string text, string requestId) reader.SetDelimiters(","); string[] headers; try { headers = reader.ReadFields(); } catch (MalformedLineException) { throw new WorkOrderException(400, "BulkCsvInvalid"); } if (headers?.Length > 0) headers[0] = headers[0].TrimStart('\uFEFF'); - var keys = Header.Split(','); + var legacy = headers != null && headers.SequenceEqual(LegacyHeader.Split(','), StringComparer.OrdinalIgnoreCase); + var keys = (legacy ? LegacyHeader : Header).Split(','); if (headers == null || !headers.SequenceEqual(keys, StringComparer.OrdinalIgnoreCase)) throw new WorkOrderException(400, "BulkCsvInvalid"); var result = new WorkOrderBulkInput { RequestId = requestId }; var rowNumber = 1; while (!reader.EndOfData) @@ -30,11 +32,13 @@ public static WorkOrderBulkInput Parse(string text, string requestId) try { var f = reader.ReadFields(); if (f?.Length != keys.Length) throw new FormatException(); + // Existing files remain importable; their currency column cannot override department settings. + if (legacy) f = f.Where((_, index) => index != 8).ToArray(); int? Number(string v) => string.IsNullOrWhiteSpace(v) ? null : int.Parse(v, NumberStyles.None, CultureInfo.InvariantCulture); row.Import = new WorkOrderInput { Type = string.IsNullOrWhiteSpace(f[2]) ? WorkOrderType.Corrective : (WorkOrderType)int.Parse(f[2], CultureInfo.InvariantCulture), Priority = string.IsNullOrWhiteSpace(f[3]) ? WorkOrderPriority.Normal : (WorkOrderPriority)int.Parse(f[3], CultureInfo.InvariantCulture), TargetUnitId = Number(f[4]), TargetGroupId = Number(f[5]), InventoryAssetId = string.IsNullOrWhiteSpace(f[6]) ? null : f[6], - DueOn = string.IsNullOrWhiteSpace(f[10]) ? null : DateTime.ParseExact(f[10], new[] { "yyyy-MM-ddTHH:mm:ss'Z'", "yyyy-MM-ddTHH:mm'Z'" }, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal), - Content = new WorkOrderContent { Title = f[0], Description = f[1], LocationText = f[7], Currency = f[8], EstimatedCost = string.IsNullOrWhiteSpace(f[9]) ? null : decimal.Parse(f[9], NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture), CostCenter = f[11] } }; + DueOn = string.IsNullOrWhiteSpace(f[9]) ? null : DateTime.ParseExact(f[9], new[] { "yyyy-MM-ddTHH:mm:ss'Z'", "yyyy-MM-ddTHH:mm'Z'" }, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal), + Content = new WorkOrderContent { Title = f[0], Description = f[1], LocationText = f[7], EstimatedCost = string.IsNullOrWhiteSpace(f[8]) ? null : decimal.Parse(f[8], NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture), CostCenter = f[10] } }; } catch (Exception ex) when (ex is FormatException or OverflowException or MalformedLineException) { row.Import = null; row.ParseError = "BulkCsvInvalid"; } } diff --git a/Core/Resgrid.Services/WorkOrderFiles.cs b/Core/Resgrid.Services/WorkOrderFiles.cs index cbacdbb56..fd09fe57e 100644 --- a/Core/Resgrid.Services/WorkOrderFiles.cs +++ b/Core/Resgrid.Services/WorkOrderFiles.cs @@ -11,7 +11,7 @@ namespace Resgrid.Services { public sealed partial class WorkOrdersService { - public async Task AddFileAsync(ChecklistActor actor, int id, int revision, string filename, string contentType, byte[] data) + public async Task AddFileAsync(ChecklistActor actor, string id, int revision, string filename, string contentType, byte[] data) { await RequireWriteAsync(actor); await FileWriteOrderAsync(actor, id, revision); Text(filename, 200, true); @@ -42,17 +42,17 @@ await TransactionAsync(actor, async events => await SaveAsync(actor, file); await ChangedAsync(actor, row, WorkOrderActivityType.FileAdded, events); return true; }); } - private async Task FileWriteOrderAsync(ChecklistActor actor, int id, int revision) + private async Task FileWriteOrderAsync(ChecklistActor actor, string id, int revision) { var row = await ReadOrderAsync(actor, id); Revision(row, revision); if (Terminal(row) || row.Status == 5 || row.CreatedBy != actor.UserId && !await _authorization.CanContributeAsync(actor, row)) throw new WorkOrderException(403, "PermissionRequired"); return row; } - public async Task GetFileAsync(ChecklistActor actor, int id) + public async Task GetFileAsync(ChecklistActor actor, string id) { await _authorization.RequireMemberAsync(actor); var metadata = await _store.GetAsync(actor.DepartmentId, id, false); if (metadata?.WorkOrderId == null || metadata.WithdrawnOn.HasValue) throw new WorkOrderException(404, "Unavailable"); - await ReadOrderAsync(actor, metadata.WorkOrderId.Value); + await ReadOrderAsync(actor, metadata.WorkOrderId); var file = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, id)); if (file.ScanState != (int)RmsAttachmentScanState.Clean || file.WithdrawnOn.HasValue) throw new WorkOrderException(404, "Unavailable"); var enveloped = ProtectedReadService.IsBinaryEnveloped(file.Data); @@ -60,7 +60,7 @@ public async Task GetFileAsync(ChecklistActor actor, int id) if (result == null || result.RedactedFields.Count > 0 || result.IsProtected && !enveloped || file.Data == null) throw new WorkOrderException(403, "ProtectedDataRequired"); if (Convert.ToHexString(SHA256.HashData(file.Data)) != file.Sha256) throw new WorkOrderException(409, "IntegrityFailed"); return file; } - public async Task WithdrawFileAsync(ChecklistActor actor, int id, int fileId, int revision, string reason) + public async Task WithdrawFileAsync(ChecklistActor actor, string id, string fileId, int revision, string reason) { Text(reason, 4000, true); await TransactionAsync(actor, async events => diff --git a/Core/Resgrid.Services/WorkOrderGdprExport.cs b/Core/Resgrid.Services/WorkOrderGdprExport.cs index bebb8395b..fa39ec38e 100644 --- a/Core/Resgrid.Services/WorkOrderGdprExport.cs +++ b/Core/Resgrid.Services/WorkOrderGdprExport.cs @@ -14,7 +14,7 @@ private async Task BuildWorkOrderDataAsync(string userId, int department { if (_workOrders == null) throw new InvalidOperationException("Work-order export storage is unavailable."); async Task Safe(T row) where T : WorkOrderRow => await _checklistProtection.Value.ForDisplayAsync(departmentId, row, WorkOrderTables.Fields()); - async Task> Children(int id) where T : WorkOrderRow + async Task> Children(string id) where T : WorkOrderRow { var result = new List(); for (var skip = 0; ; skip += 500) { var batch = await _workOrders.ChildrenAsync(departmentId, id, skip); foreach (var row in batch) result.Add(await Safe(row)); if (batch.Count < 500) return result; } @@ -26,9 +26,9 @@ async Task> Children(int id) where T : WorkOrderRow foreach (var row in rows.Take(50)) { var activity = await Children(row.Id); var labor = await Children(row.Id); - if (row.CreatedBy != userId && row.AssignedToUserId != userId && row.CompletedBy != userId && row.VerifiedBy != userId && !activity.Any(a => a.CreatedBy == userId) && !labor.Any(l => l.UserId == userId || l.CreatedBy == userId)) continue; + if (row.CreatedBy != userId && !row.AssignedToUserIds.Contains(userId) && row.CompletedBy != userId && row.VerifiedBy != userId && !activity.Any(a => a.CreatedBy == userId) && !labor.Any(l => l.UserId == userId || l.CreatedBy == userId)) continue; var snapshots = new List(); - long afterId = 0; + string afterId = null; while (true) { var batch = await _workOrders.ReportSnapshotHistoryAsync(departmentId, row.Id, afterId); diff --git a/Core/Resgrid.Services/WorkOrderInventoryParts.cs b/Core/Resgrid.Services/WorkOrderInventoryParts.cs index 5004c2e0d..0fde7c2ad 100644 --- a/Core/Resgrid.Services/WorkOrderInventoryParts.cs +++ b/Core/Resgrid.Services/WorkOrderInventoryParts.cs @@ -14,7 +14,7 @@ namespace Resgrid.Services { public sealed partial class WorkOrdersService { - public async Task CancelPartWitnessAsync(ChecklistActor actor, int orderId, int partId, int revision, string reason) + public async Task CancelPartWitnessAsync(ChecklistActor actor, string orderId, string partId, int revision, string reason) { Text(reason, 4000, true); await TransactionAsync(actor, async events => @@ -32,14 +32,14 @@ await TransactionAsync(actor, async events => await ChangedAsync(actor, order, WorkOrderActivityType.PartVoided, events, reason, trigger: WorkflowTriggerEventType.WorkOrderPartChanged); return true; }); } - private async Task RequireNoPendingPartsAsync(ChecklistActor actor, int id) + private async Task RequireNoPendingPartsAsync(ChecklistActor actor, string id) { if ((await ChildrenAsync(actor, id)).Any(p => p.Staged && (p.ReservedQuantity > 0 || p.IssuedQuantity > 0))) throw new WorkOrderException(409, "PartBalanceOutstanding"); foreach (var part in await ChildrenAsync(actor, id)) if (part.InventoryOperationId != null && !part.VoidedOn.HasValue && (part.InventoryTransactionId == null || Decode(part.Content).VoidReason != null && part.InventoryReversalId == null)) throw new WorkOrderException(409, "InventoryWitnessPending"); } - public async Task AddPartAsync(ChecklistActor actor, int id, WorkOrderPartInput input) + public async Task AddPartAsync(ChecklistActor actor, string id, WorkOrderPartInput input) { if (input?.Content == null || input.Content.Quantity <= 0 || input.Content.Quantity > 100000 || decimal.Round(input.Content.Quantity, 6) != input.Content.Quantity) throw new WorkOrderException(400, "InvalidInput"); Text(input.Content.Description, 2000, true); Money(input.Content.UnitCost); input.Content.VoidReason = null; @@ -68,7 +68,7 @@ await TransactionAsync(actor, async events => if (linked) { var command = new InventoryCommand { RequestId = input.RequestId, Lines = new() { new InventoryPosting { ItemId = input.InventoryItemId, AssetId = input.InventoryAssetId, LotId = input.InventoryLotId, FromLocationId = input.InventoryLocationId, - Quantity = input.Content.Quantity, Type = InventoryTransactionType.Consume, ReferenceType = InventoryReferenceType.WorkOrder, ReferenceId = id.ToString(CultureInfo.InvariantCulture), WorkOrderPartId = part.Id } } }; + Quantity = input.Content.Quantity, Type = InventoryTransactionType.Consume, ReferenceType = InventoryReferenceType.WorkOrder, ReferenceId = id, WorkOrderPartId = part.Id } } }; var result = await _inventoryMaintenance.Value.PostPartAsync(InventoryActor(actor), part.Id, command); events.AddRange(result.OutboxIds); part.InventoryOperationId = result.OperationId; part.Revision++; await RevealAsync(actor, part); await SaveAsync(actor, part); @@ -77,7 +77,7 @@ await TransactionAsync(actor, async events => await ChangedAsync(actor, row, WorkOrderActivityType.PartAdded, events, trigger: WorkflowTriggerEventType.WorkOrderPartChanged); return true; }); } - public async Task VoidPartAsync(ChecklistActor actor, int id, int partId, int revision, string reason) + public async Task VoidPartAsync(ChecklistActor actor, string id, string partId, int revision, string reason) { Text(reason, 4000, true); await TransactionAsync(actor, async events => @@ -95,7 +95,7 @@ await TransactionAsync(actor, async events => await SaveAsync(actor, part); var command = new InventoryCommand { RequestId = MaintenanceIdentity("part-reversal:" + actor.DepartmentId + ":" + part.Id + ":" + part.Revision), Lines = new() { new InventoryPosting { ItemId = original.ItemId, AssetId = original.AssetId, LotId = original.LotId, ToLocationId = original.FromLocationId, Quantity = original.Quantity, Type = InventoryTransactionType.Adjust, - ReferenceType = InventoryReferenceType.WorkOrder, ReferenceId = id.ToString(CultureInfo.InvariantCulture), WorkOrderPartId = partId, ReversesTransactionId = original.Id, Note = reason } } }; + ReferenceType = InventoryReferenceType.WorkOrder, ReferenceId = id, WorkOrderPartId = partId, ReversesTransactionId = original.Id, Note = reason } } }; var result = await _inventoryMaintenance.Value.PostPartAsync(InventoryActor(actor), partId, command); events.AddRange(result.OutboxIds); if (!result.AwaitingWitness) { await CompleteInventoryPartAsync(InventoryActor(actor), partId, result.TransactionIds.Single(), true, events); return true; } part.InventoryOperationId = result.OperationId; await RevealAsync(actor, part); await SaveAsync(actor, part); @@ -104,20 +104,20 @@ await TransactionAsync(actor, async events => await ChangedAsync(actor, row, WorkOrderActivityType.PartVoided, events, reason, trigger: WorkflowTriggerEventType.WorkOrderPartChanged); return true; }); } - public async Task CompleteInventoryPartAsync(InventoryActor actor, int partId, string transactionId, bool reversal, List events) + public async Task CompleteInventoryPartAsync(InventoryActor actor, string partId, string transactionId, bool reversal, List events) { if (_uow.Transaction == null || _inventoryCatalog == null) throw new InvalidOperationException("The inventory posting transaction owns completion."); var principal = new ChecklistActor { DepartmentId = actor.DepartmentId, UserId = actor.UserId, GrantToken = actor.GrantToken }; await RequireWriteAsync(principal); var part = await _store.GetAsync(actor.DepartmentId, partId); if (part?.WorkOrderId == null) throw new WorkOrderException(404, "Unavailable"); - var row = await ReadOrderAsync(principal, part.WorkOrderId.Value); + var row = await ReadOrderAsync(principal, part.WorkOrderId); if (row.Status >= 5 || !await _authorization.CanContributeAsync(principal, row)) throw new WorkOrderException(403, "PermissionRequired"); await RevealAsync(principal, part); var ledger = await _inventoryCatalog.Value.GetAsync(actor, transactionId); - if (ledger.WorkOrderPartId != partId || ledger.ReferenceType != (int)InventoryReferenceType.WorkOrder || ledger.ReferenceId != row.Id.ToString(CultureInfo.InvariantCulture) || ledger.ItemId != part.InventoryItemId) throw new WorkOrderException(409, "InventoryPartInvalid"); - if (part.Staged && ledger.WorkOrderPartMovementId.HasValue) { await CompletePartMovementAsync(principal, row, part, ledger, events); return; } - if (part.Staged || ledger.WorkOrderPartMovementId.HasValue) throw new WorkOrderException(409, "InventoryPartInvalid"); + if (ledger.WorkOrderPartId != partId || ledger.ReferenceType != (int)InventoryReferenceType.WorkOrder || ledger.ReferenceId != row.Id || ledger.ItemId != part.InventoryItemId) throw new WorkOrderException(409, "InventoryPartInvalid"); + if (part.Staged && ledger.WorkOrderPartMovementId != null) { await CompletePartMovementAsync(principal, row, part, ledger, events); return; } + if (part.Staged || ledger.WorkOrderPartMovementId != null) throw new WorkOrderException(409, "InventoryPartInvalid"); var content = Decode(part.Content); if (ledger.Quantity != content.Quantity || reversal && ledger.ReversesTransactionId != part.InventoryTransactionId || !reversal && ledger.ReversesTransactionId != null) throw new WorkOrderException(409, "InventoryPartInvalid"); if (reversal) { if (part.InventoryReversalId != null) throw new WorkOrderException(409, "Conflict"); part.InventoryReversalId = ledger.Id; part.VoidedOn = Now; } diff --git a/Core/Resgrid.Services/WorkOrderMaintenanceCore.cs b/Core/Resgrid.Services/WorkOrderMaintenanceCore.cs index d32040252..206a5e876 100644 --- a/Core/Resgrid.Services/WorkOrderMaintenanceCore.cs +++ b/Core/Resgrid.Services/WorkOrderMaintenanceCore.cs @@ -31,14 +31,20 @@ private async Task PopulateOrderContentAsync(ChecklistActor actor, Wo { if (!string.IsNullOrEmpty(row.Content)) return row; WorkOrderContent fields; - if (row.SourceType == 2 && row.RecurrenceVersionId.HasValue) + if (row.SourceType == 2 && row.RecurrenceVersionId != null) { - var version = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, row.RecurrenceVersionId.Value)); + var version = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, row.RecurrenceVersionId)); var settings = Decode(version.Content); fields = settings?.Template?.Content ?? throw new WorkOrderException(409, "MaintenanceUnavailable"); } else if (row.SourceType == 1 && row.SourceChecklistCompletionId != null) fields = new WorkOrderContent { Title = MaintenanceText("GeneratedFailureTitle"), SafetyCritical = true }; else throw new WorkOrderException(409, "MaintenanceUnavailable"); + if (row.CurrencyCode != null && fields.Currency != row.CurrencyCode) + { + // A changed department denomination must not relabel a template's old quote. + fields.Currency = row.CurrencyCode; + fields.EstimatedCost = null; fields.ApprovedCost = null; fields.Approval = null; + } // An attended projection only. First mutation seals a row-specific copy with its numeric AAD. row.Content = JsonConvert.SerializeObject(new StoredContent { Fields = fields }); return row; } @@ -98,7 +104,7 @@ private async Task> WorkerTransactionAsync(int departmentId, Func { @@ -118,6 +124,7 @@ await WorkerTransactionAsync(departmentId, async events => order.RequestId = request; order.NumberYear = Now.Year; order.NumberSequence = await _store.NextNumberAsync(departmentId, order.NumberYear); order.SourceType = 1; order.SourceChecklistCompletionId = intent.CompletionId; order.SourceChecklistItemId = intent.ItemId; order.SourceOccurrenceId = intent.OccurrenceId; order.Priority = intent.Priority; order.TargetUnitId = target.TargetUnitId; order.TargetGroupId = target.TargetGroupId; order.InventoryAssetId = target.InventoryAssetId; + order.CurrencyCode = await DepartmentCurrencyAsync(departmentId); await PinSlaAsync(order); await _store.AllocateAsync(order); await RecordGeneratedCreationAsync(order); await EventAsync(order, WorkflowTriggerEventType.WorkOrderCreated, events); result.Generated++; } if (intent.HoldUnit && order.TargetUnitId.HasValue) { await CreateHoldAsync(owner, order, true, null, events, true); result.Held++; } @@ -125,12 +132,12 @@ await WorkerTransactionAsync(departmentId, async events => intent.WorkOrderId = order.Id; intent.ProcessedOn = Now; intent.UpdatedOn = Now; intent.Revision++; await _store.WriteAsync(intent); }); } - public async Task> HoldsAsync(ChecklistActor actor, int orderId) + public async Task> HoldsAsync(ChecklistActor actor, string orderId) { RequireMaintenanceStore(); await ReadOrderAsync(actor, orderId); return (await ChildrenAsync(actor, orderId)).Select(h => new WorkOrderHoldView { Hold = h, Content = Decode(h.Content) }).ToList(); } - public async Task AddHoldAsync(ChecklistActor actor, int orderId, WorkOrderHoldInput input) + public async Task AddHoldAsync(ChecklistActor actor, string orderId, WorkOrderHoldInput input) { if (input == null || !input.Unit && !input.Asset) throw new WorkOrderException(400, "InvalidInput"); Text(input.Reason, 4000, true); RequireMaintenanceStore(); @@ -172,7 +179,7 @@ private async Task CreateHoldAsync(ChecklistActor actor, WorkOrder order, bool u else { hold.Content = JsonConvert.SerializeObject(new WorkOrderHoldContent { Reason = reason }); await SaveAsync(actor, hold, true); } await MaintenanceEventAsync(order, WorkflowTriggerEventType.WorkOrderSafetyHoldApplied, events, holdId: hold.Id); } - public async Task ReleaseHoldAsync(ChecklistActor actor, int holdId, WorkOrderReleaseInput input) + public async Task ReleaseHoldAsync(ChecklistActor actor, string holdId, WorkOrderReleaseInput input) { if (input == null) throw new WorkOrderException(400, "InvalidInput"); Text(input.Evidence, 4000, true); Text(input.Qualification, 2000, true); RequireMaintenanceStore(); @@ -180,7 +187,7 @@ await TransactionAsync(actor, async events => { var hold = await _store.GetAsync(actor.DepartmentId, holdId); if (hold?.WorkOrderId == null) throw new WorkOrderException(404, "Unavailable"); - var order = await ReadOrderAsync(actor, hold.WorkOrderId.Value); await RevealAsync(actor, hold); Revision(hold, input.Revision); + var order = await ReadOrderAsync(actor, hold.WorkOrderId); await RevealAsync(actor, hold); Revision(hold, input.Revision); if (hold.ReleasedOn.HasValue || !await _authorization.CanManageAsync(actor, order.TargetGroupId)) throw new WorkOrderException(409, "PermissionRequired"); if (actor.UserId == order.CompletedBy || actor.UserId == hold.CreatedBy) throw new WorkOrderException(403, "IndependentReleaseRequired"); var others = (await _maintenance.ActiveHoldsAsync(actor.DepartmentId, hold.UnitId, hold.AssetId)).Where(h => h.Id != hold.Id).ToList(); @@ -204,7 +211,7 @@ await TransactionAsync(actor, async events => await MaintenanceEventAsync(order, WorkflowTriggerEventType.WorkOrderSafetyHoldReleased, events, holdId: hold.Id); return true; }, safetyRelease: true); } - private async Task MaintenanceEventAsync(WorkOrder order, WorkflowTriggerEventType trigger, List events, int? holdId = null, int? recurrenceId = null) + private async Task MaintenanceEventAsync(WorkOrder order, WorkflowTriggerEventType trigger, List events, string holdId = null, string recurrenceId = null) { var entry = await _outbox.EnqueueAsync(order.DepartmentId, "WorkOrders", new DomainEventEnvelope { EventName = trigger.ToString(), AggregateType = "WorkOrder", AggregateId = Key(order), AggregateVersion = order.Revision, Trigger = trigger, OccurredOn = Now, Payload = new { WorkOrderId = order.Id, order.Revision, order.Status, order.Priority, order.TargetUnitId, order.TargetGroupId, order.InventoryAssetId, order.AssignedToRoleId, order.DueOn, HoldId = holdId, RecurrenceId = recurrenceId } }); diff --git a/Core/Resgrid.Services/WorkOrderMaintenanceGdprExport.cs b/Core/Resgrid.Services/WorkOrderMaintenanceGdprExport.cs index 8593dcd6a..98c73d936 100644 --- a/Core/Resgrid.Services/WorkOrderMaintenanceGdprExport.cs +++ b/Core/Resgrid.Services/WorkOrderMaintenanceGdprExport.cs @@ -35,7 +35,7 @@ async Task> Rows(Func subject) where T : WorkOrderRow PartMovements = await Rows(r => r.CreatedBy == userId), FailureIntents = await Rows(r => r.CreatedBy == userId || r.AuthorizedBy == userId), SafetyHolds = await Rows(r => r.CreatedBy == userId || r.ReleasedBy == userId), - Recurrences = await Rows(r => r.CreatedBy == userId || r.AssignedToUserId == userId || related.Contains(r.Id)), + Recurrences = await Rows(r => r.CreatedBy == userId || r.AssignedToUserIds.Contains(userId) || related.Contains(r.Id)), Versions = versions, Readings = readings, Changes = changes }; } diff --git a/Core/Resgrid.Services/WorkOrderNotificationService.cs b/Core/Resgrid.Services/WorkOrderNotificationService.cs index 8cee35593..293f5bae3 100644 --- a/Core/Resgrid.Services/WorkOrderNotificationService.cs +++ b/Core/Resgrid.Services/WorkOrderNotificationService.cs @@ -35,13 +35,13 @@ private async Task TransactionAsync(int departmentId, Func> action } public async Task DispatchAsync(DomainEventOutboxEntry entry) { - if (entry?.ProducerSubsystem != "WorkOrders" || entry.TriggerEventType is not (70 or 71 or 72 or 73 or 167 or 168 or 173 or 174) || !Guid.TryParseExact(entry.EventId, "D", out _) || !int.TryParse(entry.AggregateId, out var id)) return; + if (entry?.ProducerSubsystem != "WorkOrders" || entry.TriggerEventType is not (70 or 71 or 72 or 73 or 167 or 168 or 173 or 174) || !Guid.TryParseExact(entry.EventId, "D", out _) || !Guid.TryParseExact(entry.AggregateId, "D", out _)) return; if (!await _access.CanUseMaintenanceAsync(entry.DepartmentId)) return; - var row = await _orders.GetAsync(entry.DepartmentId, id); + var row = await _orders.GetAsync(entry.DepartmentId, entry.AggregateId); if (row == null) return; foreach (var user in await RecipientsAsync(entry.DepartmentId, row, entry.TriggerEventType is 173 or 174)) { - var notice = new WorkOrderNotification { DepartmentId = entry.DepartmentId, WorkOrderId = id, EventId = entry.EventId, UserId = user, LeaseOwner = Guid.NewGuid().ToString("D") }; + var notice = new WorkOrderNotification { DepartmentId = entry.DepartmentId, WorkOrderId = entry.AggregateId, EventId = entry.EventId, UserId = user, LeaseOwner = Guid.NewGuid().ToString("D") }; var state = await TransactionAsync(entry.DepartmentId, () => _orders.ClaimNotificationAsync(notice, DateTime.UtcNow)); if (state == 2 || state == 3) continue; if (state != 1) throw new InvalidOperationException("Work-order notification is leased by another delivery attempt."); @@ -50,24 +50,25 @@ public async Task DispatchAsync(DomainEventOutboxEntry entry) var profile = await _profiles.GetProfileByUserIdAsync(user); var department = await _departments.GetDepartmentByIdAsync(entry.DepartmentId, true); var number = await _settings.GetTextToCallNumberForDepartmentAsync(entry.DepartmentId); - var current = await _orders.GetAsync(entry.DepartmentId, id); + var current = await _orders.GetAsync(entry.DepartmentId, entry.AggregateId); if (department == null || profile == null || current == null || !await _access.CanUseMaintenanceAsync(entry.DepartmentId) || !await IsRecipientAsync(entry.DepartmentId, current, user, entry.TriggerEventType is 173 or 174)) { await FinishAsync(notice, 3); continue; } CultureInfo culture; try { culture = CultureInfo.GetCultureInfo(profile.Language ?? "en"); if (!SupportedLocales.GetSupportedCultures().Contains(culture.TwoLetterISOLanguageName)) culture = CultureInfo.GetCultureInfo("en"); } catch (CultureNotFoundException) { culture = CultureInfo.GetCultureInfo("en"); } - // Never decrypt, copy a title, embed an actor/grant or treat handoff as a delivery receipt. + // The public work-order number identifies the item without decrypting protected content. + var workOrderNumber = FormattableString.Invariant($"WO-{current.NumberYear}-{current.NumberSequence:D6}"); var handedOff = await _communication.SendNotificationAsync(user, entry.DepartmentId, - Strings.GetString("NotificationMessage", culture) + " " + (Config.SystemBehaviorConfig.ResgridBaseUrl ?? "").TrimEnd('/') + "/User/WorkOrders/Detail/" + id, + workOrderNumber + ": " + Strings.GetString("NotificationMessage", culture), number, department, Strings.GetString("NotificationTitle", culture), profile); await FinishAsync(notice, handedOff ? 2 : 3); } catch (Exception ex) { // Provider errors can contain content or credentials; only routing and exception types leave this boundary. - Resgrid.Framework.Logging.LogError($"Work-order notification handoff failed for department {entry.DepartmentId}, order {id}: {ex.GetType().FullName}."); + Resgrid.Framework.Logging.LogError($"Work-order notification handoff failed for department {entry.DepartmentId}, order {entry.AggregateId}: {ex.GetType().FullName}."); try { await FinishAsync(notice, 0); } - catch (Exception releaseEx) { Resgrid.Framework.Logging.LogError($"Work-order notification lease release failed for department {entry.DepartmentId}, order {id}: {releaseEx.GetType().FullName}."); } + catch (Exception releaseEx) { Resgrid.Framework.Logging.LogError($"Work-order notification lease release failed for department {entry.DepartmentId}, order {entry.AggregateId}: {releaseEx.GetType().FullName}."); } throw new InvalidOperationException("Work-order notification handoff failed."); } } @@ -81,8 +82,8 @@ private async Task IsRecipientAsync(int departmentId, WorkOrder row, strin if ((managerNotice || row.Status is 0 or 1) && await _authorization.CanManageAsync(actor, row.TargetGroupId)) return true; // Escalation-role members are recipients too; revalidating without this drops them before the assigned-user check. if (row.EscalatedOn.HasValue && row.EscalationRoleId.HasValue && (await _authorization.ScopeAsync(actor)).RoleIds.Contains(row.EscalationRoleId.Value)) return true; - if (row.AssignedToUserId != null) return row.AssignedToUserId == user; - return row.AssignedToRoleId.HasValue && (await _authorization.ScopeAsync(actor)).RoleIds.Contains(row.AssignedToRoleId.Value); + if (row.AssignedToUserIds.Contains(user)) return true; + return row.AssignedToRoleIds.Count != 0 && row.AssignedToRoleIds.Intersect((await _authorization.ScopeAsync(actor)).RoleIds).Any(); } private async Task> RecipientsAsync(int departmentId, WorkOrder row, bool managerNotice = false) { diff --git a/Core/Resgrid.Services/WorkOrderPartAllocations.cs b/Core/Resgrid.Services/WorkOrderPartAllocations.cs index 6b7774664..8866d3de4 100644 --- a/Core/Resgrid.Services/WorkOrderPartAllocations.cs +++ b/Core/Resgrid.Services/WorkOrderPartAllocations.cs @@ -18,8 +18,8 @@ namespace Resgrid.Services public sealed partial class WorkOrdersService { private static string OperationHash(object input) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(input)))); - public async Task> PartMovementsAsync(ChecklistActor actor, int id, int afterId = 0) => (await EvidencePageAsync(actor, id, afterId, r => r)).Items; - public async Task ReservePartAsync(ChecklistActor actor, int id, WorkOrderPartInput input) + public async Task> PartMovementsAsync(ChecklistActor actor, string id, string afterId = null) => (await EvidencePageAsync(actor, id, afterId, r => r)).Items; + public async Task ReservePartAsync(ChecklistActor actor, string id, WorkOrderPartInput input) { if (input?.Content == null || !Guid.TryParseExact(input.RequestId, "D", out _) || !Guid.TryParseExact(input.InventoryItemId, "D", out _) || !Guid.TryParseExact(input.InventoryLocationId, "D", out _) || input.Content.Quantity is <= 0 or > 100000 || decimal.Round(input.Content.Quantity, 6) != input.Content.Quantity || _inventoryMaintenance == null) throw new WorkOrderException(400, "InventoryPartInvalid"); @@ -45,7 +45,7 @@ await TransactionAsync(actor, async events => await ChangedAsync(actor, row, WorkOrderActivityType.PartAdded, events, trigger: WorkflowTriggerEventType.WorkOrderPartChanged); return true; }); } - public async Task MovePartAsync(ChecklistActor actor, int id, int partId, WorkOrderPartMovementInput input) + public async Task MovePartAsync(ChecklistActor actor, string id, string partId, WorkOrderPartMovementInput input) { if (input == null || !Guid.TryParseExact(input.RequestId, "D", out _) || !Enum.IsDefined(input.Kind) || input.Kind == WorkOrderPartMovementKind.Reserve || input.Quantity is <= 0 or > 100000 || decimal.Round(input.Quantity, 6) != input.Quantity) throw new WorkOrderException(400, "InventoryPartInvalid"); Text(input.Reason, 4000, input.Kind is WorkOrderPartMovementKind.ReturnUnused or WorkOrderPartMovementKind.ReleaseReservation); @@ -82,7 +82,7 @@ await TransactionAsync(actor, async events => if (input.Kind != WorkOrderPartMovementKind.ReturnUnused) await RequireSpendingAsync(actor, row); var command = new InventoryCommand { RequestId = input.RequestId, Lines = new() { new InventoryPosting { ItemId = part.InventoryItemId, AssetId = part.ReservedAssetId, LotId = part.ReservedLotId, FromLocationId = movement.FromLocationId, ToLocationId = movement.ToLocationId, Quantity = input.Quantity, Type = input.Kind == WorkOrderPartMovementKind.Consume ? InventoryTransactionType.Consume : InventoryTransactionType.Transfer, - ReferenceType = InventoryReferenceType.WorkOrder, ReferenceId = id.ToString(CultureInfo.InvariantCulture), WorkOrderPartId = partId, WorkOrderPartMovementId = movement.Id } } }; + ReferenceType = InventoryReferenceType.WorkOrder, ReferenceId = id, WorkOrderPartId = partId, WorkOrderPartMovementId = movement.Id } } }; var result = await _inventoryMaintenance.Value.PostPartAsync(InventoryActor(actor), partId, command); events.AddRange(result.OutboxIds); movement.InventoryOperationId = result.OperationId; movement.Revision++; await SaveAsync(actor, movement); if (!result.AwaitingWitness) await CompleteInventoryPartAsync(InventoryActor(actor), partId, result.TransactionIds.Single(), false, events); @@ -92,7 +92,7 @@ await TransactionAsync(actor, async events => } private async Task CompletePartMovementAsync(ChecklistActor actor, WorkOrder row, WorkOrderPart part, InventoryTransaction ledger, List events) { - var movement = await _store.GetAsync(actor.DepartmentId, ledger.WorkOrderPartMovementId.Value); + var movement = await _store.GetAsync(actor.DepartmentId, ledger.WorkOrderPartMovementId); if (movement?.PartId != part.Id || movement.WorkOrderId != row.Id || movement.Cancelled || movement.Quantity != ledger.Quantity || movement.InventoryTransactionId != null || movement.FromLocationId != ledger.FromLocationId || movement.ToLocationId != ledger.ToLocationId) throw new WorkOrderException(409, "InventoryPartInvalid"); await RevealAsync(actor, movement); var c = Decode(part.Content); @@ -113,7 +113,7 @@ private async Task CompletePartMovementAsync(ChecklistActor actor, WorkOrder row if (movement.Kind != (int)WorkOrderPartMovementKind.ReturnUnused) await RequireSpendingAsync(actor, row); await ChangedAsync(actor, row, WorkOrderActivityType.PartAdded, events, trigger: WorkflowTriggerEventType.WorkOrderPartChanged); } - public async Task CancelPartMovementAsync(ChecklistActor actor, int id, int movementId, int revision, string reason) + public async Task CancelPartMovementAsync(ChecklistActor actor, string id, string movementId, int revision, string reason) { Text(reason, 4000, true); await TransactionAsync(actor, async events => diff --git a/Core/Resgrid.Services/WorkOrderPolicies.cs b/Core/Resgrid.Services/WorkOrderPolicies.cs index 5018c60dd..1aa0df3a1 100644 --- a/Core/Resgrid.Services/WorkOrderPolicies.cs +++ b/Core/Resgrid.Services/WorkOrderPolicies.cs @@ -18,10 +18,14 @@ public sealed partial class WorkOrdersService private async Task ReadPolicyAsync(ChecklistActor actor) { var row = await PolicyRowAsync(actor.DepartmentId); - if (row == null) return new WorkOrderPolicyInput(); + if (row == null) return new WorkOrderPolicyInput { Currency = "USD" }; await RevealAsync(actor, row); - var value = Decode(row.Content); value.Revision = row.Revision; value.Calendar = Decode(row.CalendarJson); return value; + var value = Decode(row.Content); value.Revision = row.Revision; value.Calendar = Decode(row.CalendarJson); + value.Currency = row.CurrencyCode ?? "USD"; + value.SpendingThreshold = value.SpendingRules.SingleOrDefault(r => r.Currency == value.Currency)?.Threshold; + return value; } + private async Task DepartmentCurrencyAsync(int departmentId) => (await PolicyRowAsync(departmentId))?.CurrencyCode ?? "USD"; private async Task RequirePolicyManagerAsync(ChecklistActor actor) { await _authorization.RequireMemberAsync(actor); @@ -57,14 +61,29 @@ public static DateTime AddBusinessMinutes(DateTime utc, int minutes, WorkOrderBu } public async Task SavePolicyAsync(ChecklistActor actor, WorkOrderPolicyInput input) { - if (input == null || input.SpendingRules == null || input.SpendingRules.Count > 30 || input.SpendingRules.Any(r => r == null || !ValidCurrency(r.Currency) || r.Threshold < 0 || decimal.Round(r.Threshold, 2) != r.Threshold || r.Threshold > 100000000m) - || input.SpendingRules.Select(r => r.Currency).Distinct().Count() != input.SpendingRules.Count || input.ApprovalsEnabled && input.SpendingRules.Count == 0) throw new WorkOrderException(400, "OperationsPolicyInvalid"); + if (input == null || input.Currency != null && !WorkOrderCurrencies.IsSupported(input.Currency) + || input.SpendingThreshold < 0 || input.SpendingThreshold > 100000000m || input.SpendingThreshold.HasValue && decimal.Round(input.SpendingThreshold.Value, 2) != input.SpendingThreshold + || input.SpendingRules == null || input.SpendingRules.Count > 30 || input.SpendingRules.Any(r => r == null || !ValidCurrency(r.Currency) || r.Threshold < 0 || decimal.Round(r.Threshold, 2) != r.Threshold || r.Threshold > 100000000m) + || input.SpendingRules.Select(r => r.Currency).Distinct().Count() != input.SpendingRules.Count) throw new WorkOrderException(400, "OperationsPolicyInvalid"); ValidateBusinessCalendar(input.Calendar); await RequirePolicyManagerAsync(actor); RequireMaintenanceStore(); await TransactionAsync(actor, async events => { await RequirePolicyManagerAsync(actor); var row = await PolicyRowAsync(actor.DepartmentId); var previous = row == null ? null : (await RevealAsync(actor, row)).Content; if (row == null && input.Revision != 0 || row != null && row.Revision != input.Revision) throw new WorkOrderException(409, "Conflict"); + var previousSettings = row == null ? new WorkOrderPolicyInput() : Decode(previous); + // Older clients may still send the single-currency policy contract. Preserve their currency + // on first configuration; subsequent writes cannot change it without the settings field. + input.Currency ??= row?.CurrencyCode ?? (input.SpendingRules.Count == 1 ? input.SpendingRules[0].Currency : "USD"); + if (!WorkOrderCurrencies.IsSupported(input.Currency)) throw new WorkOrderException(400, "OperationsPolicyInvalid"); + var threshold = input.SpendingThreshold ?? input.SpendingRules.SingleOrDefault(r => r.Currency == input.Currency)?.Threshold; + if (input.ApprovalsEnabled && !threshold.HasValue) throw new WorkOrderException(400, "OperationsPolicyInvalid"); + // Keep historical thresholds in their original denomination for existing orders. + // They are never reinterpreted as amounts in the newly selected currency. + input.SpendingRules = previousSettings.SpendingRules.Where(r => r.Currency != input.Currency).ToList(); + if (threshold.HasValue) input.SpendingRules.Add(new WorkOrderSpendingRule { Currency = input.Currency, Threshold = threshold.Value }); + input.SpendingThreshold = threshold; var insert = row == null; row ??= New(actor); if (!insert) row.Revision++; + row.CurrencyCode = input.Currency; row.CalendarJson = JsonConvert.SerializeObject(input.Calendar); row.Content = JsonConvert.SerializeObject(input); await SaveAsync(actor, row, insert); var receipt = New(actor); receipt.Kind = 2; receipt.RequestId = Guid.NewGuid().ToString("D"); receipt.Content = JsonConvert.SerializeObject(new { PolicyId = row.Id, row.Revision, Previous = previous, Settings = input }); await SaveAsync(actor, receipt, true); var audit = await _audit.InsertAsync(new AuditLog { DepartmentId = actor.DepartmentId, ObjectDepartmentId = actor.DepartmentId, UserId = actor.UserId, ObjectId = "WorkOrderPolicy:" + row.Id, @@ -107,7 +126,7 @@ private async Task RequireSpendingAsync(ChecklistActor actor, WorkOrder order, d if (total <= rule.Threshold) return; if (content.Approval?.State != WorkOrderApprovalState.Approved || content.Approval.Currency != currency || content.Approval.Amount < total) throw new WorkOrderException(409, "SpendingApprovalRequired"); } - public async Task RequestApprovalAsync(ChecklistActor actor, int id, WorkOrderApprovalInput input) + public async Task RequestApprovalAsync(ChecklistActor actor, string id, WorkOrderApprovalInput input) { if (input == null) throw new WorkOrderException(400, "InvalidInput"); Money(input.Amount); Text(input.Reason, 4000, true); await TransactionAsync(actor, async events => @@ -120,7 +139,7 @@ await TransactionAsync(actor, async events => await ChangedAsync(actor, row, WorkOrderActivityType.Updated, events, trigger: WorkflowTriggerEventType.WorkOrderApprovalChanged); return true; }); } - public async Task DecideApprovalAsync(ChecklistActor actor, int id, WorkOrderApprovalInput input) + public async Task DecideApprovalAsync(ChecklistActor actor, string id, WorkOrderApprovalInput input) { if (input == null) throw new WorkOrderException(400, "InvalidInput"); Text(input.Reason, 4000, true); await TransactionAsync(actor, async events => diff --git a/Core/Resgrid.Services/WorkOrderRecurrenceService.cs b/Core/Resgrid.Services/WorkOrderRecurrenceService.cs index 5c22f4010..1993862a0 100644 --- a/Core/Resgrid.Services/WorkOrderRecurrenceService.cs +++ b/Core/Resgrid.Services/WorkOrderRecurrenceService.cs @@ -71,6 +71,7 @@ private async Task RecurrenceViewAsync(ChecklistActor a await RequireRecurrenceAsync(actor, row); await RevealAsync(actor, row); var version = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, row.CurrentVersionId)); var settings = Decode(version.Content); settings.Id = row.Id; settings.Revision = row.Revision; settings.IsActive = row.IsActive; settings.MeterBaseline = row.MeterBaseline; settings.AnchorLocal = row.AnchorLocal; + NormalizeRecurrenceAssignees(settings); var view = new WorkOrderRecurrenceView { HistoryPage = historyPage, Schedule = row, Settings = settings }; if (history) { @@ -80,7 +81,7 @@ private async Task RecurrenceViewAsync(ChecklistActor a view.HasMoreHistory = view.Changes.Count == 500 || view.Readings.Count == 500; return view; } - public async Task RecurrenceAsync(ChecklistActor actor, int id, int historyPage = 0) + public async Task RecurrenceAsync(ChecklistActor actor, string id, int historyPage = 0) { RequireMaintenanceStore(); if (historyPage < 0 || historyPage > 10000) throw new WorkOrderException(400, "RecurrenceInvalid"); return await RecurrenceViewAsync(actor, await _store.GetAsync(actor.DepartmentId, id), true, historyPage); @@ -102,10 +103,11 @@ public async Task> RecurrencesAsync(ChecklistActor if (rows.Count < 500) return result; } } - public async Task SaveRecurrenceAsync(ChecklistActor actor, WorkOrderRecurrenceInput input) + public async Task SaveRecurrenceAsync(ChecklistActor actor, WorkOrderRecurrenceInput input) { RequireMaintenanceStore(); if (input?.Template == null) throw new WorkOrderException(400, "RecurrenceInvalid"); + NormalizeRecurrenceAssignees(input); input.Template.Content ??= new WorkOrderContent(); input.Template.Content.Resolution = input.Template.Content.Cause = input.Template.Content.VerificationEvidence = null; foreach (var step in input.Template.Content.Steps ?? new()) step.Completed = false; @@ -121,56 +123,81 @@ public async Task SaveRecurrenceAsync(ChecklistActor actor, WorkOrderRecurr || input.ConditionThreshold < -1000000000m || input.ConditionThreshold > 1000000000m || input.Condition != 0 && (!input.ConditionThreshold.HasValue || string.IsNullOrWhiteSpace(input.ConditionUnit)) || input.EscalateAfterMinutes < 0 || input.EscalateAfterMinutes > 525600) throw new WorkOrderException(400, "RecurrenceInvalid"); - MaintenanceZone(input.TimeZoneId); Text(input.ConditionUnit, 100); Text(input.Reason, 4000, input.Id != 0); + MaintenanceZone(input.TimeZoneId); Text(input.ConditionUnit, 100); Text(input.Reason, 4000, input.Id != null); return await TransactionAsync(actor, async events => { await _authorization.ValidateTargetAsync(actor, input.Template); if (!await _authorization.CanManageAsync(actor, input.Template.TargetGroupId)) throw new WorkOrderException(403, "PermissionRequired"); - if (input.Id == 0) + if (input.Id == null) { var existing = (await _maintenance.QueryMaintenanceAsync(actor.DepartmentId, "RequestId", input.Template.RequestId)).SingleOrDefault(); if (existing != null) { await RequireRecurrenceAsync(actor, existing); var pinned = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, existing.CurrentVersionId)); - if (existing.CreatedBy != actor.UserId || pinned.Content != JsonConvert.SerializeObject(input)) throw new WorkOrderException(409, "Conflict"); + var previous = Decode(pinned.Content); + input.Template.Content.Currency = previous.Template.Content.Currency; + NormalizeRecurrenceAssignees(previous); + if (existing.CreatedBy != actor.UserId || JsonConvert.SerializeObject(previous) != JsonConvert.SerializeObject(input)) throw new WorkOrderException(409, "Conflict"); return existing.Id; } } - var row = input.Id == 0 ? New(actor) : await _store.GetAsync(actor.DepartmentId, input.Id); + var row = input.Id == null ? New(actor) : await _store.GetAsync(actor.DepartmentId, input.Id); if (row == null) throw new WorkOrderException(404, "Unavailable"); + if (input.Id == null) input.Template.Content.Currency = await DepartmentCurrencyAsync(actor.DepartmentId); row.RequestId ??= input.Template.RequestId; var calendarChanged = row.Calendar != (int)input.Calendar || row.Interval != input.Interval || row.TimeZoneId != input.TimeZoneId || row.AnchorLocal != input.AnchorLocal; - if (input.Id != 0) + if (input.Id != null) { await RequireRecurrenceAsync(actor, row); await RevealAsync(actor, row); Revision(row, input.Revision); + var previousVersion = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, row.CurrentVersionId)); + input.Template.Content.Currency = Decode(previousVersion.Content).Template.Content.Currency; if (row.TargetUnitId != input.Template.TargetUnitId || row.InventoryAssetId != input.Template.InventoryAssetId) throw new WorkOrderException(409, "TargetUnavailable"); - if (row.PendingWorkOrderId.HasValue && (row.TargetUnitId != input.Template.TargetUnitId || row.InventoryAssetId != input.Template.InventoryAssetId || row.MeterUnit != (int)input.MeterUnit || row.MeterBaseline != input.MeterBaseline)) throw new WorkOrderException(409, "RecurrencePending"); + if (row.PendingWorkOrderId != null && (row.TargetUnitId != input.Template.TargetUnitId || row.InventoryAssetId != input.Template.InventoryAssetId || row.MeterUnit != (int)input.MeterUnit || row.MeterBaseline != input.MeterBaseline)) throw new WorkOrderException(409, "RecurrencePending"); row.Revision++; } var assignment = new WorkOrder { DepartmentId = actor.DepartmentId, TargetGroupId = input.Template.TargetGroupId }; - if (input.AssignedToUserId != null || input.AssignedToRoleId.HasValue) await _authorization.ValidateAssignmentAsync(actor, assignment, input.AssignedToUserId, input.AssignedToRoleId); + await ValidateRecurrenceAssigneesAsync(actor, assignment, input.AssignedToUserIds, input.AssignedToRoleIds); if (input.EscalationRoleId.HasValue) await _authorization.ValidateAssignmentAsync(actor, assignment, null, input.EscalationRoleId); row.TargetUnitId = input.Template.TargetUnitId; row.TargetGroupId = input.Template.TargetGroupId; row.InventoryAssetId = input.Template.InventoryAssetId; row.AssignedToUserId = input.AssignedToUserId; row.AssignedToRoleId = input.AssignedToRoleId; row.Priority = (int)input.Template.Priority; + row.AssignedToUserIds = input.AssignedToUserIds; row.AssignedToRoleIds = input.AssignedToRoleIds; row.IsActive = input.IsActive; row.Calendar = (int)input.Calendar; row.Interval = input.Interval; row.TimeZoneId = input.TimeZoneId; row.AnchorLocal = DateTime.SpecifyKind(input.AnchorLocal, DateTimeKind.Unspecified); row.EndOn = input.EndOn?.Date; row.LeadDays = input.LeadDays; row.CompletionBased = input.CompletionBased; row.ServiceWeekdays = input.ServiceWeekdays; row.ServiceStartMinute = input.ServiceStartMinute; row.ServiceEndMinute = input.ServiceEndMinute; row.BlackoutFrom = input.BlackoutFrom?.Date; row.BlackoutUntil = input.BlackoutUntil?.Date; - if (input.Id != 0 && (row.MeterUnit != (int)input.MeterUnit || row.MeterBaseline != input.MeterBaseline)) throw new WorkOrderException(409, "UseMeterReset"); + if (input.Id != null && (row.MeterUnit != (int)input.MeterUnit || row.MeterBaseline != input.MeterBaseline)) throw new WorkOrderException(409, "UseMeterReset"); row.MeterUnit = (int)input.MeterUnit; row.MeterInterval = input.MeterInterval; row.MeterBaseline = input.MeterBaseline; row.LastMeterValue ??= input.MeterBaseline; row.Condition = (int)input.Condition; row.ConditionThreshold = input.ConditionThreshold; row.EscalateAfterMinutes = input.EscalateAfterMinutes; row.EscalationRoleId = input.EscalationRoleId; - if (input.Id == 0 || calendarChanged) row.NextDueOn = NextMaintenanceDue(row, input.Id == 0 ? null : Now); + if (input.Id == null || calendarChanged) row.NextDueOn = NextMaintenanceDue(row, input.Id == null ? null : Now); // Meter provenance changes only through a separately audited reset command. row.Content = JsonConvert.SerializeObject(new { Name = input.Template.Content.Title }); - if (input.Id == 0) await SaveAsync(actor, row, true); + if (input.Id == null) await SaveAsync(actor, row, true); var version = New(actor); version.RecurrenceId = row.Id; version.Content = JsonConvert.SerializeObject(input); await SaveAsync(actor, version, true); row.CurrentVersionId = version.Id; await SaveAsync(actor, row); await RecurrenceChangeAsync(actor, row, MaintenanceChangeType.Configured, input.Reason, null, null); await RecurrenceEventAsync(row, WorkflowTriggerEventType.WorkOrderRecurrenceChanged, events); return row.Id; }); } + private static void NormalizeRecurrenceAssignees(WorkOrderRecurrenceInput input) + { + // Null collections are older API requests or stored versions. Empty collections clear assignments. + input.AssignedToUserIds ??= WorkOrderAssignees.Users(null, input.AssignedToUserId); + input.AssignedToRoleIds ??= WorkOrderAssignees.Roles(null, input.AssignedToRoleId); + if (input.AssignedToUserIds.Count + input.AssignedToRoleIds.Count > 500 + || input.AssignedToUserIds.Any(id => string.IsNullOrWhiteSpace(id) || id.Length > 128) + || input.AssignedToRoleIds.Any(id => id <= 0)) throw new WorkOrderException(400, "AssignmentRequired"); + input.AssignedToUserIds = input.AssignedToUserIds.Distinct(StringComparer.Ordinal).OrderBy(id => id, StringComparer.Ordinal).ToList(); + input.AssignedToRoleIds = input.AssignedToRoleIds.Distinct().OrderBy(id => id).ToList(); + input.AssignedToUserId = input.AssignedToUserIds.Count == 1 && input.AssignedToRoleIds.Count == 0 ? input.AssignedToUserIds[0] : null; + input.AssignedToRoleId = input.AssignedToRoleIds.Count == 1 && input.AssignedToUserIds.Count == 0 ? input.AssignedToRoleIds[0] : null; + } + private async Task ValidateRecurrenceAssigneesAsync(ChecklistActor actor, WorkOrder order, IEnumerable users, IEnumerable roles) + { + foreach (var user in users) await _authorization.ValidateAssignmentAsync(actor, order, user, null); + foreach (var role in roles) await _authorization.ValidateAssignmentAsync(actor, order, null, role); + } private async Task RecurrenceChangeAsync(ChecklistActor actor, WorkOrderRecurrence row, MaintenanceChangeType type, string note, DateTime? original, DateTime? revised) { var change = New(actor); change.RecurrenceId = row.Id; change.ChangeType = (int)type; change.OriginalDueOn = original; change.RevisedDueOn = revised; @@ -181,7 +208,7 @@ private async Task RecurrenceEventAsync(WorkOrderRecurrence row, WorkflowTrigger var entry = await _outbox.EnqueueAsync(row.DepartmentId, "WorkOrders", new DomainEventEnvelope { EventName = trigger.ToString(), AggregateType = "WorkOrderRecurrence", AggregateId = "recurrence:" + row.Id, AggregateVersion = row.Revision, Trigger = trigger, OccurredOn = Now, Payload = new { RecurrenceId = row.Id, row.Revision, row.TargetUnitId, row.TargetGroupId, row.InventoryAssetId, DueOn = row.NextDueOn } }); events.Add(entry.DomainEventOutboxId); } - public async Task RecordReadingAsync(ChecklistActor actor, int id, WorkOrderReadingInput input) + public async Task RecordReadingAsync(ChecklistActor actor, string id, WorkOrderReadingInput input) { RequireMaintenanceStore(); if (input == null || !Guid.TryParseExact(input.RequestId, "D", out _) || input.ObservedOn.Year < 2000 || input.ObservedOn > Now.AddMinutes(5) @@ -201,7 +228,7 @@ await TransactionAsync(actor, async events => } Revision(row, input.Revision); if (row.LastReadingOn.HasValue && input.ObservedOn <= row.LastReadingOn || input.MeterValue.HasValue && row.MeterUnit == 0 || input.ConditionValue.HasValue && row.Condition == 0 - || input.ResetMeter && (!input.MeterValue.HasValue || row.PendingWorkOrderId.HasValue) || !input.ResetMeter && input.MeterValue < row.LastMeterValue) throw new WorkOrderException(409, "ReadingInvalid"); + || input.ResetMeter && (!input.MeterValue.HasValue || row.PendingWorkOrderId != null) || !input.ResetMeter && input.MeterValue < row.LastMeterValue) throw new WorkOrderException(409, "ReadingInvalid"); if (input.ResetMeter) { row.MeterEpoch++; row.MeterBaseline = input.MeterValue; row.ReadingDue = false; row.ReadingDueOn = null; } var reading = New(actor); reading.RecurrenceId = id; reading.RequestId = input.RequestId; reading.MeterEpoch = row.MeterEpoch; reading.ObservedOn = input.ObservedOn; reading.Content = JsonConvert.SerializeObject(input); await SaveAsync(actor, reading, true); @@ -218,7 +245,7 @@ await TransactionAsync(actor, async events => if (!before && row.ReadingDue) await RecurrenceEventAsync(row, WorkflowTriggerEventType.WorkOrderThresholdReached, events); return true; }); } - public async Task DeferAsync(ChecklistActor actor, int orderId, WorkOrderDeferralInput input) + public async Task DeferAsync(ChecklistActor actor, string orderId, WorkOrderDeferralInput input) { RequireMaintenanceStore(); if (input == null || input.DueOn <= Now || input.DueOn > Now.AddDays(366)) throw new WorkOrderException(400, "RecurrenceInvalid"); @@ -229,9 +256,9 @@ await TransactionAsync(actor, async events => if (Terminal(order) || order.Status == 5 || !await _authorization.CanManageAsync(actor, order.TargetGroupId)) throw new WorkOrderException(403, "PermissionRequired"); order.OriginalDueOn ??= order.DueOn; var original = order.DueOn; order.DueOn = input.DueOn; order.EscalatedOn = null; await ChangedAsync(actor, order, WorkOrderActivityType.Deferred, events, input.Reason, trigger: WorkflowTriggerEventType.WorkOrderDeferred, previousDue: original); - if (int.TryParse(order.WorkOrderRecurrenceId, out var scheduleId)) + if (Guid.TryParseExact(order.WorkOrderRecurrenceId, "D", out _)) { - var recurrence = await _store.GetAsync(actor.DepartmentId, scheduleId); + var recurrence = await _store.GetAsync(actor.DepartmentId, order.WorkOrderRecurrenceId); await RecurrenceChangeAsync(actor, recurrence, MaintenanceChangeType.Deferred, input.Reason, original, input.DueOn); } return true; @@ -239,18 +266,18 @@ await TransactionAsync(actor, async events => } private async Task RecurrenceReopenedAsync(ChecklistActor actor, WorkOrder order, string reason, List events) { - if (!int.TryParse(order.WorkOrderRecurrenceId, out var id) || _maintenance == null) return; + if (!Guid.TryParseExact(order.WorkOrderRecurrenceId, "D", out _) || _maintenance == null) return; Text(reason, 4000, true); - var row = await _store.GetAsync(actor.DepartmentId, id); - if (row == null || row.PendingWorkOrderId.HasValue && row.PendingWorkOrderId != order.Id || row.Cycle != order.RecurrenceCycle) throw new WorkOrderException(409, "RecurrencePending"); + var row = await _store.GetAsync(actor.DepartmentId, order.WorkOrderRecurrenceId); + if (row == null || row.PendingWorkOrderId != null && row.PendingWorkOrderId != order.Id || row.Cycle != order.RecurrenceCycle) throw new WorkOrderException(409, "RecurrencePending"); row.IsActive = false; row.PendingWorkOrderId = order.Id; row.UpdatedOn = Now; row.Revision++; await _store.WriteAsync(row); await RecurrenceChangeAsync(actor, row, MaintenanceChangeType.Configured, reason, order.OriginalDueOn, row.NextDueOn); await RecurrenceEventAsync(row, WorkflowTriggerEventType.WorkOrderRecurrenceChanged, events); } private async Task RecurrenceCompletedAsync(WorkOrder order) { - if (!Terminal(order) || !int.TryParse(order.WorkOrderRecurrenceId, out var id) || _maintenance == null) return; - var row = await _store.GetAsync(order.DepartmentId, id); + if (!Terminal(order) || !Guid.TryParseExact(order.WorkOrderRecurrenceId, "D", out _) || _maintenance == null) return; + var row = await _store.GetAsync(order.DepartmentId, order.WorkOrderRecurrenceId); if (row?.PendingWorkOrderId != order.Id) return; if (order.Status != (int)WorkOrderStatus.Closed) { @@ -283,7 +310,7 @@ public async Task GenerateMaintenanceAsync(int depart await WorkerTransactionAsync(departmentId, async events => { var row = await _store.GetAsync(departmentId, candidate.Id); - if (row?.IsActive != true || row.PendingWorkOrderId.HasValue || !row.ReadingDue && (!row.NextDueOn.HasValue || ServiceDue(row, row.NextDueOn.Value).AddDays(-row.LeadDays) > Now)) return; + if (row?.IsActive != true || row.PendingWorkOrderId != null || !row.ReadingDue && (!row.NextDueOn.HasValue || ServiceDue(row, row.NextDueOn.Value).AddDays(-row.LeadDays) > Now)) return; if (row.EndOn.HasValue && TimeZoneInfo.ConvertTimeFromUtc(Now, MaintenanceZone(row.TimeZoneId)).Date > row.EndOn.Value.Date) return; var version = await _store.GetAsync(departmentId, row.CurrentVersionId); if (version?.RecurrenceId != row.Id) throw new WorkOrderException(409, "RecurrenceInvalid"); @@ -299,12 +326,14 @@ await WorkerTransactionAsync(departmentId, async events => if (order == null) { order = New(owner); order.RequestId = request; order.NumberYear = Now.Year; order.NumberSequence = await _store.NextNumberAsync(departmentId, order.NumberYear); + order.CurrencyCode = await DepartmentCurrencyAsync(departmentId); order.SourceType = 2; order.Type = (int)WorkOrderType.Preventive; order.Priority = row.Priority; order.TargetUnitId = row.TargetUnitId; order.TargetGroupId = row.TargetGroupId; order.InventoryAssetId = row.InventoryAssetId; - order.WorkOrderRecurrenceId = row.Id.ToString(System.Globalization.CultureInfo.InvariantCulture); order.RecurrenceVersionId = row.CurrentVersionId; order.RecurrenceCycle = row.Cycle + 1; + order.WorkOrderRecurrenceId = row.Id; order.RecurrenceVersionId = row.CurrentVersionId; order.RecurrenceCycle = row.Cycle + 1; order.DueOn = due; order.OriginalDueOn = original; order.EscalateAfterMinutes = row.EscalateAfterMinutes; order.EscalationRoleId = row.EscalationRoleId; order.AssignedToUserId = row.AssignedToUserId; order.AssignedToRoleId = row.AssignedToRoleId; - order.Status = order.AssignedToUserId != null || order.AssignedToRoleId.HasValue ? (int)WorkOrderStatus.Assigned : (int)WorkOrderStatus.Accepted; - if (order.Status == 2) { await _authorization.ValidateAssignmentAsync(owner, order, order.AssignedToUserId, order.AssignedToRoleId); order.AssignedOn = Now; } + order.AssignedToUserIds = row.AssignedToUserIds; order.AssignedToRoleIds = row.AssignedToRoleIds; + order.Status = order.AssignedToUserIds.Count != 0 || order.AssignedToRoleIds.Count != 0 ? (int)WorkOrderStatus.Assigned : (int)WorkOrderStatus.Accepted; + if (order.Status == 2) { await ValidateRecurrenceAssigneesAsync(owner, order, order.AssignedToUserIds, order.AssignedToRoleIds); order.AssignedOn = Now; } await PinSlaAsync(order); if (order.Status == (int)WorkOrderStatus.Accepted) order.ResponseOn = Now; await _store.AllocateAsync(order); await RecordGeneratedCreationAsync(order); var generated = New(owner, order.Id); generated.RecurrenceId = row.Id; generated.ChangeType = (int)MaintenanceChangeType.Generated; generated.OriginalDueOn = original; generated.RevisedDueOn = due; await _store.AllocateAsync(generated); diff --git a/Core/Resgrid.Services/WorkOrderReportDocuments.cs b/Core/Resgrid.Services/WorkOrderReportDocuments.cs index 2a798f76e..abf29dd0f 100644 --- a/Core/Resgrid.Services/WorkOrderReportDocuments.cs +++ b/Core/Resgrid.Services/WorkOrderReportDocuments.cs @@ -28,8 +28,9 @@ public static byte[] Csv(IEnumerable entries) entry.Order.CreatedOn.ToString("O"), entry.StartedOn?.ToString("O"), entry.CompletedOn?.ToString("O"), entry.RepairHours, entry.ActiveRepairHours, entry.WaitingHours, entry.DowntimeHours, entry.ResponseDueOn?.ToString("O"), entry.RepairDueOn?.ToString("O"), cost.Currency ?? Text("UnknownCurrency"), cost.Labor, cost.Parts, cost.Vendor, cost.Total, cost.UnknownLabor, cost.UnknownParts }.Select(CsvCell))); return new UTF8Encoding(true).GetPreamble().Concat(Encoding.UTF8.GetBytes(csv.ToString())).ToArray(); } - public static string PacketSection(IEnumerable entries) + public static string PacketSection(IEnumerable entries, Func format = null) { + format ??= utc => utc?.ToString("u"); string H(object value) => WebUtility.HtmlEncode(Convert.ToString(value, CultureInfo.CurrentCulture)); var html = new StringBuilder("

" + H(Text("WorkOrders")) + "

" + H(Text("WorkOrderPacketMethod")) + "

"); foreach (var key in new[] { "Number", "Title", "Status", "Priority", "Unit", "Asset", "DueOn", "Revision", "SafetyHolds" }) html.Append(""); @@ -37,7 +38,7 @@ public static string PacketSection(IEnumerable entri foreach (var item in entries) { html.Append(""); - foreach (var value in new object[] { item.WorkOrderId, item.Title, Text("Status" + (WorkOrderStatus)item.Status), Text("Priority" + (WorkOrderPriority)item.Priority), item.UnitId, item.AssetId, item.DueOn?.ToString("u"), item.Revision, string.Join(", ", item.ActiveSafetyHoldIds) }) html.Append(""); + foreach (var value in new object[] { item.WorkOrderId, item.Title, Text("Status" + (WorkOrderStatus)item.Status), Text("Priority" + (WorkOrderPriority)item.Priority), item.UnitId, item.AssetId, format(item.DueOn), item.Revision, string.Join(", ", item.ActiveSafetyHoldIds) }) html.Append(""); html.Append(""); } return html.Append("
").Append(H(Text(key))).Append("
").Append(H(value)).Append("").Append(H(value)).Append("
").ToString(); diff --git a/Core/Resgrid.Services/WorkOrderReporting.cs b/Core/Resgrid.Services/WorkOrderReporting.cs index da9eef6e5..bda5d3748 100644 --- a/Core/Resgrid.Services/WorkOrderReporting.cs +++ b/Core/Resgrid.Services/WorkOrderReporting.cs @@ -20,23 +20,23 @@ private async Task RevealReportBatchAsync(ChecklistActor actor, IReadOnlyColl var result = await _read.Value.ResolveRecordsEntitiesForReadAsync(actor.DepartmentId, rows.Select(r => (r, Key(r))).ToList(), WorkOrderTables.Fields(), actor.GrantToken, actor.UserId); if (result == null || result.RedactedFields.Count > 0 || result.IsProtected && hadPlaintext) throw new WorkOrderException(403, "ProtectedDataRequired"); } - private async Task> EvidencePageAsync(ChecklistActor actor, int id, int afterId, Func project) where T : WorkOrderRow + private async Task> EvidencePageAsync(ChecklistActor actor, string id, string afterId, Func project) where T : WorkOrderRow { - if (afterId < 0) throw new WorkOrderException(400, "InvalidInput"); + if (afterId != null && !Guid.TryParseExact(afterId, "D", out _)) throw new WorkOrderException(400, "InvalidInput"); var order = await ReadOrderAsync(actor, id); var rows = await _store.ReportChildrenAsync(actor.DepartmentId, new[] { id }, afterId, 51); var page = new WorkOrderEvidencePage { NextAfterId = rows.Count > 50 ? rows[49].Id : null }; foreach (var row in rows.Take(50)) page.Items.Add(project(await RevealAsync(actor, row))); await VerifyReportAccessAsync(actor, new[] { order }); return page; } - public Task> GetWorkOrderActivityAsync(ChecklistActor actor, int id, int afterId = 0) => EvidencePageAsync(actor, id, afterId, a => + public Task> GetWorkOrderActivityAsync(ChecklistActor actor, string id, string afterId = null) => EvidencePageAsync(actor, id, afterId, a => { var view = Decode(a.Content); view.Id = a.Id; view.Type = (WorkOrderActivityType)a.ActivityType; view.UserId = a.CreatedBy; view.CreatedOn = a.CreatedOn; view.OldStatus = a.OldStatus; view.NewStatus = a.NewStatus; return view; }); - public Task> GetWorkOrderHoldsAsync(ChecklistActor actor, int id, int afterId = 0) => EvidencePageAsync(actor, id, afterId, h => new WorkOrderHoldView { Hold = h, Content = Decode(h.Content) }); + public Task> GetWorkOrderHoldsAsync(ChecklistActor actor, string id, string afterId = null) => EvidencePageAsync(actor, id, afterId, h => new WorkOrderHoldView { Hold = h, Content = Decode(h.Content) }); private WorkOrderReportQuery ReportQuery(WorkOrderReportQuery input, bool statistics) { - if (input == null || input.AfterId < 0 || input.UnitId <= 0 || input.GroupId <= 0 || input.FromUtc?.Kind == DateTimeKind.Local || input.UntilUtc?.Kind == DateTimeKind.Local + if (input == null || (input.AfterId != null && !Guid.TryParseExact(input.AfterId, "D", out _)) || input.UnitId <= 0 || input.GroupId <= 0 || input.FromUtc?.Kind == DateTimeKind.Local || input.UntilUtc?.Kind == DateTimeKind.Local || input.Status.HasValue && !Enum.IsDefined(input.Status.Value) || input.Priority.HasValue && !Enum.IsDefined(input.Priority.Value) || input.AssetId != null && !Guid.TryParseExact(input.AssetId, "D", out _)) throw new WorkOrderException(400, "InvalidInput"); var query = new WorkOrderReportQuery { FromUtc = input.FromUtc, UntilUtc = input.UntilUtc, AfterId = input.AfterId, UnitId = input.UnitId, GroupId = input.GroupId, AssetId = input.AssetId, Status = input.Status, Priority = input.Priority }; @@ -46,16 +46,16 @@ private WorkOrderReportQuery ReportQuery(WorkOrderReportQuery input, bool statis || statistics && query.UntilUtc - query.FromUtc > TimeSpan.FromDays(366)) throw new WorkOrderException(400, "ReportRangeInvalid"); return query; } - private async Task> ReportChildrenAsync(ChecklistActor actor, int[] ids) where T : WorkOrderRow + private async Task> ReportChildrenAsync(ChecklistActor actor, string[] ids) where T : WorkOrderRow { - var result = new List(); var after = 0; + var result = new List(); string after = null; while (true) { var rows = await _store.ReportChildrenAsync(actor.DepartmentId, ids, after, 500); await RevealReportBatchAsync(actor, rows); foreach (var row in rows) { - if (!row.WorkOrderId.HasValue || !ids.Contains(row.WorkOrderId.Value)) throw new WorkOrderException(404, "Unavailable"); + if (row.WorkOrderId == null || !ids.Contains(row.WorkOrderId)) throw new WorkOrderException(404, "Unavailable"); result.Add(row); } if (result.Count > ReportLimit) throw new WorkOrderException(400, "ReportTooLarge"); @@ -83,11 +83,11 @@ private async Task> ReportEntriesAsync(ChecklistActor { if (rows.Count == 0) return new(); var ids = rows.Select(x => x.Id).ToArray(); - var labor = (await ReportChildrenAsync(actor, ids)).ToLookup(x => x.WorkOrderId.Value); - var parts = (await ReportChildrenAsync(actor, ids)).ToLookup(x => x.WorkOrderId.Value); - var vendor = (await ReportChildrenAsync(actor, ids)).ToLookup(x => x.WorkOrderId.Value); - var activity = (await ReportChildrenAsync(actor, ids)).ToLookup(x => x.WorkOrderId.Value); - var holds = (await ReportChildrenAsync(actor, ids)).ToLookup(x => x.WorkOrderId.Value); + var labor = (await ReportChildrenAsync(actor, ids)).ToLookup(x => x.WorkOrderId); + var parts = (await ReportChildrenAsync(actor, ids)).ToLookup(x => x.WorkOrderId); + var vendor = (await ReportChildrenAsync(actor, ids)).ToLookup(x => x.WorkOrderId); + var activity = (await ReportChildrenAsync(actor, ids)).ToLookup(x => x.WorkOrderId); + var holds = (await ReportChildrenAsync(actor, ids)).ToLookup(x => x.WorkOrderId); var entries = new List(); await RevealReportBatchAsync(actor, rows); foreach (var row in rows) @@ -136,7 +136,7 @@ public async Task GetWorkOrderHistoryAsync(ChecklistActor private async Task<(List Rows, List Entries)> AllReportEntriesAsync(ChecklistActor actor, WorkOrderReportQuery query) { await _authorization.RequireMemberAsync(actor); var scope = await _authorization.ScopeAsync(actor); - var all = new List(); var entries = new List(); query.AfterId = 0; + var all = new List(); var entries = new List(); query.AfterId = null; while (true) { var rows = await _store.ReportOrdersAsync(actor.DepartmentId, scope, query, 500); @@ -187,7 +187,7 @@ public async Task ReadinessEvidenceAsync(ChecklistAct var section = new ReadinessWorkOrderSection { RestrictedScope = !scope.All }; if (unitIds.Length == 0 && assetIds.Length == 0) return section; // Include older still-open maintenance as well as completed work in the packet lookback. section.HistoryUnavailable = await _store.HasUnrecoverableReportHistoryAsync(actor.DepartmentId, scope, callUtc); - var captured = new List(); var scanned = 0; var afterId = 0; + var captured = new List(); var scanned = 0; string afterId = null; while (true) { var rows = await _store.ReportPacketOrdersAsync(actor.DepartmentId, scope, callUtc, unitIds, assetIds, afterId); @@ -210,15 +210,15 @@ public async Task ReadinessEvidenceAsync(ChecklistAct if (status >= 5 && (completed ?? row.CreatedOn) < fromUtc && lastChange < fromUtc) continue; WorkOrderContent fields; if (snapshot == null) { await RevealOrderAsync(actor, row); fields = Decode(row.Content).Fields; } - else if (snapshot.SourceActivityId.HasValue) + else if (snapshot.SourceActivityId != null) { - var activity = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, snapshot.SourceActivityId.Value)); + var activity = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, snapshot.SourceActivityId)); if (activity.WorkOrderId != row.Id) throw new WorkOrderException(409, "Unavailable"); fields = JObject.Parse(activity.Content ?? "{}")["Snapshot"]?.ToObject(); } - else if (snapshot.SourceType == 2 && snapshot.RecurrenceVersionId.HasValue) + else if (snapshot.SourceType == 2 && snapshot.RecurrenceVersionId != null) { - var version = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, snapshot.RecurrenceVersionId.Value)); + var version = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, snapshot.RecurrenceVersionId)); fields = Decode(version.Content)?.Template?.Content; } else if (snapshot.SourceType == 1) { await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, row.Id)); fields = new WorkOrderContent { Title = MaintenanceText("GeneratedFailureTitle") }; } diff --git a/Core/Resgrid.Services/WorkOrderTimeAnalytics.cs b/Core/Resgrid.Services/WorkOrderTimeAnalytics.cs index d18f69cec..247a693b2 100644 --- a/Core/Resgrid.Services/WorkOrderTimeAnalytics.cs +++ b/Core/Resgrid.Services/WorkOrderTimeAnalytics.cs @@ -16,7 +16,7 @@ private async Task RecordGeneratedCreationAsync(WorkOrder row) } private static void PopulateTimeAnalytics(WorkOrderReportEntry entry, WorkOrder order, IEnumerable activities, IEnumerable holds, DateTime now) { - var events = activities.Where(a => a.CreatedOn <= now).OrderBy(a => a.CreatedOn).ThenBy(a => a.Id).ToList(); + var events = activities.Where(a => a.CreatedOn <= now).OrderBy(a => a.CreatedOn).ThenBy(a => a.Revision).ThenBy(a => a.Id).ToList(); var created = events.FirstOrDefault(a => a.ActivityType == (int)WorkOrderActivityType.Created); if (created != null && Math.Abs((created.CreatedOn - order.CreatedOn).TotalMinutes) < 1) { diff --git a/Core/Resgrid.Services/WorkOrderVendorCharges.cs b/Core/Resgrid.Services/WorkOrderVendorCharges.cs index 80387e07d..50eda3f31 100644 --- a/Core/Resgrid.Services/WorkOrderVendorCharges.cs +++ b/Core/Resgrid.Services/WorkOrderVendorCharges.cs @@ -14,26 +14,31 @@ namespace Resgrid.Services { public sealed partial class WorkOrdersService { - public async Task> VendorChargesAsync(ChecklistActor actor, int id, int afterId = 0) + public async Task> VendorChargesAsync(ChecklistActor actor, string id, string afterId = null) { var page = await EvidencePageAsync(actor, id, afterId, row => new WorkOrderVendorChargeView { Id = row.Id, VoidedOn = row.VoidedOn, Content = Decode(row.Content) }); return page.Items; } - public async Task AddVendorChargeAsync(ChecklistActor actor, int id, WorkOrderVendorChargeInput input) + public async Task AddVendorChargeAsync(ChecklistActor actor, string id, WorkOrderVendorChargeInput input) { if (input?.Content == null || !Guid.TryParseExact(input.RequestId, "D", out _)) throw new WorkOrderException(400, "InvalidInput"); var c = input.Content; Money(c.Amount); Text(c.VendorName, 200, true); Text(c.InvoiceReference, 200, true); Text(c.Description, 4000, true); // Newtonsoft binds an offset-bearing value as Local; compare and store the instant, not the wall clock. c.ServiceDate = c.ServiceDate.Kind == DateTimeKind.Local ? c.ServiceDate.ToUniversalTime() : DateTime.SpecifyKind(c.ServiceDate, DateTimeKind.Utc); - if (!ValidCurrency(c.Currency) || c.Amount <= 0 || c.ServiceDate.Year < 2000 || c.ServiceDate > Now.AddDays(1) || c.VendorId != null && !Guid.TryParseExact(c.VendorId, "D", out _)) throw new WorkOrderException(400, "InvalidInput"); - var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(new { c.VendorId, c.VendorName, c.InvoiceReference, c.Description, c.Currency, c.Amount, c.ServiceDate })))); + if (c.Amount <= 0 || c.ServiceDate.Year < 2000 || c.ServiceDate > Now.AddDays(1) || c.VendorId != null && !Guid.TryParseExact(c.VendorId, "D", out _)) throw new WorkOrderException(400, "InvalidInput"); await TransactionAsync(actor, async events => { RequireMaintenanceStore(); var row = await ReadOrderAsync(actor, id); var existing = (await _maintenance.QueryMaintenanceAsync(actor.DepartmentId, "RequestId", input.RequestId)).SingleOrDefault(); + c.Currency = Decode(row.Content).Fields.Currency; if (existing != null) { await RevealAsync(actor, existing); + c.Currency = Decode(existing.Content).Currency; + } + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(new { c.VendorId, c.VendorName, c.InvoiceReference, c.Description, c.Currency, c.Amount, c.ServiceDate })))); + if (existing != null) + { if (existing.CreatedBy != actor.UserId || existing.WorkOrderId != id || Decode(existing.Content).RequestHash != hash) throw new WorkOrderException(409, "Conflict"); return true; } @@ -50,7 +55,7 @@ await TransactionAsync(actor, async events => await ChangedAsync(actor, row, WorkOrderActivityType.Updated, events, trigger: WorkflowTriggerEventType.WorkOrderVendorChargeChanged); return true; }); } - public async Task VoidVendorChargeAsync(ChecklistActor actor, int id, int chargeId, int revision, string reason) + public async Task VoidVendorChargeAsync(ChecklistActor actor, string id, string chargeId, int revision, string reason) { Text(reason, 4000, true); await TransactionAsync(actor, async events => diff --git a/Core/Resgrid.Services/WorkOrdersService.cs b/Core/Resgrid.Services/WorkOrdersService.cs index 862d60053..d154d312a 100644 --- a/Core/Resgrid.Services/WorkOrdersService.cs +++ b/Core/Resgrid.Services/WorkOrdersService.cs @@ -44,7 +44,7 @@ public WorkOrdersService(IWorkOrderRepository store, IWorkOrderAuthorizationServ private DateTime Now => _clock.GetUtcNow().UtcDateTime; private sealed class StoredContent { public WorkOrderContent Fields { get; set; } public string RequestHash { get; set; } } private static T Decode(string value) => JsonConvert.DeserializeObject(value ?? "{}"); - private static string Key(WorkOrderRow row) => row.Id.ToString(CultureInfo.InvariantCulture); + private static string Key(WorkOrderRow row) => row.Id; private static void Revision(WorkOrderRow row, int revision) { if (row.Revision != revision) throw new WorkOrderException(409, "Conflict"); } private static void Text(string value, int limit, bool required = false) { if (value?.Length > limit || required && string.IsNullOrWhiteSpace(value)) throw new WorkOrderException(400, "InvalidInput"); } private static void Money(decimal? value) { if (value < 0 || value > 100000000m || value.HasValue && decimal.Round(value.Value, 2) != value.Value) throw new WorkOrderException(400, "InvalidInput"); } @@ -54,10 +54,10 @@ private async Task RequireWriteAsync(ChecklistActor actor) await _authorization.RequireMemberAsync(actor); if (!await _access.CanUseMaintenanceAsync(actor.DepartmentId)) throw new WorkOrderException(402, "ReadinessProRequired"); } - private async Task ReadOrderAsync(ChecklistActor actor, int id) + private async Task ReadOrderAsync(ChecklistActor actor, string id) { await _authorization.RequireMemberAsync(actor); - var row = id > 0 ? await _store.GetAsync(actor.DepartmentId, id) : null; + var row = Guid.TryParseExact(id, "D", out _) ? await _store.GetAsync(actor.DepartmentId, id) : null; if (row == null || row.IsDeleted || !(await _authorization.ScopeAsync(actor)).Allows(row)) throw new WorkOrderException(404, "Unavailable"); return await RevealOrderAsync(actor, row); } @@ -77,7 +77,7 @@ private async Task SaveAsync(ChecklistActor actor, T row, bool insert = false if (result?.Success != true || result.IsProtected && !ProtectedDataEnvelope.HasEnvelopePrefix(row.Content)) throw new WorkOrderException(403, "ProtectedDataRequired"); await _store.WriteAsync(row); } - private T New(ChecklistActor actor, int? orderId = null) where T : WorkOrderRow, new() => new T { DepartmentId = actor.DepartmentId, WorkOrderId = orderId, CreatedBy = actor.UserId, CreatedOn = Now, UpdatedOn = Now }; + private T New(ChecklistActor actor, string orderId = null) where T : WorkOrderRow, new() => new T { DepartmentId = actor.DepartmentId, WorkOrderId = orderId, CreatedBy = actor.UserId, CreatedOn = Now, UpdatedOn = Now }; private async Task TransactionAsync(ChecklistActor actor, Func, Task> command, bool safetyRelease = false) { if (safetyRelease) await _authorization.RequireMemberAsync(actor); else await RequireWriteAsync(actor); @@ -92,9 +92,9 @@ private async Task TransactionAsync(ChecklistActor actor, Func, catch { _uow.DiscardChanges(); throw; } await _outbox.DispatchAfterCommitAsync(events); return result; } - private async Task ActivityAsync(ChecklistActor actor, WorkOrder row, WorkOrderActivityType type, string note = null, int? oldStatus = null, int? newStatus = null, WorkOrderContent snapshot = null, DateTime? previousDue = null) + private async Task ActivityAsync(ChecklistActor actor, WorkOrder row, WorkOrderActivityType type, string note = null, int? oldStatus = null, int? newStatus = null, WorkOrderContent snapshot = null, DateTime? previousDue = null) { - var activity = New(actor, row.Id); activity.ActivityType = (int)type; activity.OldStatus = oldStatus; activity.NewStatus = newStatus; + var activity = New(actor, row.Id); activity.Revision = row.Revision; activity.ActivityType = (int)type; activity.OldStatus = oldStatus; activity.NewStatus = newStatus; activity.Content = JsonConvert.SerializeObject(new { Note = note, Snapshot = snapshot, row.ResponseDueOn, row.RepairDueOn, row.ResponseOn, row.SlaPolicyRevision, OriginalDueOn = previousDue, RevisedDueOn = type == WorkOrderActivityType.Deferred ? row.DueOn : null, AssignedToUserId = type == WorkOrderActivityType.Assigned ? row.AssignedToUserId : null, AssignedToRoleId = type == WorkOrderActivityType.Assigned ? row.AssignedToRoleId : null }); await SaveAsync(actor, activity, true); var audit = await _audit.InsertAsync(new AuditLog { DepartmentId = actor.DepartmentId, ObjectDepartmentId = actor.DepartmentId, UserId = actor.UserId, ObjectId = Key(row), LogType = (int)AuditLogTypes.WorkOrderChanged, LoggedOn = Now, Successful = true, Message = "WorkOrderChanged", ServerName = Environment.MachineName }, CancellationToken.None); @@ -104,7 +104,7 @@ private async Task ActivityAsync(ChecklistActor actor, WorkOrder row, WorkO await _audit.UpdateAsync(audit, CancellationToken.None); return activity.Id; } - private async Task EventAsync(WorkOrder row, WorkflowTriggerEventType? trigger, List events, int? oldStatus = null, int? activityId = null, int? approvalState = null) + private async Task EventAsync(WorkOrder row, WorkflowTriggerEventType? trigger, List events, int? oldStatus = null, string activityId = null, int? approvalState = null) { await _store.CaptureReportSnapshotAsync(row, Now, activityId); var entry = await _outbox.EnqueueAsync(row.DepartmentId, "WorkOrders", new DomainEventEnvelope { EventName = trigger?.ToString() ?? "WorkOrderUpdated", AggregateType = "WorkOrder", AggregateId = Key(row), AggregateVersion = row.Revision, @@ -124,7 +124,6 @@ private static void Validate(WorkOrderInput input) if (input?.Content == null || !Guid.TryParseExact(input.RequestId, "D", out _) || !Enum.IsDefined(input.Type) || !Enum.IsDefined(input.Priority)) throw new WorkOrderException(400, "InvalidInput"); var c = input.Content; Text(c.Title, 200, true); Text(c.Description, 20000); Text(c.LocationText, 1000); Text(c.CostCenter, 200); foreach (var v in new[] { c.VendorDetails, c.WarrantyReference, c.ProcedureReference, c.ProcedureVersion, c.PermitReference, c.IsolationReference, c.QualifiedPersonnel, c.Resolution, c.Cause, c.VerificationEvidence }) Text(v, 4000); - if (c.Currency == null || c.Currency.Length != 3 || c.Currency.Any(ch => ch < 'A' || ch > 'Z')) throw new WorkOrderException(400, "InvalidInput"); Money(c.EstimatedCost); Money(c.ApprovedCost); if (c.Steps == null || c.Steps.Count > 100 || c.Steps.Any(s => s == null || string.IsNullOrWhiteSpace(s.Text) || s.Text.Length > 1000)) throw new WorkOrderException(400, "InvalidInput"); if (input.DueOn.HasValue && (input.DueOn.Value.Year < 2000 || input.DueOn.Value.Year > 2200)) throw new WorkOrderException(400, "InvalidInput"); @@ -132,7 +131,8 @@ private static void Validate(WorkOrderInput input) } private static void Apply(WorkOrder row, WorkOrderInput input) { row.Type = (int)input.Type; row.Priority = (int)input.Priority; row.TargetUnitId = input.TargetUnitId; row.TargetGroupId = input.TargetGroupId; row.InventoryAssetId = input.InventoryAssetId; row.DueOn = input.DueOn; } private static string Fingerprint(WorkOrderInput input) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(new { input.Type, input.Priority, input.TargetUnitId, input.TargetGroupId, input.InventoryAssetId, input.DueOn, input.Content })))); - public async Task CreateAsync(ChecklistActor actor, WorkOrderInput input) + public Task CreateAsync(ChecklistActor actor, WorkOrderInput input) => CreateAsync(actor, input, null); + private async Task CreateAsync(ChecklistActor actor, WorkOrderInput input, string previewCurrency) { if (input?.Content?.Approval != null) throw new WorkOrderException(400, "InvalidInput"); Validate(input); await RequireWriteAsync(actor); await _authorization.ValidateTargetAsync(actor, input); @@ -140,15 +140,20 @@ public async Task CreateAsync(ChecklistActor actor, WorkOrderIn { await _authorization.ValidateTargetAsync(actor, input); if ((input.Content.ApprovedCost.HasValue || !string.IsNullOrEmpty(input.Content.VerificationEvidence) || !string.IsNullOrEmpty(input.Content.Resolution)) && !await _authorization.CanManageAsync(actor, input.TargetGroupId)) throw new WorkOrderException(403, "PermissionRequired"); - var hash = Fingerprint(input); var existing = await _store.RequestAsync(actor.DepartmentId, input.RequestId); + var existing = await _store.RequestAsync(actor.DepartmentId, input.RequestId); if (existing != null) { if (existing.CreatedBy != actor.UserId) throw new WorkOrderException(409, "Conflict"); existing = await RevealAsync(actor, existing); - if (Decode(existing.Content).RequestHash != hash) throw new WorkOrderException(409, "Conflict"); + var saved = Decode(existing.Content); + input.Content.Currency = saved.Fields.Currency; + if (saved.RequestHash != Fingerprint(input)) throw new WorkOrderException(409, "Conflict"); return existing.Id; } - var row = New(actor); row.RequestId = input.RequestId; row.NumberYear = Now.Year; row.NumberSequence = await _store.NextNumberAsync(actor.DepartmentId, row.NumberYear); Apply(row, input); + input.Content.Currency = await DepartmentCurrencyAsync(actor.DepartmentId); + if (previewCurrency != null && previewCurrency != input.Content.Currency) throw new WorkOrderException(409, "BulkPreviewChanged"); + var hash = Fingerprint(input); + var row = New(actor); row.CurrencyCode = input.Content.Currency; row.RequestId = input.RequestId; row.NumberYear = Now.Year; row.NumberSequence = await _store.NextNumberAsync(actor.DepartmentId, row.NumberYear); Apply(row, input); row.OriginalDueOn = row.DueOn; row.Content = JsonConvert.SerializeObject(new StoredContent { Fields = input.Content, RequestHash = hash }); if (input.Content.ApprovedCost.HasValue && (await ReadPolicyAsync(actor)).ApprovalsEnabled) throw new WorkOrderException(409, "SpendingApprovalRequired"); await PinSlaAsync(row); @@ -158,7 +163,7 @@ public async Task CreateAsync(ChecklistActor actor, WorkOrderIn }); return await GetAsync(actor, id); } - public async Task UpdateAsync(ChecklistActor actor, int id, WorkOrderInput input) + public async Task UpdateAsync(ChecklistActor actor, string id, WorkOrderInput input) { Validate(input); await TransactionAsync(actor, async events => @@ -168,16 +173,16 @@ await TransactionAsync(actor, async events => await _authorization.ValidateTargetAsync(actor, input); if (manage && !await _authorization.CanManageAsync(actor, input.TargetGroupId)) throw new WorkOrderException(403, "PermissionRequired"); var document = Decode(row.Content); + input.Content.Currency = document.Fields.Currency; if (input.DueOn != row.DueOn) throw new WorkOrderException(409, "UseDueDeferral"); if ((row.TargetUnitId != input.TargetUnitId || row.InventoryAssetId != input.InventoryAssetId) - && (row.RecurrenceVersionId.HasValue || _maintenance != null && (await ChildrenAsync(actor, id)).Any(h => !h.ReleasedOn.HasValue))) + && (row.RecurrenceVersionId != null || _maintenance != null && (await ChildrenAsync(actor, id)).Any(h => !h.ReleasedOn.HasValue))) throw new WorkOrderException(409, "TargetUnavailable"); if (row.StartedOn.HasValue && (document.Fields.SafetyCritical && !input.Content.SafetyCritical || document.Fields.HazardousWork && !input.Content.HazardousWork)) throw new WorkOrderException(409, "SafetyRequirements"); if (!manage && (input.Content.ApprovedCost.HasValue && input.Content.ApprovedCost != document.Fields.ApprovedCost || input.Content.Resolution != document.Fields.Resolution || input.Content.VerificationEvidence != document.Fields.VerificationEvidence)) throw new WorkOrderException(403, "PermissionRequired"); if (!manage) input.Content.ApprovedCost = document.Fields.ApprovedCost; input.Content.Approval = document.Fields.Approval; if ((await ReadPolicyAsync(actor)).ApprovalsEnabled && input.Content.ApprovedCost != document.Fields.ApprovedCost) throw new WorkOrderException(409, "SpendingApprovalRequired"); - if (input.Content.Currency != document.Fields.Currency) { input.Content.Approval = null; input.Content.ApprovedCost = null; } var oldPriority = row.Priority; document.Fields = input.Content; row.Content = JsonConvert.SerializeObject(document); Apply(row, input); if (oldPriority != row.Priority) @@ -195,14 +200,14 @@ await TransactionAsync(actor, async events => { WorkOrderStatus.Requested => new[] { WorkOrderStatus.Accepted, WorkOrderStatus.Rejected, WorkOrderStatus.Duplicate, WorkOrderStatus.Cancelled }, WorkOrderStatus.Accepted => new[] { WorkOrderStatus.Cancelled }, - WorkOrderStatus.Assigned => new[] { WorkOrderStatus.InProgress, WorkOrderStatus.OnHold, WorkOrderStatus.Cancelled }, + WorkOrderStatus.Assigned => new[] { WorkOrderStatus.InProgress, WorkOrderStatus.OnHold, WorkOrderStatus.Completed, WorkOrderStatus.Cancelled }, WorkOrderStatus.InProgress => new[] { WorkOrderStatus.OnHold, WorkOrderStatus.Completed, WorkOrderStatus.Cancelled }, - WorkOrderStatus.OnHold => new[] { WorkOrderStatus.InProgress, WorkOrderStatus.Cancelled }, + WorkOrderStatus.OnHold => new[] { WorkOrderStatus.InProgress, WorkOrderStatus.Completed, WorkOrderStatus.Cancelled }, WorkOrderStatus.Completed => new[] { WorkOrderStatus.Closed, WorkOrderStatus.InProgress }, WorkOrderStatus.Closed or WorkOrderStatus.Rejected or WorkOrderStatus.Duplicate or WorkOrderStatus.Cancelled => new[] { WorkOrderStatus.Accepted }, _ => Array.Empty() }; - public async Task TransitionAsync(ChecklistActor actor, int id, WorkOrderTransition input) + public async Task TransitionAsync(ChecklistActor actor, string id, WorkOrderTransition input) { if (input == null || !Enum.IsDefined(input.Status)) throw new WorkOrderException(400, "InvalidInput"); Text(input.Reason, 4000); Text(input.Resolution, 4000); Text(input.Cause, 4000); Text(input.VerificationEvidence, 4000); @@ -220,16 +225,19 @@ await TransactionAsync(actor, async events => if (input.Status is WorkOrderStatus.OnHold or WorkOrderStatus.Cancelled or WorkOrderStatus.Rejected or WorkOrderStatus.Duplicate || Terminal(row) || old == (int)WorkOrderStatus.Completed && input.Status == WorkOrderStatus.InProgress) Text(input.Reason, 4000, true); if (input.Status == WorkOrderStatus.Duplicate) { - if (!input.DuplicateOfId.HasValue || input.DuplicateOfId == id) throw new WorkOrderException(400, "InvalidInput"); - var canonical = await ReadOrderAsync(actor, input.DuplicateOfId.Value); + if (input.DuplicateOfId == null || input.DuplicateOfId == id) throw new WorkOrderException(400, "InvalidInput"); + var canonical = await ReadOrderAsync(actor, input.DuplicateOfId); if (canonical.Status == (int)WorkOrderStatus.Duplicate) throw new WorkOrderException(409, "InvalidTransition"); row.DuplicateOfId = canonical.Id; } if (input.Status == WorkOrderStatus.Accepted) { row.TriagedOn = Now; row.ClosedOn = null; row.CompletedOn = null; row.CompletedBy = null; row.VerifiedBy = null; row.DuplicateOfId = null; foreach (var step in c.Steps) step.Completed = false; row.AssignmentAcceptedOn = null; row.AssignmentAcceptedBy = null; c.Resolution = null; c.Cause = null; c.VerificationEvidence = null; } - if (input.Status == WorkOrderStatus.InProgress) + if (input.Status is WorkOrderStatus.InProgress or WorkOrderStatus.Completed) { if (!row.AssignmentAcceptedOn.HasValue) throw new WorkOrderException(409, "AcceptAssignmentFirst"); if (c.HazardousWork && new[] { c.ProcedureReference, c.ProcedureVersion, c.PermitReference, c.IsolationReference, c.QualifiedPersonnel }.Any(string.IsNullOrWhiteSpace)) throw new WorkOrderException(409, "SafetyRequirements"); + } + if (input.Status == WorkOrderStatus.InProgress) + { if (old == (int)WorkOrderStatus.Completed) { foreach (var step in c.Steps) step.Completed = false; c.Resolution = null; c.Cause = null; } row.StartedOn ??= Now; row.CompletedOn = null; row.CompletedBy = null; row.ClosedOn = null; row.VerifiedBy = null; c.VerificationEvidence = null; } @@ -255,23 +263,24 @@ await TransactionAsync(actor, async events => if (responseBreach || repairBreach) await EventAsync(row, WorkflowTriggerEventType.WorkOrderSlaBreached, events); await RecurrenceCompletedAsync(row); return true; }); } - public async Task AssignAsync(ChecklistActor actor, int id, WorkOrderAssignment input) + public async Task AssignAsync(ChecklistActor actor, string id, WorkOrderAssignment input) { if (input == null) throw new WorkOrderException(400, "InvalidInput"); await TransactionAsync(actor, events => AssignWithinAsync(actor, id, input, events)); } - private async Task AssignWithinAsync(ChecklistActor actor, int id, WorkOrderAssignment input, List events) + private async Task AssignWithinAsync(ChecklistActor actor, string id, WorkOrderAssignment input, List events) { var row = await ReadOrderAsync(actor, id); Revision(row, input.Revision); if (!await _authorization.CanManageAsync(actor, row.TargetGroupId)) throw new WorkOrderException(403, "PermissionRequired"); if (row.Status is not (1 or 2 or 3 or 4)) throw new WorkOrderException(409, "InvalidTransition"); await _authorization.ValidateAssignmentAsync(actor, row, input.UserId, input.RoleId); - if (row.AssignedToUserId == input.UserId && row.AssignedToRoleId == input.RoleId && row.AssignedOn.HasValue) return true; + if (row.AssignedToUserIds.SequenceEqual(string.IsNullOrEmpty(input.UserId) ? Array.Empty() : new[] { input.UserId }) && row.AssignedToRoleIds.SequenceEqual(input.RoleId.HasValue ? new[] { input.RoleId.Value } : Array.Empty()) && row.AssignedOn.HasValue) return true; + row.AssignedToUserIdsJson = row.AssignedToRoleIdsJson = null; var old = row.Status; row.AssignedToUserId = string.IsNullOrEmpty(input.UserId) ? null : input.UserId; row.AssignedToRoleId = input.RoleId; row.AssignedOn = Now; row.AssignmentAcceptedOn = null; row.AssignmentAcceptedBy = null; row.Status = (int)WorkOrderStatus.Assigned; await ChangedAsync(actor, row, WorkOrderActivityType.Assigned, events, previous: old, trigger: WorkflowTriggerEventType.WorkOrderAssigned); if (old != row.Status) await EventAsync(row, WorkflowTriggerEventType.WorkOrderStatusChanged, events); return true; } - public async Task AcceptAssignmentAsync(ChecklistActor actor, int id, int revision) + public async Task AcceptAssignmentAsync(ChecklistActor actor, string id, int revision) { await TransactionAsync(actor, async events => { @@ -281,18 +290,18 @@ await TransactionAsync(actor, async events => row.AssignmentAcceptedOn = Now; row.AssignmentAcceptedBy = actor.UserId; await ChangedAsync(actor, row, WorkOrderActivityType.AssignmentAccepted, events); return true; }); } - private async Task ContributionAsync(ChecklistActor actor, int id, int revision) + private async Task ContributionAsync(ChecklistActor actor, string id, int revision) { var row = await ReadOrderAsync(actor, id); Revision(row, revision); if (Terminal(row) || row.Status == (int)WorkOrderStatus.Completed || !await _authorization.CanContributeAsync(actor, row)) throw new WorkOrderException(403, "PermissionRequired"); return row; } - public async Task CommentAsync(ChecklistActor actor, int id, int revision, string note) + public async Task CommentAsync(ChecklistActor actor, string id, int revision, string note) { Text(note, 10000, true); await TransactionAsync(actor, async events => { var row = await ReadOrderAsync(actor, id); Revision(row, revision); if (Terminal(row) || row.CreatedBy != actor.UserId && !await _authorization.CanContributeAsync(actor, row)) throw new WorkOrderException(403, "PermissionRequired"); await ChangedAsync(actor, row, WorkOrderActivityType.Comment, events, note); return true; }); } - public async Task AddLaborAsync(ChecklistActor actor, int id, WorkOrderLaborInput input) + public async Task AddLaborAsync(ChecklistActor actor, string id, WorkOrderLaborInput input) { if (input?.Content == null || input.Content.Hours <= 0 || input.Content.Hours > 24 || decimal.Round(input.Content.Hours, 2) != input.Content.Hours || input.WorkDate.Year < 2000 || input.WorkDate > Now.AddDays(1)) throw new WorkOrderException(400, "InvalidInput"); Money(input.Content.RatePerHour); Text(input.Content.Note, 4000); @@ -307,9 +316,14 @@ await TransactionAsync(actor, async events => await ChangedAsync(actor, row, WorkOrderActivityType.LaborAdded, events); return true; }); } - public Task ChoicesAsync(ChecklistActor actor) => _authorization.ChoicesAsync(actor); + public async Task ChoicesAsync(ChecklistActor actor) + { + var choices = await _authorization.ChoicesAsync(actor); + choices.Currency = await DepartmentCurrencyAsync(actor.DepartmentId); + return choices; + } private static WorkOrderSummary Summary(WorkOrder row, WorkOrderContent c) => new WorkOrderSummary { Id = row.Id, Number = $"WO-{row.NumberYear}-{row.NumberSequence:D6}", Title = c.Title, Status = (WorkOrderStatus)row.Status, Priority = (WorkOrderPriority)row.Priority, - Revision = row.Revision, UpdatedOn = row.UpdatedOn, CreatedOn = row.CreatedOn, DueOn = row.DueOn, AssignedToUserId = row.AssignedToUserId, AssignedToRoleId = row.AssignedToRoleId, UnitId = row.TargetUnitId, GroupId = row.TargetGroupId, AssetId = row.InventoryAssetId }; + Revision = row.Revision, UpdatedOn = row.UpdatedOn, CreatedOn = row.CreatedOn, DueOn = row.DueOn, AssignedToUserId = row.AssignedToUserId, AssignedToRoleId = row.AssignedToRoleId, AssignedToUserIds = row.AssignedToUserIds, AssignedToRoleIds = row.AssignedToRoleIds, UnitId = row.TargetUnitId, GroupId = row.TargetGroupId, AssetId = row.InventoryAssetId }; public async Task ListAsync(ChecklistActor actor, WorkOrderFilter filter) { if (filter == null || filter.Page < 0 || filter.Page > 10000 || filter.Status.HasValue && !Enum.IsDefined(filter.Status.Value) || filter.Priority.HasValue && !Enum.IsDefined(filter.Priority.Value) || filter.AssetId != null && !Guid.TryParseExact(filter.AssetId, "D", out _) || filter.ChecklistCompletionId != null && !Guid.TryParseExact(filter.ChecklistCompletionId, "D", out _)) throw new WorkOrderException(400, "InvalidInput"); @@ -324,12 +338,12 @@ public async Task ListAsync(ChecklistActor actor, WorkOrderFilter } return page; } - private async Task> ChildrenAsync(ChecklistActor actor, int id) where T : WorkOrderRow + private async Task> ChildrenAsync(ChecklistActor actor, string id) where T : WorkOrderRow { var result = new List(); for (var skip = 0; ; skip += 500) { var page = await _store.ChildrenAsync(actor.DepartmentId, id, skip); foreach (var row in page) result.Add(await RevealAsync(actor, row)); if (page.Count < 500) return result; if (skip >= 9500) throw new WorkOrderException(400, "HistoryLimit"); } } - public async Task GetAsync(ChecklistActor actor, int id) + public async Task GetAsync(ChecklistActor actor, string id) { var row = await ReadOrderAsync(actor, id); var c = Decode(row.Content).Fields; var manage = await _authorization.CanManageAsync(actor, row.TargetGroupId); var contribute = await _authorization.CanContributeAsync(actor, row); var write = await _access.CanUseMaintenanceAsync(actor.DepartmentId); diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0197_AddWorkOrders.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0197_AddWorkOrders.cs index d112438be..66c1ff2d2 100644 --- a/Providers/Resgrid.Providers.Migrations/Migrations/M0197_AddWorkOrders.cs +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0197_AddWorkOrders.cs @@ -10,9 +10,9 @@ public override void Up() { foreach (var name in new[] { "WorkOrders", "WorkOrderActivities", "WorkOrderLabors", "WorkOrderParts", "WorkOrderFiles" }) { - var table = Create.Table(N(name)).WithColumn(N("Id")).AsInt32().PrimaryKey().Identity() + var table = Create.Table(N(name)).WithColumn(N("Id")).AsString(36).PrimaryKey() .WithColumn(N("DepartmentId")).AsInt32().NotNullable() - .WithColumn(N("WorkOrderId")).AsInt32().Nullable() + .WithColumn(N("WorkOrderId")).AsString(36).Nullable() .WithColumn(N("Content")).AsString(int.MaxValue).Nullable() .WithColumn(N("Revision")).AsInt32().NotNullable().WithDefaultValue(1) .WithColumn(N("CreatedOn")).AsDateTime2().NotNullable() @@ -32,7 +32,7 @@ public override void Up() .WithColumn(N("DueOn")).AsDateTime2().Nullable().WithColumn(N("TriagedOn")).AsDateTime2().Nullable().WithColumn(N("AssignedOn")).AsDateTime2().Nullable() .WithColumn(N("AssignmentAcceptedOn")).AsDateTime2().Nullable().WithColumn(N("AssignmentAcceptedBy")).AsString(128).Nullable() .WithColumn(N("StartedOn")).AsDateTime2().Nullable().WithColumn(N("CompletedOn")).AsDateTime2().Nullable().WithColumn(N("CompletedBy")).AsString(128).Nullable() - .WithColumn(N("ClosedOn")).AsDateTime2().Nullable().WithColumn(N("VerifiedBy")).AsString(128).Nullable().WithColumn(N("DuplicateOfId")).AsInt32().Nullable() + .WithColumn(N("ClosedOn")).AsDateTime2().Nullable().WithColumn(N("VerifiedBy")).AsString(128).Nullable().WithColumn(N("DuplicateOfId")).AsString(36).Nullable() .WithColumn(N("SetUnitOutOfService")).AsBoolean().NotNullable().WithDefaultValue(false).WithColumn(N("RestoreUnitStateOnClose")).AsBoolean().NotNullable().WithDefaultValue(false) .WithColumn(N("PreviousUnitStateType")).AsInt32().Nullable().WithColumn(N("WorkOrderRecurrenceId")).AsString(36).Nullable() .WithColumn(N("IsDeleted")).AsBoolean().NotNullable().WithDefaultValue(false); break; @@ -42,7 +42,7 @@ public override void Up() case "WorkOrderFiles": table.WithColumn(N("ContentType")).AsString(100).NotNullable().WithColumn(N("Size")).AsInt32().NotNullable().WithColumn(N("Sha256")).AsString(64).NotNullable() .WithColumn(N("Data")).AsBinary(int.MaxValue).Nullable().WithColumn(N("ScanState")).AsInt32().NotNullable().WithColumn(N("WithdrawnOn")).AsDateTime2().Nullable(); break; } - if (name != "WorkOrders") Alter.Column(N("WorkOrderId")).OnTable(N(name)).AsInt32().NotNullable(); + if (name != "WorkOrders") Alter.Column(N("WorkOrderId")).OnTable(N(name)).AsString(36).NotNullable(); Index(name, "TenantId", true, "DepartmentId", "Id"); Index(name, "ParentDate", false, "DepartmentId", "WorkOrderId", "CreatedOn"); if (name != "WorkOrders") Create.ForeignKey(N("FK_" + name + "_Order")).FromTable(N(name)).ForeignColumns(N("DepartmentId"), N("WorkOrderId")).ToTable(N("WorkOrders")).PrimaryColumns(N("DepartmentId"), N("Id")); @@ -63,7 +63,7 @@ public override void Up() .WithColumn(N("DepartmentId")).AsInt32().NotNullable().PrimaryKey() .WithColumn(N("EventId")).AsString(36).NotNullable().PrimaryKey() .WithColumn(N("UserId")).AsString(128).NotNullable().PrimaryKey() - .WithColumn(N("WorkOrderId")).AsInt32().NotNullable() + .WithColumn(N("WorkOrderId")).AsString(36).NotNullable() .WithColumn(N("State")).AsInt32().NotNullable() .WithColumn(N("LeaseOwner")).AsString(36).Nullable() .WithColumn(N("LeaseExpiresOn")).AsDateTime2().Nullable() diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0203_AddWorkOrderIntegrations.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0203_AddWorkOrderIntegrations.cs index e7ba089c0..d116dab88 100644 --- a/Providers/Resgrid.Providers.Migrations/Migrations/M0203_AddWorkOrderIntegrations.cs +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0203_AddWorkOrderIntegrations.cs @@ -8,9 +8,9 @@ public class M0203_AddWorkOrderIntegrations : Migration public override void Up() { Create.Table(N("WorkOrderFailureIntents")) - .WithColumn(N("Id")).AsInt32().PrimaryKey().Identity() + .WithColumn(N("Id")).AsString(36).PrimaryKey() .WithColumn(N("DepartmentId")).AsInt32().NotNullable() - .WithColumn(N("WorkOrderId")).AsInt32().Nullable() + .WithColumn(N("WorkOrderId")).AsString(36).Nullable() .WithColumn(N("Content")).AsString(int.MaxValue).Nullable() .WithColumn(N("Revision")).AsInt32().NotNullable().WithDefaultValue(1) .WithColumn(N("CreatedOn")).AsDateTime2().NotNullable() @@ -34,9 +34,9 @@ public override void Up() Index("WorkOrderFailureIntents", "Order", false, "DepartmentId", "WorkOrderId", "Id"); Foreign("WorkOrderFailureIntents", "Order", "WorkOrderId", "WorkOrders"); Create.Table(N("WorkOrderSafetyHolds")) - .WithColumn(N("Id")).AsInt32().PrimaryKey().Identity() + .WithColumn(N("Id")).AsString(36).PrimaryKey() .WithColumn(N("DepartmentId")).AsInt32().NotNullable() - .WithColumn(N("WorkOrderId")).AsInt32().Nullable() + .WithColumn(N("WorkOrderId")).AsString(36).Nullable() .WithColumn(N("Content")).AsString(int.MaxValue).Nullable() .WithColumn(N("Revision")).AsInt32().NotNullable().WithDefaultValue(1) .WithColumn(N("CreatedOn")).AsDateTime2().NotNullable() @@ -62,7 +62,7 @@ public override void Up() foreach (var column in new[] { "InventoryOperationId", "InventoryReversalId", "InventoryRequestId" }) Alter.Table(N("WorkOrderParts")).AddColumn(N(column)).AsString(36).Nullable(); Execute.Sql("CREATE UNIQUE INDEX UX_WorkOrderParts_InventoryRequest ON WorkOrderParts(DepartmentId,InventoryRequestId) WHERE InventoryRequestId IS NOT NULL;"); - Alter.Table(N("InventoryTransactions")).AddColumn(N("WorkOrderPartId")).AsInt32().Nullable(); + Alter.Table(N("InventoryTransactions")).AddColumn(N("WorkOrderPartId")).AsString(36).Nullable(); Foreign("InventoryTransactions", "WorkOrderPart", "WorkOrderPartId", "WorkOrderParts"); Index("InventoryTransactions", "WorkOrderPart", false, "DepartmentId", "WorkOrderPartId"); } diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0204_AddWorkOrderRecurrences.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0204_AddWorkOrderRecurrences.cs index ce822b36c..44218d5e7 100644 --- a/Providers/Resgrid.Providers.Migrations/Migrations/M0204_AddWorkOrderRecurrences.cs +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0204_AddWorkOrderRecurrences.cs @@ -7,9 +7,9 @@ public class M0204_AddWorkOrderRecurrences : Migration private static string N(string value) => value; public override void Up() { - Create.Table(N("WorkOrderRecurrences")).WithColumn(N("Id")).AsInt32().PrimaryKey().Identity() + Create.Table(N("WorkOrderRecurrences")).WithColumn(N("Id")).AsString(36).PrimaryKey() .WithColumn(N("DepartmentId")).AsInt32().NotNullable() - .WithColumn(N("WorkOrderId")).AsInt32().Nullable() + .WithColumn(N("WorkOrderId")).AsString(36).Nullable() .WithColumn(N("Content")).AsString(int.MaxValue).Nullable() .WithColumn(N("Revision")).AsInt32().NotNullable().WithDefaultValue(1) .WithColumn(N("CreatedOn")).AsDateTime2().NotNullable() @@ -17,7 +17,7 @@ public override void Up() .WithColumn(N("CreatedBy")).AsString(128).NotNullable() .WithColumn(N("IsProtected")).AsBoolean().NotNullable().WithDefaultValue(false) .WithColumn(N("RequestId")).AsString(36).NotNullable() - .WithColumn(N("CurrentVersionId")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("CurrentVersionId")).AsString(36).Nullable() .WithColumn(N("TargetUnitId")).AsInt32().Nullable() .WithColumn(N("TargetGroupId")).AsInt32().Nullable() .WithColumn(N("InventoryAssetId")).AsString(36).Nullable() @@ -39,7 +39,7 @@ public override void Up() .WithColumn(N("BlackoutUntil")).AsDateTime2().Nullable() .WithColumn(N("NextDueOn")).AsDateTime2().Nullable() .WithColumn(N("Cycle")).AsInt64().NotNullable().WithDefaultValue(0) - .WithColumn(N("PendingWorkOrderId")).AsInt32().Nullable() + .WithColumn(N("PendingWorkOrderId")).AsString(36).Nullable() .WithColumn(N("ReadingDue")).AsBoolean().NotNullable().WithDefaultValue(false) .WithColumn(N("ReadingDueOn")).AsDateTime2().Nullable() .WithColumn(N("MeterUnit")).AsInt32().NotNullable().WithDefaultValue(0) @@ -57,32 +57,32 @@ public override void Up() Index("WorkOrderRecurrences", "Tenant", true, "DepartmentId", "Id"); Index("WorkOrderRecurrences", "Order", false, "DepartmentId", "WorkOrderId", "Id"); Foreign("WorkOrderRecurrences", "Order", "WorkOrderId", "WorkOrders"); - Create.Table(N("WorkOrderRecurrenceVersions")).WithColumn(N("Id")).AsInt32().PrimaryKey().Identity() + Create.Table(N("WorkOrderRecurrenceVersions")).WithColumn(N("Id")).AsString(36).PrimaryKey() .WithColumn(N("DepartmentId")).AsInt32().NotNullable() - .WithColumn(N("WorkOrderId")).AsInt32().Nullable() + .WithColumn(N("WorkOrderId")).AsString(36).Nullable() .WithColumn(N("Content")).AsString(int.MaxValue).Nullable() .WithColumn(N("Revision")).AsInt32().NotNullable().WithDefaultValue(1) .WithColumn(N("CreatedOn")).AsDateTime2().NotNullable() .WithColumn(N("UpdatedOn")).AsDateTime2().NotNullable() .WithColumn(N("CreatedBy")).AsString(128).NotNullable() .WithColumn(N("IsProtected")).AsBoolean().NotNullable().WithDefaultValue(false) - .WithColumn(N("RecurrenceId")).AsInt32().NotNullable().WithDefaultValue(0); + .WithColumn(N("RecurrenceId")).AsString(36).NotNullable(); Create.ForeignKey(N("FK_WorkOrderRecurrenceVersions_Department")).FromTable(N("WorkOrderRecurrenceVersions")).ForeignColumn(N("DepartmentId")).ToTable(N("Departments")).PrimaryColumn(N("DepartmentId")); Index("WorkOrderRecurrenceVersions", "Tenant", true, "DepartmentId", "Id"); Index("WorkOrderRecurrenceVersions", "Order", false, "DepartmentId", "WorkOrderId", "Id"); Foreign("WorkOrderRecurrenceVersions", "Order", "WorkOrderId", "WorkOrders"); Foreign("WorkOrderRecurrenceVersions", "Recurrence", "RecurrenceId", "WorkOrderRecurrences"); Index("WorkOrderRecurrenceVersions", "Schedule", false, "DepartmentId", "RecurrenceId", "Id"); - Create.Table(N("WorkOrderMeterReadings")).WithColumn(N("Id")).AsInt32().PrimaryKey().Identity() + Create.Table(N("WorkOrderMeterReadings")).WithColumn(N("Id")).AsString(36).PrimaryKey() .WithColumn(N("DepartmentId")).AsInt32().NotNullable() - .WithColumn(N("WorkOrderId")).AsInt32().Nullable() + .WithColumn(N("WorkOrderId")).AsString(36).Nullable() .WithColumn(N("Content")).AsString(int.MaxValue).Nullable() .WithColumn(N("Revision")).AsInt32().NotNullable().WithDefaultValue(1) .WithColumn(N("CreatedOn")).AsDateTime2().NotNullable() .WithColumn(N("UpdatedOn")).AsDateTime2().NotNullable() .WithColumn(N("CreatedBy")).AsString(128).NotNullable() .WithColumn(N("IsProtected")).AsBoolean().NotNullable().WithDefaultValue(false) - .WithColumn(N("RecurrenceId")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("RecurrenceId")).AsString(36).NotNullable() .WithColumn(N("RequestId")).AsString(36).Nullable() .WithColumn(N("MeterEpoch")).AsInt32().NotNullable().WithDefaultValue(0) .WithColumn(N("ObservedOn")).AsDateTime2().NotNullable(); @@ -92,16 +92,16 @@ public override void Up() Foreign("WorkOrderMeterReadings", "Order", "WorkOrderId", "WorkOrders"); Foreign("WorkOrderMeterReadings", "Recurrence", "RecurrenceId", "WorkOrderRecurrences"); Index("WorkOrderMeterReadings", "Schedule", false, "DepartmentId", "RecurrenceId", "Id"); - Create.Table(N("WorkOrderRecurrenceChanges")).WithColumn(N("Id")).AsInt32().PrimaryKey().Identity() + Create.Table(N("WorkOrderRecurrenceChanges")).WithColumn(N("Id")).AsString(36).PrimaryKey() .WithColumn(N("DepartmentId")).AsInt32().NotNullable() - .WithColumn(N("WorkOrderId")).AsInt32().Nullable() + .WithColumn(N("WorkOrderId")).AsString(36).Nullable() .WithColumn(N("Content")).AsString(int.MaxValue).Nullable() .WithColumn(N("Revision")).AsInt32().NotNullable().WithDefaultValue(1) .WithColumn(N("CreatedOn")).AsDateTime2().NotNullable() .WithColumn(N("UpdatedOn")).AsDateTime2().NotNullable() .WithColumn(N("CreatedBy")).AsString(128).NotNullable() .WithColumn(N("IsProtected")).AsBoolean().NotNullable().WithDefaultValue(false) - .WithColumn(N("RecurrenceId")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("RecurrenceId")).AsString(36).NotNullable() .WithColumn(N("ChangeType")).AsInt32().NotNullable().WithDefaultValue(0) .WithColumn(N("OriginalDueOn")).AsDateTime2().Nullable() .WithColumn(N("RevisedDueOn")).AsDateTime2().Nullable(); @@ -114,7 +114,8 @@ public override void Up() Index("WorkOrderRecurrences", "Request", true, "DepartmentId", "RequestId"); Index("WorkOrderRecurrences", "Due", false, "DepartmentId", "IsActive", "NextDueOn"); Index("WorkOrderMeterReadings", "Request", true, "DepartmentId", "RequestId"); - foreach (var column in new[] { "RecurrenceVersionId", "EscalationRoleId" }) Alter.Table(N("WorkOrders")).AddColumn(N(column)).AsInt32().Nullable(); + Alter.Table(N("WorkOrders")).AddColumn(N("RecurrenceVersionId")).AsString(36).Nullable(); + Alter.Table(N("WorkOrders")).AddColumn(N("EscalationRoleId")).AsInt32().Nullable(); Alter.Table(N("WorkOrders")).AddColumn(N("RecurrenceCycle")).AsInt64().Nullable(); Alter.Table(N("WorkOrders")).AddColumn(N("EscalateAfterMinutes")).AsInt32().NotNullable().WithDefaultValue(0); foreach (var column in new[] { "OriginalDueOn", "EscalatedOn" }) Alter.Table(N("WorkOrders")).AddColumn(N(column)).AsDateTime2().Nullable(); diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0206_AddWorkOrderReporting.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0206_AddWorkOrderReporting.cs index 42b850cc9..ac509d2b1 100644 --- a/Providers/Resgrid.Providers.Migrations/Migrations/M0206_AddWorkOrderReporting.cs +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0206_AddWorkOrderReporting.cs @@ -9,13 +9,13 @@ public class M0206_AddWorkOrderReporting : Migration public override void Up() { Create.Table(N("WorkOrderReportSnapshots")) - .WithColumn(N("Id")).AsInt64().PrimaryKey().Identity() + .WithColumn(N("Id")).AsString(36).PrimaryKey() .WithColumn(N("DepartmentId")).AsInt32().NotNullable() - .WithColumn(N("WorkOrderId")).AsInt32().NotNullable() + .WithColumn(N("WorkOrderId")).AsString(36).NotNullable() .WithColumn(N("Revision")).AsInt32().NotNullable() .WithColumn(N("RecordedOn")).AsDateTime2().NotNullable() - .WithColumn(N("SourceActivityId")).AsInt32().Nullable() - .WithColumn(N("RecurrenceVersionId")).AsInt32().Nullable() + .WithColumn(N("SourceActivityId")).AsString(36).Nullable() + .WithColumn(N("RecurrenceVersionId")).AsString(36).Nullable() .WithColumn(N("SourceType")).AsInt32().NotNullable() .WithColumn(N("Status")).AsInt32().NotNullable() .WithColumn(N("Priority")).AsInt32().NotNullable() @@ -30,7 +30,7 @@ public override void Up() Foreign("Activity", "SourceActivityId", "WorkOrderActivities"); Foreign("Template", "RecurrenceVersionId", "WorkOrderRecurrenceVersions"); Index("WorkOrderReportSnapshots", "Revision", true, "DepartmentId", "WorkOrderId", "Revision"); - Index("WorkOrderReportSnapshots", "AsOf", false, "DepartmentId", "WorkOrderId", "RecordedOn", "Id"); + Index("WorkOrderReportSnapshots", "AsOf", false, "DepartmentId", "WorkOrderId", "RecordedOn", "Revision"); Index("WorkOrders", "ReportCreated", false, "DepartmentId", "CreatedOn", "Id"); // PostgreSQL checks this self-reference for every deleted parent; a missing child index // turns an authorized large-department purge into repeated full scans. diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0207_AddWorkOrderOperations.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0207_AddWorkOrderOperations.cs index a193b827f..752f9ca49 100644 --- a/Providers/Resgrid.Providers.Migrations/Migrations/M0207_AddWorkOrderOperations.cs +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0207_AddWorkOrderOperations.cs @@ -21,7 +21,7 @@ public override void Up() .AddColumn(N("VoidedOn")).AsDateTime2().Nullable(); Index("WorkOrderVendorCharges", "Request", true, "DepartmentId", "RequestId"); Base("WorkOrderPartMovements"); - Alter.Table(N("WorkOrderPartMovements")).AddColumn(N("PartId")).AsInt32().NotNullable() + Alter.Table(N("WorkOrderPartMovements")).AddColumn(N("PartId")).AsString(36).NotNullable() .AddColumn(N("RequestId")).AsString(36).NotNullable() .AddColumn(N("Kind")).AsInt32().NotNullable() .AddColumn(N("Quantity")).AsDecimal(24,6).NotNullable() @@ -49,16 +49,16 @@ public override void Up() Index("WorkOrders", "RepairSla", false, "DepartmentId", "RepairDueOn", "RepairBreachedOn"); if (Schema.Table(N("InventoryTransactions")).Exists()) { - Alter.Table(N("InventoryTransactions")).AddColumn(N("WorkOrderPartMovementId")).AsInt32().Nullable(); + Alter.Table(N("InventoryTransactions")).AddColumn(N("WorkOrderPartMovementId")).AsString(36).Nullable(); Foreign("InventoryTransactions", "WorkOrderMovement", "WorkOrderPartMovementId", "WorkOrderPartMovements"); Index("InventoryTransactions", "WorkOrderMovement", false, "DepartmentId", "WorkOrderPartMovementId"); } } private void Base(string table) { - Create.Table(N(table)).WithColumn(N("Id")).AsInt32().PrimaryKey().Identity() + Create.Table(N(table)).WithColumn(N("Id")).AsString(36).PrimaryKey() .WithColumn(N("DepartmentId")).AsInt32().NotNullable() - .WithColumn(N("WorkOrderId")).AsInt32().Nullable() + .WithColumn(N("WorkOrderId")).AsString(36).Nullable() .WithColumn(N("Content")).AsString(int.MaxValue).Nullable() .WithColumn(N("Revision")).AsInt32().NotNullable().WithDefaultValue(1) .WithColumn(N("CreatedOn")).AsDateTime2().NotNullable() diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0225_AddMaintenanceAssignees.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0225_AddMaintenanceAssignees.cs new file mode 100644 index 000000000..bed6351d2 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0225_AddMaintenanceAssignees.cs @@ -0,0 +1,28 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + // IDs are routing metadata, just like the existing single-user and role columns. + [Migration(225)] + public class M0225_AddMaintenanceAssignees : Migration + { + public override void Up() + { + Execute.Sql("IF COL_LENGTH('WorkOrders', 'AssignedToUserIdsJson') IS NULL ALTER TABLE [WorkOrders] ADD [AssignedToUserIdsJson] nvarchar(max) NULL;"); + Execute.Sql("IF COL_LENGTH('WorkOrders', 'AssignedToRoleIdsJson') IS NULL ALTER TABLE [WorkOrders] ADD [AssignedToRoleIdsJson] nvarchar(max) NULL;"); + Execute.Sql("IF COL_LENGTH('WorkOrderRecurrences', 'AssignedToUserIdsJson') IS NULL ALTER TABLE [WorkOrderRecurrences] ADD [AssignedToUserIdsJson] nvarchar(max) NULL;"); + Execute.Sql("IF COL_LENGTH('WorkOrderRecurrences', 'AssignedToRoleIdsJson') IS NULL ALTER TABLE [WorkOrderRecurrences] ADD [AssignedToRoleIdsJson] nvarchar(max) NULL;"); + } + + public override void Down() + { + Execute.Sql("IF EXISTS (SELECT 1 FROM [WorkOrders] WHERE COALESCE([AssignedToUserIdsJson], '[]') <> '[]' OR COALESCE([AssignedToRoleIdsJson], '[]') <> '[]') THROW 51000, 'Remove multiple-assignee routing before rolling back maintenance assignments.', 1;"); + Execute.Sql("IF EXISTS (SELECT 1 FROM [WorkOrderRecurrences] WHERE COALESCE([AssignedToUserIdsJson], '[]') <> '[]' OR COALESCE([AssignedToRoleIdsJson], '[]') <> '[]') THROW 51000, 'Remove multiple-assignee routing before rolling back maintenance assignments.', 1;"); + Delete.Column("AssignedToUserIdsJson").FromTable("WorkOrders"); + Delete.Column("AssignedToRoleIdsJson").FromTable("WorkOrders"); + Delete.Column("AssignedToUserIdsJson").FromTable("WorkOrderRecurrences"); + Delete.Column("AssignedToRoleIdsJson").FromTable("WorkOrderRecurrences"); + } + } +} + diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0226_AddWorkOrderCurrency.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0226_AddWorkOrderCurrency.cs new file mode 100644 index 000000000..d8309c63c --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0226_AddWorkOrderCurrency.cs @@ -0,0 +1,21 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(226)] + public class M0226_AddWorkOrderCurrency : Migration + { + public override void Up() + { + Execute.Sql("IF COL_LENGTH('WorkOrderPolicies', 'CurrencyCode') IS NULL ALTER TABLE [WorkOrderPolicies] ADD [CurrencyCode] nvarchar(3) NULL;"); + Execute.Sql("IF COL_LENGTH('WorkOrders', 'CurrencyCode') IS NULL ALTER TABLE [WorkOrders] ADD [CurrencyCode] nvarchar(3) NULL;"); + } + + public override void Down() + { + Execute.Sql("IF EXISTS (SELECT 1 FROM [WorkOrderPolicies] WHERE [CurrencyCode] IS NOT NULL) OR EXISTS (SELECT 1 FROM [WorkOrders] WHERE [CurrencyCode] IS NOT NULL) THROW 51000, 'Retained department currency settings and work-order snapshots prevent rollback.', 1;"); + Delete.Column("CurrencyCode").FromTable("WorkOrders"); + Delete.Column("CurrencyCode").FromTable("WorkOrderPolicies"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0197_AddWorkOrdersPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0197_AddWorkOrdersPg.cs index ae9d7ce87..6f5af8a5f 100644 --- a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0197_AddWorkOrdersPg.cs +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0197_AddWorkOrdersPg.cs @@ -10,9 +10,9 @@ public override void Up() { foreach (var name in new[] { "WorkOrders", "WorkOrderActivities", "WorkOrderLabors", "WorkOrderParts", "WorkOrderFiles" }) { - var table = Create.Table(N(name)).WithColumn(N("Id")).AsInt32().PrimaryKey().Identity() + var table = Create.Table(N(name)).WithColumn(N("Id")).AsString(36).PrimaryKey() .WithColumn(N("DepartmentId")).AsInt32().NotNullable() - .WithColumn(N("WorkOrderId")).AsInt32().Nullable() + .WithColumn(N("WorkOrderId")).AsString(36).Nullable() .WithColumn(N("Content")).AsString(int.MaxValue).Nullable() .WithColumn(N("Revision")).AsInt32().NotNullable().WithDefaultValue(1) .WithColumn(N("CreatedOn")).AsDateTime2().NotNullable() @@ -32,7 +32,7 @@ public override void Up() .WithColumn(N("DueOn")).AsDateTime2().Nullable().WithColumn(N("TriagedOn")).AsDateTime2().Nullable().WithColumn(N("AssignedOn")).AsDateTime2().Nullable() .WithColumn(N("AssignmentAcceptedOn")).AsDateTime2().Nullable().WithColumn(N("AssignmentAcceptedBy")).AsString(128).Nullable() .WithColumn(N("StartedOn")).AsDateTime2().Nullable().WithColumn(N("CompletedOn")).AsDateTime2().Nullable().WithColumn(N("CompletedBy")).AsString(128).Nullable() - .WithColumn(N("ClosedOn")).AsDateTime2().Nullable().WithColumn(N("VerifiedBy")).AsString(128).Nullable().WithColumn(N("DuplicateOfId")).AsInt32().Nullable() + .WithColumn(N("ClosedOn")).AsDateTime2().Nullable().WithColumn(N("VerifiedBy")).AsString(128).Nullable().WithColumn(N("DuplicateOfId")).AsString(36).Nullable() .WithColumn(N("SetUnitOutOfService")).AsBoolean().NotNullable().WithDefaultValue(false).WithColumn(N("RestoreUnitStateOnClose")).AsBoolean().NotNullable().WithDefaultValue(false) .WithColumn(N("PreviousUnitStateType")).AsInt32().Nullable().WithColumn(N("WorkOrderRecurrenceId")).AsString(36).Nullable() .WithColumn(N("IsDeleted")).AsBoolean().NotNullable().WithDefaultValue(false); break; @@ -42,7 +42,7 @@ public override void Up() case "WorkOrderFiles": table.WithColumn(N("ContentType")).AsString(100).NotNullable().WithColumn(N("Size")).AsInt32().NotNullable().WithColumn(N("Sha256")).AsString(64).NotNullable() .WithColumn(N("Data")).AsBinary(int.MaxValue).Nullable().WithColumn(N("ScanState")).AsInt32().NotNullable().WithColumn(N("WithdrawnOn")).AsDateTime2().Nullable(); break; } - if (name != "WorkOrders") Alter.Column(N("WorkOrderId")).OnTable(N(name)).AsInt32().NotNullable(); + if (name != "WorkOrders") Alter.Column(N("WorkOrderId")).OnTable(N(name)).AsString(36).NotNullable(); Index(name, "TenantId", true, "DepartmentId", "Id"); Index(name, "ParentDate", false, "DepartmentId", "WorkOrderId", "CreatedOn"); if (name != "WorkOrders") Create.ForeignKey(N("FK_" + name + "_Order")).FromTable(N(name)).ForeignColumns(N("DepartmentId"), N("WorkOrderId")).ToTable(N("WorkOrders")).PrimaryColumns(N("DepartmentId"), N("Id")); @@ -63,7 +63,7 @@ public override void Up() .WithColumn(N("DepartmentId")).AsInt32().NotNullable().PrimaryKey() .WithColumn(N("EventId")).AsString(36).NotNullable().PrimaryKey() .WithColumn(N("UserId")).AsString(128).NotNullable().PrimaryKey() - .WithColumn(N("WorkOrderId")).AsInt32().NotNullable() + .WithColumn(N("WorkOrderId")).AsString(36).NotNullable() .WithColumn(N("State")).AsInt32().NotNullable() .WithColumn(N("LeaseOwner")).AsString(36).Nullable() .WithColumn(N("LeaseExpiresOn")).AsDateTime2().Nullable() diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0203_AddWorkOrderIntegrationsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0203_AddWorkOrderIntegrationsPg.cs index 6851c5f80..568b4a01b 100644 --- a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0203_AddWorkOrderIntegrationsPg.cs +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0203_AddWorkOrderIntegrationsPg.cs @@ -8,9 +8,9 @@ public class M0203_AddWorkOrderIntegrationsPg : Migration public override void Up() { Create.Table(N("WorkOrderFailureIntents")) - .WithColumn(N("Id")).AsInt32().PrimaryKey().Identity() + .WithColumn(N("Id")).AsString(36).PrimaryKey() .WithColumn(N("DepartmentId")).AsInt32().NotNullable() - .WithColumn(N("WorkOrderId")).AsInt32().Nullable() + .WithColumn(N("WorkOrderId")).AsString(36).Nullable() .WithColumn(N("Content")).AsString(int.MaxValue).Nullable() .WithColumn(N("Revision")).AsInt32().NotNullable().WithDefaultValue(1) .WithColumn(N("CreatedOn")).AsDateTime().NotNullable() @@ -34,9 +34,9 @@ public override void Up() Index("WorkOrderFailureIntents", "Order", false, "DepartmentId", "WorkOrderId", "Id"); Foreign("WorkOrderFailureIntents", "Order", "WorkOrderId", "WorkOrders"); Create.Table(N("WorkOrderSafetyHolds")) - .WithColumn(N("Id")).AsInt32().PrimaryKey().Identity() + .WithColumn(N("Id")).AsString(36).PrimaryKey() .WithColumn(N("DepartmentId")).AsInt32().NotNullable() - .WithColumn(N("WorkOrderId")).AsInt32().Nullable() + .WithColumn(N("WorkOrderId")).AsString(36).Nullable() .WithColumn(N("Content")).AsString(int.MaxValue).Nullable() .WithColumn(N("Revision")).AsInt32().NotNullable().WithDefaultValue(1) .WithColumn(N("CreatedOn")).AsDateTime().NotNullable() @@ -62,7 +62,7 @@ public override void Up() foreach (var column in new[] { "InventoryOperationId", "InventoryReversalId", "InventoryRequestId" }) Alter.Table(N("WorkOrderParts")).AddColumn(N(column)).AsString(36).Nullable(); Execute.Sql("CREATE UNIQUE INDEX ux_workorderparts_inventoryrequest ON workorderparts(departmentid,inventoryrequestid) WHERE inventoryrequestid IS NOT NULL;"); - Alter.Table(N("InventoryTransactions")).AddColumn(N("WorkOrderPartId")).AsInt32().Nullable(); + Alter.Table(N("InventoryTransactions")).AddColumn(N("WorkOrderPartId")).AsString(36).Nullable(); Foreign("InventoryTransactions", "WorkOrderPart", "WorkOrderPartId", "WorkOrderParts"); Index("InventoryTransactions", "WorkOrderPart", false, "DepartmentId", "WorkOrderPartId"); } diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0204_AddWorkOrderRecurrencesPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0204_AddWorkOrderRecurrencesPg.cs index 256eb7253..5777aea25 100644 --- a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0204_AddWorkOrderRecurrencesPg.cs +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0204_AddWorkOrderRecurrencesPg.cs @@ -7,9 +7,9 @@ public class M0204_AddWorkOrderRecurrencesPg : Migration private static string N(string value) => value.ToLowerInvariant(); public override void Up() { - Create.Table(N("WorkOrderRecurrences")).WithColumn(N("Id")).AsInt32().PrimaryKey().Identity() + Create.Table(N("WorkOrderRecurrences")).WithColumn(N("Id")).AsString(36).PrimaryKey() .WithColumn(N("DepartmentId")).AsInt32().NotNullable() - .WithColumn(N("WorkOrderId")).AsInt32().Nullable() + .WithColumn(N("WorkOrderId")).AsString(36).Nullable() .WithColumn(N("Content")).AsString(int.MaxValue).Nullable() .WithColumn(N("Revision")).AsInt32().NotNullable().WithDefaultValue(1) .WithColumn(N("CreatedOn")).AsDateTime().NotNullable() @@ -17,7 +17,7 @@ public override void Up() .WithColumn(N("CreatedBy")).AsString(128).NotNullable() .WithColumn(N("IsProtected")).AsBoolean().NotNullable().WithDefaultValue(false) .WithColumn(N("RequestId")).AsString(36).NotNullable() - .WithColumn(N("CurrentVersionId")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("CurrentVersionId")).AsString(36).Nullable() .WithColumn(N("TargetUnitId")).AsInt32().Nullable() .WithColumn(N("TargetGroupId")).AsInt32().Nullable() .WithColumn(N("InventoryAssetId")).AsString(36).Nullable() @@ -39,7 +39,7 @@ public override void Up() .WithColumn(N("BlackoutUntil")).AsDateTime().Nullable() .WithColumn(N("NextDueOn")).AsDateTime().Nullable() .WithColumn(N("Cycle")).AsInt64().NotNullable().WithDefaultValue(0) - .WithColumn(N("PendingWorkOrderId")).AsInt32().Nullable() + .WithColumn(N("PendingWorkOrderId")).AsString(36).Nullable() .WithColumn(N("ReadingDue")).AsBoolean().NotNullable().WithDefaultValue(false) .WithColumn(N("ReadingDueOn")).AsDateTime2().Nullable() .WithColumn(N("MeterUnit")).AsInt32().NotNullable().WithDefaultValue(0) @@ -57,32 +57,32 @@ public override void Up() Index("WorkOrderRecurrences", "Tenant", true, "DepartmentId", "Id"); Index("WorkOrderRecurrences", "Order", false, "DepartmentId", "WorkOrderId", "Id"); Foreign("WorkOrderRecurrences", "Order", "WorkOrderId", "WorkOrders"); - Create.Table(N("WorkOrderRecurrenceVersions")).WithColumn(N("Id")).AsInt32().PrimaryKey().Identity() + Create.Table(N("WorkOrderRecurrenceVersions")).WithColumn(N("Id")).AsString(36).PrimaryKey() .WithColumn(N("DepartmentId")).AsInt32().NotNullable() - .WithColumn(N("WorkOrderId")).AsInt32().Nullable() + .WithColumn(N("WorkOrderId")).AsString(36).Nullable() .WithColumn(N("Content")).AsString(int.MaxValue).Nullable() .WithColumn(N("Revision")).AsInt32().NotNullable().WithDefaultValue(1) .WithColumn(N("CreatedOn")).AsDateTime().NotNullable() .WithColumn(N("UpdatedOn")).AsDateTime().NotNullable() .WithColumn(N("CreatedBy")).AsString(128).NotNullable() .WithColumn(N("IsProtected")).AsBoolean().NotNullable().WithDefaultValue(false) - .WithColumn(N("RecurrenceId")).AsInt32().NotNullable().WithDefaultValue(0); + .WithColumn(N("RecurrenceId")).AsString(36).NotNullable(); Create.ForeignKey(N("FK_WorkOrderRecurrenceVersions_Department")).FromTable(N("WorkOrderRecurrenceVersions")).ForeignColumn(N("DepartmentId")).ToTable(N("Departments")).PrimaryColumn(N("DepartmentId")); Index("WorkOrderRecurrenceVersions", "Tenant", true, "DepartmentId", "Id"); Index("WorkOrderRecurrenceVersions", "Order", false, "DepartmentId", "WorkOrderId", "Id"); Foreign("WorkOrderRecurrenceVersions", "Order", "WorkOrderId", "WorkOrders"); Foreign("WorkOrderRecurrenceVersions", "Recurrence", "RecurrenceId", "WorkOrderRecurrences"); Index("WorkOrderRecurrenceVersions", "Schedule", false, "DepartmentId", "RecurrenceId", "Id"); - Create.Table(N("WorkOrderMeterReadings")).WithColumn(N("Id")).AsInt32().PrimaryKey().Identity() + Create.Table(N("WorkOrderMeterReadings")).WithColumn(N("Id")).AsString(36).PrimaryKey() .WithColumn(N("DepartmentId")).AsInt32().NotNullable() - .WithColumn(N("WorkOrderId")).AsInt32().Nullable() + .WithColumn(N("WorkOrderId")).AsString(36).Nullable() .WithColumn(N("Content")).AsString(int.MaxValue).Nullable() .WithColumn(N("Revision")).AsInt32().NotNullable().WithDefaultValue(1) .WithColumn(N("CreatedOn")).AsDateTime().NotNullable() .WithColumn(N("UpdatedOn")).AsDateTime().NotNullable() .WithColumn(N("CreatedBy")).AsString(128).NotNullable() .WithColumn(N("IsProtected")).AsBoolean().NotNullable().WithDefaultValue(false) - .WithColumn(N("RecurrenceId")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("RecurrenceId")).AsString(36).NotNullable() .WithColumn(N("RequestId")).AsString(36).Nullable() .WithColumn(N("MeterEpoch")).AsInt32().NotNullable().WithDefaultValue(0) .WithColumn(N("ObservedOn")).AsDateTime().NotNullable(); @@ -92,16 +92,16 @@ public override void Up() Foreign("WorkOrderMeterReadings", "Order", "WorkOrderId", "WorkOrders"); Foreign("WorkOrderMeterReadings", "Recurrence", "RecurrenceId", "WorkOrderRecurrences"); Index("WorkOrderMeterReadings", "Schedule", false, "DepartmentId", "RecurrenceId", "Id"); - Create.Table(N("WorkOrderRecurrenceChanges")).WithColumn(N("Id")).AsInt32().PrimaryKey().Identity() + Create.Table(N("WorkOrderRecurrenceChanges")).WithColumn(N("Id")).AsString(36).PrimaryKey() .WithColumn(N("DepartmentId")).AsInt32().NotNullable() - .WithColumn(N("WorkOrderId")).AsInt32().Nullable() + .WithColumn(N("WorkOrderId")).AsString(36).Nullable() .WithColumn(N("Content")).AsString(int.MaxValue).Nullable() .WithColumn(N("Revision")).AsInt32().NotNullable().WithDefaultValue(1) .WithColumn(N("CreatedOn")).AsDateTime().NotNullable() .WithColumn(N("UpdatedOn")).AsDateTime().NotNullable() .WithColumn(N("CreatedBy")).AsString(128).NotNullable() .WithColumn(N("IsProtected")).AsBoolean().NotNullable().WithDefaultValue(false) - .WithColumn(N("RecurrenceId")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("RecurrenceId")).AsString(36).NotNullable() .WithColumn(N("ChangeType")).AsInt32().NotNullable().WithDefaultValue(0) .WithColumn(N("OriginalDueOn")).AsDateTime().Nullable() .WithColumn(N("RevisedDueOn")).AsDateTime().Nullable(); @@ -114,7 +114,8 @@ public override void Up() Index("WorkOrderRecurrences", "Request", true, "DepartmentId", "RequestId"); Index("WorkOrderRecurrences", "Due", false, "DepartmentId", "IsActive", "NextDueOn"); Index("WorkOrderMeterReadings", "Request", true, "DepartmentId", "RequestId"); - foreach (var column in new[] { "RecurrenceVersionId", "EscalationRoleId" }) Alter.Table(N("WorkOrders")).AddColumn(N(column)).AsInt32().Nullable(); + Alter.Table(N("WorkOrders")).AddColumn(N("RecurrenceVersionId")).AsString(36).Nullable(); + Alter.Table(N("WorkOrders")).AddColumn(N("EscalationRoleId")).AsInt32().Nullable(); Alter.Table(N("WorkOrders")).AddColumn(N("RecurrenceCycle")).AsInt64().Nullable(); Alter.Table(N("WorkOrders")).AddColumn(N("EscalateAfterMinutes")).AsInt32().NotNullable().WithDefaultValue(0); foreach (var column in new[] { "OriginalDueOn", "EscalatedOn" }) Alter.Table(N("WorkOrders")).AddColumn(N(column)).AsDateTime().Nullable(); diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0206_AddWorkOrderReportingPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0206_AddWorkOrderReportingPg.cs index 5a7cae53a..7cb1823a4 100644 --- a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0206_AddWorkOrderReportingPg.cs +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0206_AddWorkOrderReportingPg.cs @@ -9,13 +9,13 @@ public class M0206_AddWorkOrderReportingPg : Migration public override void Up() { Create.Table(N("WorkOrderReportSnapshots")) - .WithColumn(N("Id")).AsInt64().PrimaryKey().Identity() + .WithColumn(N("Id")).AsString(36).PrimaryKey() .WithColumn(N("DepartmentId")).AsInt32().NotNullable() - .WithColumn(N("WorkOrderId")).AsInt32().NotNullable() + .WithColumn(N("WorkOrderId")).AsString(36).NotNullable() .WithColumn(N("Revision")).AsInt32().NotNullable() .WithColumn(N("RecordedOn")).AsDateTime().NotNullable() - .WithColumn(N("SourceActivityId")).AsInt32().Nullable() - .WithColumn(N("RecurrenceVersionId")).AsInt32().Nullable() + .WithColumn(N("SourceActivityId")).AsString(36).Nullable() + .WithColumn(N("RecurrenceVersionId")).AsString(36).Nullable() .WithColumn(N("SourceType")).AsInt32().NotNullable() .WithColumn(N("Status")).AsInt32().NotNullable() .WithColumn(N("Priority")).AsInt32().NotNullable() @@ -30,7 +30,7 @@ public override void Up() Foreign("Activity", "SourceActivityId", "WorkOrderActivities"); Foreign("Template", "RecurrenceVersionId", "WorkOrderRecurrenceVersions"); Index("WorkOrderReportSnapshots", "Revision", true, "DepartmentId", "WorkOrderId", "Revision"); - Index("WorkOrderReportSnapshots", "AsOf", false, "DepartmentId", "WorkOrderId", "RecordedOn", "Id"); + Index("WorkOrderReportSnapshots", "AsOf", false, "DepartmentId", "WorkOrderId", "RecordedOn", "Revision"); Index("WorkOrders", "ReportCreated", false, "DepartmentId", "CreatedOn", "Id"); // PostgreSQL checks this self-reference for every deleted parent; a missing child index // turns an authorized large-department purge into repeated full scans. diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0207_AddWorkOrderOperationsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0207_AddWorkOrderOperationsPg.cs index 9e21c4ba5..0fc7e94af 100644 --- a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0207_AddWorkOrderOperationsPg.cs +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0207_AddWorkOrderOperationsPg.cs @@ -21,7 +21,7 @@ public override void Up() .AddColumn(N("VoidedOn")).AsDateTime2().Nullable(); Index("WorkOrderVendorCharges", "Request", true, "DepartmentId", "RequestId"); Base("WorkOrderPartMovements"); - Alter.Table(N("WorkOrderPartMovements")).AddColumn(N("PartId")).AsInt32().NotNullable() + Alter.Table(N("WorkOrderPartMovements")).AddColumn(N("PartId")).AsString(36).NotNullable() .AddColumn(N("RequestId")).AsString(36).NotNullable() .AddColumn(N("Kind")).AsInt32().NotNullable() .AddColumn(N("Quantity")).AsDecimal(24,6).NotNullable() @@ -49,16 +49,16 @@ public override void Up() Index("WorkOrders", "RepairSla", false, "DepartmentId", "RepairDueOn", "RepairBreachedOn"); if (Schema.Table(N("InventoryTransactions")).Exists()) { - Alter.Table(N("InventoryTransactions")).AddColumn(N("WorkOrderPartMovementId")).AsInt32().Nullable(); + Alter.Table(N("InventoryTransactions")).AddColumn(N("WorkOrderPartMovementId")).AsString(36).Nullable(); Foreign("InventoryTransactions", "WorkOrderMovement", "WorkOrderPartMovementId", "WorkOrderPartMovements"); Index("InventoryTransactions", "WorkOrderMovement", false, "DepartmentId", "WorkOrderPartMovementId"); } } private void Base(string table) { - Create.Table(N(table)).WithColumn(N("Id")).AsInt32().PrimaryKey().Identity() + Create.Table(N(table)).WithColumn(N("Id")).AsString(36).PrimaryKey() .WithColumn(N("DepartmentId")).AsInt32().NotNullable() - .WithColumn(N("WorkOrderId")).AsInt32().Nullable() + .WithColumn(N("WorkOrderId")).AsString(36).Nullable() .WithColumn(N("Content")).AsString(int.MaxValue).Nullable() .WithColumn(N("Revision")).AsInt32().NotNullable().WithDefaultValue(1) .WithColumn(N("CreatedOn")).AsDateTime2().NotNullable() diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0225_AddMaintenanceAssigneesPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0225_AddMaintenanceAssigneesPg.cs new file mode 100644 index 000000000..ea252e264 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0225_AddMaintenanceAssigneesPg.cs @@ -0,0 +1,28 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + // IDs are routing metadata, just like the existing single-user and role columns. + [Migration(225)] + public class M0225_AddMaintenanceAssigneesPg : Migration + { + public override void Up() + { + Execute.Sql("ALTER TABLE workorders ADD COLUMN IF NOT EXISTS assignedtouseridsjson text NULL;"); + Execute.Sql("ALTER TABLE workorders ADD COLUMN IF NOT EXISTS assignedtoroleidsjson text NULL;"); + Execute.Sql("ALTER TABLE workorderrecurrences ADD COLUMN IF NOT EXISTS assignedtouseridsjson text NULL;"); + Execute.Sql("ALTER TABLE workorderrecurrences ADD COLUMN IF NOT EXISTS assignedtoroleidsjson text NULL;"); + } + + public override void Down() + { + Execute.Sql("DO $$ BEGIN IF EXISTS (SELECT 1 FROM workorders WHERE COALESCE(assignedtouseridsjson, '[]') <> '[]' OR COALESCE(assignedtoroleidsjson, '[]') <> '[]') THEN RAISE EXCEPTION 'Remove multiple-assignee routing before rolling back maintenance assignments.'; END IF; END $$;"); + Execute.Sql("DO $$ BEGIN IF EXISTS (SELECT 1 FROM workorderrecurrences WHERE COALESCE(assignedtouseridsjson, '[]') <> '[]' OR COALESCE(assignedtoroleidsjson, '[]') <> '[]') THEN RAISE EXCEPTION 'Remove multiple-assignee routing before rolling back maintenance assignments.'; END IF; END $$;"); + Delete.Column("assignedtouseridsjson").FromTable("workorders"); + Delete.Column("assignedtoroleidsjson").FromTable("workorders"); + Delete.Column("assignedtouseridsjson").FromTable("workorderrecurrences"); + Delete.Column("assignedtoroleidsjson").FromTable("workorderrecurrences"); + } + } +} + diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0226_AddWorkOrderCurrencyPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0226_AddWorkOrderCurrencyPg.cs new file mode 100644 index 000000000..6afab3c93 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0226_AddWorkOrderCurrencyPg.cs @@ -0,0 +1,21 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(226)] + public class M0226_AddWorkOrderCurrencyPg : Migration + { + public override void Up() + { + Execute.Sql("ALTER TABLE workorderpolicies ADD COLUMN IF NOT EXISTS currencycode varchar(3) NULL;"); + Execute.Sql("ALTER TABLE workorders ADD COLUMN IF NOT EXISTS currencycode varchar(3) NULL;"); + } + + public override void Down() + { + Execute.Sql("DO $$ BEGIN IF EXISTS (SELECT 1 FROM workorderpolicies WHERE currencycode IS NOT NULL) OR EXISTS (SELECT 1 FROM workorders WHERE currencycode IS NOT NULL) THEN RAISE EXCEPTION 'Retained department currency settings and work-order snapshots prevent rollback.'; END IF; END $$;"); + Delete.Column("currencycode").FromTable("workorders"); + Delete.Column("currencycode").FromTable("workorderpolicies"); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/WorkOrderMaintenanceRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/WorkOrderMaintenanceRepository.cs index 68ae843e3..0ca1bade4 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/WorkOrderMaintenanceRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/WorkOrderMaintenanceRepository.cs @@ -16,7 +16,7 @@ public async Task> QueryMaintenanceAsync(int departmentId, string fie if (field != null) where += $" AND {Col(field)}={P}Value"; if (pendingOnly) where += typeof(T) == typeof(WorkOrderFailureIntent) ? $" AND {Col("ProcessedOn")} IS NULL" : typeof(T) == typeof(WorkOrderRecurrence) ? $" AND {Col("IsActive")}={Bool(true)}" : throw new ArgumentException("Invalid maintenance queue."); - return (await QueryAsync($"SELECT {Cols(Columns(false))} FROM {Tbl(Table())} WHERE {where} ORDER BY {Col("Id")} {Paging()}", new { DepartmentId = departmentId, Value = value, Skip = skip, Take = 500 }, default)).ToList(); + return (await QueryAsync($"SELECT {Cols(Columns(false))} FROM {Tbl(Table())} WHERE {where} ORDER BY {Col("CreatedOn")},{Col("Id")} {Paging()}", new { DepartmentId = departmentId, Value = value, Skip = skip, Take = 500 }, default)).ToList(); } public async Task> MaintenanceDepartmentsAsync(int afterDepartmentId) => (await QueryAsync($"SELECT DISTINCT {Col("DepartmentId")} FROM {Tbl("Departments")} WHERE {Col("DepartmentId")}>{P}After ORDER BY {Col("DepartmentId")} {Paging()}", new { After = afterDepartmentId, Skip = 0, Take = 200 }, default)).ToList(); public Task FailureAsync(int departmentId, string completionId, string itemId) => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("WorkOrderFailureIntents")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("CompletionId")}={P}CompletionId AND {Col("ItemId")}={P}ItemId", new { DepartmentId = departmentId, CompletionId = completionId, ItemId = itemId }, default); diff --git a/Repositories/Resgrid.Repositories.DataRepository/WorkOrderReportingRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/WorkOrderReportingRepository.cs index 462e6e17b..e28789846 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/WorkOrderReportingRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/WorkOrderReportingRepository.cs @@ -9,19 +9,26 @@ namespace Resgrid.Repositories.DataRepository { public sealed partial class WorkOrderRepository { + // GUIDs are opaque cursors. Resolve their immutable sort keys inside the same tenant. + private string AfterCursor(string table, params string[] columns) + { + var later = columns.Select((column, index) => "(" + string.Join(" AND ", columns.Take(index).Select(c => $"page.{Col(c)}=anchor.{Col(c)}").Append($"page.{Col(column)}>anchor.{Col(column)}")) + ")"); + return $"({P}AfterId IS NULL OR EXISTS (SELECT 1 FROM {Tbl(table)} anchor WHERE anchor.{Col("DepartmentId")}={P}DepartmentId AND anchor.{Col("Id")}={P}AfterId AND ({string.Join(" OR ", later)})))"; + } + private static string[] EvidenceOrder() where T : WorkOrderRow => typeof(T) == typeof(WorkOrderActivity) ? new[] { "CreatedOn", "Revision", "Id" } : new[] { "CreatedOn", "Id" }; private string ReportScope(WorkOrderReadScope scope, DynamicParameters parameters) { if (scope == null || string.IsNullOrWhiteSpace(scope.UserId)) throw new ArgumentException("Invalid work-order scope."); parameters.Add("UserId", scope.UserId); parameters.Add("AllowedGroup", scope.GroupId); parameters.Add("Roles", InListValue(scope.RoleIds == null || scope.RoleIds.Length == 0 ? new[] { -1 } : scope.RoleIds)); - return scope.All ? "1=1" : $"({Col("CreatedBy")}={P}UserId OR {Col("AssignedToUserId")}={P}UserId OR {InList("AssignedToRoleId", "Roles")} OR {Col("TargetGroupId")}={P}AllowedGroup)"; + return scope.All ? "1=1" : $"({Col("CreatedBy")}={P}UserId OR {AssignmentScope()} OR {Col("TargetGroupId")}={P}AllowedGroup)"; } - public async Task> ReportOrdersAsync(int departmentId, WorkOrderReadScope scope, WorkOrderReportQuery query, int take, int[] ids = null) + public async Task> ReportOrdersAsync(int departmentId, WorkOrderReadScope scope, WorkOrderReportQuery query, int take, string[] ids = null) { - if (query == null || query.AfterId < 0 || take < 1 || take > 500) throw new ArgumentException("Invalid report page."); + if (query == null || (query.AfterId != null && !Guid.TryParseExact(query.AfterId, "D", out _)) || take < 1 || take > 500) throw new ArgumentException("Invalid report page."); if (ids?.Length > 500 || ids?.Length == 0) throw new ArgumentException("Invalid report identities."); var parameters = new DynamicParameters(new { DepartmentId = departmentId, query.FromUtc, query.UntilUtc, query.AfterId, Status = (int?)query.Status, Priority = (int?)query.Priority, query.UnitId, query.GroupId, query.AssetId, Skip = 0, Take = take }); - var conditions = new List { $"{Col("DepartmentId")}={P}DepartmentId", $"{Col("IsDeleted")}={(IsPostgres ? "false" : "0")}", $"{Col("Id")}>{P}AfterId", ReportScope(scope, parameters) }; + var conditions = new List { $"{Col("DepartmentId")}={P}DepartmentId", $"{Col("IsDeleted")}={(IsPostgres ? "false" : "0")}", AfterCursor("WorkOrders", "CreatedOn", "Id"), ReportScope(scope, parameters) }; if (ids != null) { parameters.Add("Ids", InListValue(ids)); conditions.Add(InList("Id", "Ids")); } var cohort = query.PreventiveDueCohort ? $"COALESCE({Col("OriginalDueOn")},{Col("DueOn")})" : Col("CreatedOn"); if (query.PreventiveDueCohort) conditions.Add($"{Col("Type")}=1"); @@ -29,16 +36,16 @@ public async Task> ReportOrdersAsync(int departmentId, WorkOrder if (query.UntilUtc.HasValue) conditions.Add($"{cohort}<{P}UntilUtc"); foreach (var item in new[] { (query.Status.HasValue, "Status", "Status"), (query.Priority.HasValue, "Priority", "Priority"), (query.UnitId.HasValue, "TargetUnitId", "UnitId"), (query.GroupId.HasValue, "TargetGroupId", "GroupId"), (query.AssetId != null, "InventoryAssetId", "AssetId") }) if (item.Item1) conditions.Add(Col(item.Item2) + "=" + P + item.Item3); - return (await QueryAsync($"SELECT {Cols(Columns())} FROM {Tbl("WorkOrders")} WHERE {string.Join(" AND ", conditions)} ORDER BY {Col("Id")} {Paging()}", parameters, default)).ToList(); + return (await QueryAsync($"SELECT {Cols(Columns())} FROM {Tbl("WorkOrders")} page WHERE {string.Join(" AND ", conditions)} ORDER BY {Cols("CreatedOn", "Id")} {Paging()}", parameters, default)).ToList(); } - public async Task> ReportChildrenAsync(int departmentId, int[] orderIds, int afterId, int take) where T : WorkOrderRow + public async Task> ReportChildrenAsync(int departmentId, string[] orderIds, string afterId, int take) where T : WorkOrderRow { - if (orderIds == null || orderIds.Length == 0 || orderIds.Length > 500 || afterId < 0 || take < 1 || take > 500 || typeof(T) == typeof(WorkOrder)) throw new ArgumentException("Invalid report child page."); + if (orderIds == null || orderIds.Length == 0 || orderIds.Length > 500 || (afterId != null && !Guid.TryParseExact(afterId, "D", out _)) || take < 1 || take > 500 || typeof(T) == typeof(WorkOrder)) throw new ArgumentException("Invalid report child page."); var parameters = new DynamicParameters(new { DepartmentId = departmentId, AfterId = afterId, Skip = 0, Take = take }); parameters.Add("OrderIds", InListValue(orderIds)); - return (await QueryAsync($"SELECT {Cols(Columns(false))} FROM {Tbl(Table())} WHERE {Col("DepartmentId")}={P}DepartmentId AND {InList("WorkOrderId", "OrderIds")} AND {Col("Id")}>{P}AfterId ORDER BY {Col("Id")} {Paging()}", parameters, default)).ToList(); + return (await QueryAsync($"SELECT {Cols(Columns(false))} FROM {Tbl(Table())} page WHERE {Col("DepartmentId")}={P}DepartmentId AND {InList("WorkOrderId", "OrderIds")} AND {AfterCursor(Table(), EvidenceOrder())} ORDER BY {Cols(EvidenceOrder())} {Paging()}", parameters, default)).ToList(); } - public async Task CaptureReportSnapshotAsync(WorkOrder row, DateTime recordedOn, int? activityId) + public async Task CaptureReportSnapshotAsync(WorkOrder row, DateTime recordedOn, string activityId) { Transaction(); var snapshots = await ReportSnapshotsAsync(row.DepartmentId, new[] { row.Id }, DateTime.MaxValue); @@ -47,34 +54,34 @@ public async Task CaptureReportSnapshotAsync(WorkOrder row, DateTime recordedOn, // Background updates inherit a protected immutable source reference, never its plaintext. var snapshot = new WorkOrderReportSnapshot { DepartmentId = row.DepartmentId, WorkOrderId = row.Id, Revision = row.Revision, RecordedOn = recordedOn, SourceActivityId = activityId ?? previous?.SourceActivityId, RecurrenceVersionId = row.RecurrenceVersionId, - SourceType = activityId.HasValue ? row.SourceType : previous?.SourceType ?? (row.Content == null ? row.SourceType : 0), + SourceType = activityId != null ? row.SourceType : previous?.SourceType ?? (row.Content == null ? row.SourceType : 0), Status = row.Status, Priority = row.Priority, TargetUnitId = row.TargetUnitId, TargetGroupId = row.TargetGroupId, InventoryAssetId = row.InventoryAssetId, DueOn = row.DueOn, StartedOn = row.StartedOn, CompletedOn = row.CompletedOn, ClosedOn = row.ClosedOn, ResponseDueOn = row.ResponseDueOn, RepairDueOn = row.RepairDueOn, ResponseOn = row.ResponseOn, ResponseBreachedOn = row.ResponseBreachedOn, RepairBreachedOn = row.RepairBreachedOn, SlaPolicyRevision = row.SlaPolicyRevision }; - var columns = typeof(WorkOrderReportSnapshot).GetProperties().Select(p => p.Name).Where(n => n != "Id").ToArray(); + var columns = typeof(WorkOrderReportSnapshot).GetProperties().Select(p => p.Name).ToArray(); await ExecuteAsync($"INSERT INTO {Tbl("WorkOrderReportSnapshots")} ({Cols(columns)}) VALUES ({string.Join(",", columns.Select(c => P + c))})", snapshot, default); } - public async Task> ReportSnapshotsAsync(int departmentId, int[] orderIds, DateTime asOf) + public async Task> ReportSnapshotsAsync(int departmentId, string[] orderIds, DateTime asOf) { if (orderIds == null || orderIds.Length == 0 || orderIds.Length > 500) throw new ArgumentException("Invalid snapshot page."); var parameters = new DynamicParameters(new { DepartmentId = departmentId, AsOf = asOf }); parameters.Add("OrderIds", InListValue(orderIds)); var columns = typeof(WorkOrderReportSnapshot).GetProperties().Select(p => p.Name).ToArray(); - return (await QueryAsync($"SELECT {Cols(columns)} FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY {Col("WorkOrderId")} ORDER BY {Col("RecordedOn")} DESC,{Col("Id")} DESC) AS rn FROM {Tbl("WorkOrderReportSnapshots")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {InList("WorkOrderId", "OrderIds")} AND {Col("RecordedOn")}<={P}AsOf) s WHERE rn=1", parameters, default)).ToList(); + return (await QueryAsync($"SELECT {Cols(columns)} FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY {Col("WorkOrderId")} ORDER BY {Col("RecordedOn")} DESC,{Col("Revision")} DESC) AS rn FROM {Tbl("WorkOrderReportSnapshots")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {InList("WorkOrderId", "OrderIds")} AND {Col("RecordedOn")}<={P}AsOf) s WHERE rn=1", parameters, default)).ToList(); } - public async Task> ReportSnapshotHistoryAsync(int departmentId, int orderId, long afterId) + public async Task> ReportSnapshotHistoryAsync(int departmentId, string orderId, string afterId) { - if (orderId <= 0 || afterId < 0) throw new ArgumentException("Invalid snapshot history page."); - return (await QueryAsync($"SELECT * FROM {Tbl("WorkOrderReportSnapshots")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("WorkOrderId")}={P}OrderId AND {Col("Id")}>{P}AfterId ORDER BY {Col("Id")} {Paging()}", new { DepartmentId = departmentId, OrderId = orderId, AfterId = afterId, Skip = 0, Take = 500 }, default)).ToList(); + if (!Guid.TryParseExact(orderId, "D", out _) || (afterId != null && !Guid.TryParseExact(afterId, "D", out _))) throw new ArgumentException("Invalid snapshot history page."); + return (await QueryAsync($"SELECT * FROM {Tbl("WorkOrderReportSnapshots")} page WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("WorkOrderId")}={P}OrderId AND {AfterCursor("WorkOrderReportSnapshots", "Revision", "Id")} ORDER BY {Cols("Revision", "Id")} {Paging()}", new { DepartmentId = departmentId, OrderId = orderId, AfterId = afterId, Skip = 0, Take = 500 }, default)).ToList(); } - public async Task> ReportPacketOrdersAsync(int departmentId, WorkOrderReadScope scope, DateTime asOf, int[] unitIds, string[] assetIds, int afterId) + public async Task> ReportPacketOrdersAsync(int departmentId, WorkOrderReadScope scope, DateTime asOf, int[] unitIds, string[] assetIds, string afterId) { - if (unitIds == null || assetIds == null || unitIds.Length > 250 || assetIds.Length > 1000 || afterId < 0) throw new ArgumentException("Invalid packet target page."); + if (unitIds == null || assetIds == null || unitIds.Length > 250 || assetIds.Length > 1000 || (afterId != null && !Guid.TryParseExact(afterId, "D", out _))) throw new ArgumentException("Invalid packet target page."); if (unitIds.Length == 0 && assetIds.Length == 0) return new(); var parameters = new DynamicParameters(new { DepartmentId = departmentId, AsOf = asOf, AfterId = afterId, Skip = 0, Take = 500 }); parameters.Add("Units", InListValue(unitIds.Length == 0 ? new[] { -1 } : unitIds)); parameters.Add("Assets", InListValue(assetIds.Length == 0 ? new[] { "" } : assetIds)); var targets = $"({InList("TargetUnitId", "Units")} OR {InList("InventoryAssetId", "Assets")})"; var candidates = $"SELECT {Col("Id")} FROM {Tbl("WorkOrders")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {targets} UNION SELECT {Col("WorkOrderId")} FROM {Tbl("WorkOrderReportSnapshots")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("RecordedOn")}<={P}AsOf AND {targets}"; - return (await QueryAsync($"SELECT {Cols(Columns())} FROM {Tbl("WorkOrders")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("IsDeleted")}={(IsPostgres ? "false" : "0")} AND {Col("CreatedOn")}<={P}AsOf AND {Col("Id")}>{P}AfterId AND {Col("Id")} IN ({candidates}) AND {ReportScope(scope, parameters)} ORDER BY {Col("Id")} {Paging()}", parameters, default)).ToList(); + return (await QueryAsync($"SELECT {Cols(Columns())} FROM {Tbl("WorkOrders")} page WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("IsDeleted")}={(IsPostgres ? "false" : "0")} AND {Col("CreatedOn")}<={P}AsOf AND {AfterCursor("WorkOrders", "CreatedOn", "Id")} AND {Col("Id")} IN ({candidates}) AND {ReportScope(scope, parameters)} ORDER BY {Cols("CreatedOn", "Id")} {Paging()}", parameters, default)).ToList(); } public async Task HasUnrecoverableReportHistoryAsync(int departmentId, WorkOrderReadScope scope, DateTime asOf) { diff --git a/Repositories/Resgrid.Repositories.DataRepository/WorkOrderRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/WorkOrderRepository.cs index 4656409a0..22649edd2 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/WorkOrderRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/WorkOrderRepository.cs @@ -37,25 +37,35 @@ public async Task FinishNotificationAsync(WorkOrderNotification row, int s } public Task LockDepartmentAsync(int departmentId) => LockRecordsDepartmentAsync(departmentId, default); private void Transaction() { if (UnitOfWork.Transaction == null) throw new InvalidOperationException("Work-order writes require a transaction."); } - public Task GetAsync(int departmentId, int id, bool includeData = true) where T : WorkOrderRow => QueryFirstOrDefaultAsync($"SELECT {Cols(Columns(includeData))} FROM {Tbl(Table())} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("Id")}={P}Id", new { DepartmentId = departmentId, Id = id }, default); + public Task GetAsync(int departmentId, string id, bool includeData = true) where T : WorkOrderRow => QueryFirstOrDefaultAsync($"SELECT {Cols(Columns(includeData))} FROM {Tbl(Table())} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("Id")}={P}Id", new { DepartmentId = departmentId, Id = id }, default); public Task RequestAsync(int departmentId, string requestId) => QueryFirstOrDefaultAsync($"SELECT {Cols(Columns())} FROM {Tbl("WorkOrders")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("RequestId")}={P}RequestId", new { DepartmentId = departmentId, RequestId = requestId }, default); public async Task> ListAsync(int departmentId, WorkOrderReadScope scope, WorkOrderFilter filter) { if (scope == null || string.IsNullOrWhiteSpace(scope.UserId) || filter.Page < 0 || filter.Page > 10000) throw new ArgumentException("Invalid work-order scope."); var parameters = new DynamicParameters(new { DepartmentId = departmentId, UserId = scope.UserId, AllowedGroup = scope.GroupId, Status = (int?)filter.Status, Priority = (int?)filter.Priority, UnitId = filter.UnitId, GroupId = filter.GroupId, AssetId = filter.AssetId, ChecklistCompletionId = filter.ChecklistCompletionId, Skip = filter.Page * 50, Take = 51 }); parameters.Add("Roles", InListValue(scope.RoleIds == null || scope.RoleIds.Length == 0 ? new[] { -1 } : scope.RoleIds)); - var own = $"({Col("CreatedBy")}={P}UserId OR {Col("AssignedToUserId")}={P}UserId OR {InList("AssignedToRoleId", "Roles")} OR {Col("TargetGroupId")}={P}AllowedGroup)"; + var own = $"({Col("CreatedBy")}={P}UserId OR {AssignmentScope()} OR {Col("TargetGroupId")}={P}AllowedGroup)"; var conditions = new List { $"{Col("DepartmentId")}={P}DepartmentId", $"{Col("IsDeleted")}={(IsPostgres ? "false" : "0")}" }; if (!scope.All) conditions.Add(own); - if (filter.AssignedToMe) conditions.Add($"({Col("AssignedToUserId")}={P}UserId OR {InList("AssignedToRoleId", "Roles")})"); + if (filter.AssignedToMe) conditions.Add(AssignmentScope()); foreach (var item in new[] { (filter.Status.HasValue, "Status", "Status"), (filter.Priority.HasValue, "Priority", "Priority"), (filter.UnitId.HasValue, "TargetUnitId", "UnitId"), (filter.GroupId.HasValue, "TargetGroupId", "GroupId"), (filter.AssetId != null, "InventoryAssetId", "AssetId"), (filter.ChecklistCompletionId != null, "SourceChecklistCompletionId", "ChecklistCompletionId") }) if (item.Item1) conditions.Add(Col(item.Item2) + "=" + P + item.Item3); - return (await QueryAsync($"SELECT {Cols(Columns())} FROM {Tbl("WorkOrders")} WHERE {string.Join(" AND ", conditions)} ORDER BY {Col("Id")} DESC {Paging()}", parameters, default)).ToList(); + return (await QueryAsync($"SELECT {Cols(Columns())} FROM {Tbl("WorkOrders")} WHERE {string.Join(" AND ", conditions)} ORDER BY {Col("NumberYear")} DESC,{Col("NumberSequence")} DESC {Paging()}", parameters, default)).ToList(); } - public async Task> ChildrenAsync(int departmentId, int orderId, int skip = 0) where T : WorkOrderRow + private string AssignmentScope() + { + var users = IsPostgres + ? $"EXISTS (SELECT 1 FROM jsonb_array_elements_text(COALESCE({Col("AssignedToUserIdsJson")}, '[]')::jsonb) AS au(value) WHERE au.value={P}UserId)" + : $"EXISTS (SELECT 1 FROM OPENJSON({Col("AssignedToUserIdsJson")}) WHERE [value]={P}UserId)"; + var roles = IsPostgres + ? $"EXISTS (SELECT 1 FROM jsonb_array_elements_text(COALESCE({Col("AssignedToRoleIdsJson")}, '[]')::jsonb) AS ar(value) WHERE ar.value::integer=ANY({P}Roles))" + : $"EXISTS (SELECT 1 FROM OPENJSON({Col("AssignedToRoleIdsJson")}) WHERE TRY_CONVERT(int,[value]) IN {P}Roles)"; + return $"({Col("AssignedToUserId")}={P}UserId OR {InList("AssignedToRoleId", "Roles")} OR {users} OR {roles})"; + } + public async Task> ChildrenAsync(int departmentId, string orderId, int skip = 0) where T : WorkOrderRow { if (skip < 0 || typeof(T) == typeof(WorkOrder)) throw new ArgumentException("Invalid child page."); - return (await QueryAsync($"SELECT {Cols(Columns(false))} FROM {Tbl(Table())} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("WorkOrderId")}={P}OrderId ORDER BY {Col("Id")} {Paging()}", new { DepartmentId = departmentId, OrderId = orderId, Skip = skip, Take = 500 }, default)).ToList(); + return (await QueryAsync($"SELECT {Cols(Columns(false))} FROM {Tbl(Table())} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("WorkOrderId")}={P}OrderId ORDER BY {Cols(EvidenceOrder())} {Paging()}", new { DepartmentId = departmentId, OrderId = orderId, Skip = skip, Take = 500 }, default)).ToList(); } public Task NextNumberAsync(int departmentId, int year) { @@ -65,10 +75,11 @@ public Task NextNumberAsync(int departmentId, int year) public async Task AllocateAsync(T row) where T : WorkOrderRow { Transaction(); - if (row.Id != 0 || row.Content != null || row is WorkOrderFile file && file.Data != null) throw new InvalidOperationException("Allocate identities without sensitive content."); - var columns = Columns().Where(c => c != "Id").ToArray(); - var sql = $"INSERT INTO {Tbl(Table())} ({Cols(columns)}) {(IsPostgres ? "" : "OUTPUT INSERTED.[Id]")} VALUES ({string.Join(",", columns.Select(c => P + c))}) {(IsPostgres ? "RETURNING id" : "")}"; - row.Id = await ScalarAsync(sql, row, default); + if (row.Id != null || row.Content != null || row is WorkOrderFile file && file.Data != null) throw new InvalidOperationException("Allocate identities without sensitive content."); + row.Id = Guid.NewGuid().ToString("D"); + var columns = Columns(); + var sql = $"INSERT INTO {Tbl(Table())} ({Cols(columns)}) VALUES ({string.Join(",", columns.Select(c => P + c))})"; + await ExecuteAsync(sql, row, default); } public async Task WriteAsync(T row) where T : WorkOrderRow { diff --git a/Tests/Resgrid.Tests/Chatbot/ExternalChatbotAuthorizationTests.cs b/Tests/Resgrid.Tests/Chatbot/ExternalChatbotAuthorizationTests.cs index 54bbd7ecf..048109e38 100644 --- a/Tests/Resgrid.Tests/Chatbot/ExternalChatbotAuthorizationTests.cs +++ b/Tests/Resgrid.Tests/Chatbot/ExternalChatbotAuthorizationTests.cs @@ -25,36 +25,65 @@ public class ExternalChatbotAuthorizationTests private const int DepartmentId = 42; [Test] - public async Task CallsList_FiltersDeniedAndForeignCallsBeforeApplyingTenCallLimit() + public async Task CallsList_FiltersForeignCallsBeforeApplyingTenCallLimit_WithOneAuthorizationCheck() { - var candidates = Enumerable.Range(1, 12) - .Select(id => new Call { CallId = id, DepartmentId = DepartmentId, Name = "HiddenCall" + id }).ToList(); - candidates.Add(new Call { CallId = 900, DepartmentId = 99, Name = "ForeignCall" }); + var candidates = new List { new Call { CallId = 900, DepartmentId = 99, Name = "ForeignCall" } }; candidates.AddRange(Enumerable.Range(100, 11) .Select(id => new Call { CallId = id, DepartmentId = DepartmentId, Name = "VisibleCall" + id })); var calls = new Mock(); calls.Setup(c => c.GetActiveCallsByDepartmentAsync(DepartmentId)).ReturnsAsync(candidates); - var authorization = new Mock(); - authorization.Setup(a => a.CanUserViewCallAsync(UserId, It.IsAny())) - .ReturnsAsync((string userId, int callId) => callId >= 100); + var authorization = MemberAuthorization(); var handler = new CallsActionHandler(calls.Object, Mock.Of(), Mock.Of(), Mock.Of(), authorization.Object); var response = await handler.HandleAsync(Message(), new ChatbotIntent { Type = ChatbotIntentType.ListCalls }, Session()); response.Processed.Should().BeTrue(); - response.Text.Should().NotContain("HiddenCall").And.NotContain("ForeignCall").And.NotContain("VisibleCall110"); + response.Text.Should().NotContain("ForeignCall").And.NotContain("VisibleCall110"); foreach (var id in Enumerable.Range(100, 10)) response.Text.Should().Contain("VisibleCall" + id); - authorization.Verify(a => a.CanUserViewCallAsync(It.IsAny(), 900), Times.Never); + // One membership decision for the request; never a lookup per row. + authorization.Verify(a => a.IsUserValidWithinLimitsAsync(UserId, DepartmentId), Times.Once); + authorization.Verify(a => a.CanUserViewCallAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task CallsList_OnlyForeignRows_ReportsNoActiveCalls() + { + var calls = new Mock(); + calls.Setup(c => c.GetActiveCallsByDepartmentAsync(DepartmentId)) + .ReturnsAsync(new List { new Call { CallId = 900, DepartmentId = 99, Name = "ForeignCall" } }); + var handler = new CallsActionHandler(calls.Object, Mock.Of(), + Mock.Of(), Mock.Of(), MemberAuthorization().Object); + + var response = await handler.HandleAsync(Message(), new ChatbotIntent { Type = ChatbotIntentType.ListCalls }, Session()); + + response.Processed.Should().BeTrue(); + response.Text.Should().Contain("No active calls").And.NotContain("ForeignCall").And.NotContain("Active Calls for"); + } + + [Test] + public async Task CallsList_NonMember_IsDeniedBeforeAnyCallIsRead() + { + var calls = new Mock(); + var authorization = new Mock(); + authorization.Setup(a => a.IsUserValidWithinLimitsAsync(UserId, DepartmentId)).ReturnsAsync(false); + var handler = new CallsActionHandler(calls.Object, Mock.Of(), + Mock.Of(), Mock.Of(), authorization.Object); + + var response = await handler.HandleAsync(Message(), new ChatbotIntent { Type = ChatbotIntentType.ListCalls }, Session()); + + response.Processed.Should().BeFalse(); + response.Text.Should().Contain("permission"); + calls.Verify(c => c.GetActiveCallsByDepartmentAsync(It.IsAny()), Times.Never); } [Test] - public async Task UnitsList_FiltersDeniedAndForeignUnitsBeforeApplyingFifteenUnitLimit() + public async Task UnitsList_FiltersForeignUnitsBeforeApplyingFifteenUnitLimit_WithOneAuthorizationCheck() { var candidates = UnitCandidates(); var units = new Mock(); units.Setup(u => u.GetAllLatestStatusForUnitsByDepartmentIdAsync(DepartmentId)).ReturnsAsync(candidates); - var authorization = UnitAuthorization(); + var authorization = MemberAuthorization(); var states = new Mock(); states.Setup(s => s.GetCustomUnitStateAsync(It.IsAny())) .ReturnsAsync(new CustomStateDetail { ButtonText = "Available" }); @@ -63,18 +92,34 @@ public async Task UnitsList_FiltersDeniedAndForeignUnitsBeforeApplyingFifteenUni var response = await handler.HandleAsync(Message(), new ChatbotIntent { Type = ChatbotIntentType.ListUnits }, Session()); response.Processed.Should().BeTrue(); - response.Text.Should().NotContain("HiddenUnit").And.NotContain("ForeignUnit").And.NotContain("VisibleUnit115"); + response.Text.Should().NotContain("ForeignUnit").And.NotContain("VisibleUnit115"); foreach (var id in Enumerable.Range(100, 15)) response.Text.Should().Contain("VisibleUnit" + id); - states.Verify(s => s.GetCustomUnitStateAsync(It.Is(u => u.UnitId < 100 || u.UnitId == 900)), Times.Never); - authorization.Verify(a => a.CanUserViewUnitAsync(It.IsAny(), 900), Times.Never); + states.Verify(s => s.GetCustomUnitStateAsync(It.Is(u => u.UnitId == 900)), Times.Never); + authorization.Verify(a => a.IsUserValidWithinLimitsAsync(UserId, DepartmentId), Times.Once); + authorization.Verify(a => a.CanUserViewUnitAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task UnitsList_NonMember_IsDeniedBeforeAnyUnitIsRead() + { + var units = new Mock(); + var authorization = new Mock(); + authorization.Setup(a => a.IsUserValidWithinLimitsAsync(UserId, DepartmentId)).ReturnsAsync(false); + var handler = new UnitsActionHandler(units.Object, Mock.Of(), Mock.Of(), authorization.Object); + + var response = await handler.HandleAsync(Message(), new ChatbotIntent { Type = ChatbotIntentType.ListUnits }, Session()); + + response.Processed.Should().BeFalse(); + response.Text.Should().Contain("permission"); + units.Verify(u => u.GetAllLatestStatusForUnitsByDepartmentIdAsync(It.IsAny()), Times.Never); } [Test] - public async Task AvailableUnits_CountAndOverflowIncludeOnlyAuthorizedDepartmentUnits() + public async Task AvailableUnits_CountAndOverflowIncludeOnlyDepartmentUnits_WithOneAuthorizationCheck() { var units = new Mock(); units.Setup(u => u.GetAllLatestStatusForUnitsByDepartmentIdAsync(DepartmentId)).ReturnsAsync(UnitCandidates()); - var authorization = UnitAuthorization(); + var authorization = MemberAuthorization(); var states = new Mock(); states.Setup(s => s.GetCustomUnitStateAsync(It.IsAny())) .ReturnsAsync(new CustomStateDetail { ButtonText = "Available" }); @@ -87,12 +132,28 @@ public async Task AvailableUnits_CountAndOverflowIncludeOnlyAuthorizedDepartment response.Processed.Should().BeTrue(); response.Text.Should().Contain("Available Units (16):").And.Contain("...and 1 more.") - .And.NotContain("HiddenUnit").And.NotContain("ForeignUnit").And.NotContain("VisibleUnit115"); + .And.NotContain("ForeignUnit").And.NotContain("VisibleUnit115"); foreach (var id in Enumerable.Range(100, 15)) response.Text.Should().Contain("VisibleUnit" + id); - // The fixture uses distinct state ids so even availability classification must not touch denied units. - reporting.Verify(r => r.ClassifyUnitAvailabilityAsync(DepartmentId, It.Is(state => state < 100 || state == 900), It.IsAny()), Times.Never); + // The fixture uses distinct state ids so even availability classification must not touch the foreign unit. + reporting.Verify(r => r.ClassifyUnitAvailabilityAsync(DepartmentId, 900, It.IsAny()), Times.Never); reporting.Verify(r => r.ClassifyUnitAvailabilityAsync(DepartmentId, It.IsAny(), It.IsAny()), Times.Exactly(16)); - authorization.Verify(a => a.CanUserViewUnitAsync(It.IsAny(), 900), Times.Never); + authorization.Verify(a => a.IsUserValidWithinLimitsAsync(UserId, DepartmentId), Times.Once); + authorization.Verify(a => a.CanUserViewUnitAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task AvailableUnits_NonMember_IsDeniedBeforeAnyUnitIsRead() + { + var units = new Mock(); + var authorization = new Mock(); + authorization.Setup(a => a.IsUserValidWithinLimitsAsync(UserId, DepartmentId)).ReturnsAsync(false); + var handler = new UnitsAvailableActionHandler(units.Object, Mock.Of(), Mock.Of(), authorization.Object); + + var response = await handler.HandleAsync(Message(), new ChatbotIntent { Type = ChatbotIntentType.UnitsAvailable }, Session()); + + response.Processed.Should().BeFalse(); + response.Text.Should().Contain("permission"); + units.Verify(u => u.GetAllLatestStatusForUnitsByDepartmentIdAsync(It.IsAny()), Times.Never); } [TestCase(ChatbotPlatform.Telegram)] @@ -184,18 +245,16 @@ public async Task ExternalChannel_StillRequiresEnabledDepartmentAndAllowedPlatfo private static List UnitCandidates() { - var units = Enumerable.Range(1, 16).Select(id => State(id, DepartmentId, "HiddenUnit" + id)).ToList(); - units.Add(State(900, 99, "ForeignUnit")); + var units = new List { State(900, 99, "ForeignUnit") }; units.AddRange(Enumerable.Range(100, 16).Select(id => State(id, DepartmentId, "VisibleUnit" + id))); return units; } private static UnitState State(int id, int departmentId, string name) => new() { UnitId = id, State = id, Unit = new Unit { UnitId = id, DepartmentId = departmentId, Name = name } }; - private static Mock UnitAuthorization() + private static Mock MemberAuthorization() { var authorization = new Mock(); - authorization.Setup(a => a.CanUserViewUnitAsync(UserId, It.IsAny())) - .ReturnsAsync((string userId, int unitId) => unitId >= 100); + authorization.Setup(a => a.IsUserValidWithinLimitsAsync(UserId, DepartmentId)).ReturnsAsync(true); return authorization; } diff --git a/Tests/Resgrid.Tests/Rms/ChecklistReadinessEvidenceTests.cs b/Tests/Resgrid.Tests/Rms/ChecklistReadinessEvidenceTests.cs index a4c6de78d..c58b08804 100644 --- a/Tests/Resgrid.Tests/Rms/ChecklistReadinessEvidenceTests.cs +++ b/Tests/Resgrid.Tests/Rms/ChecklistReadinessEvidenceTests.cs @@ -24,7 +24,7 @@ public async Task Readiness_PDF_and_manifest_are_immutable_classified_evidence_a CoverageStartUtc = DateTime.UtcNow.AddDays(-30), CoverageEndUtc = DateTime.UtcNow.AddHours(-2), Checklists = new List { new() { OccurrenceId = "source-occurrence", VersionId = "pinned-version", Version = 3, Name = "Original evidence", Target = new ChecklistTarget { Id = "1", Type = ChecklistTargetType.Unit, Name = "Engine 1" } } } }; var checklists = new Mock(); checklists.Setup(c => c.GetReadinessPacketForCallAsync(It.IsAny(), 501, 30)).ReturnsAsync(() => source); - source.WorkOrders.Add(new Resgrid.Model.WorkOrders.ReadinessWorkOrderEvidence { WorkOrderId = 19, Revision = 3, SnapshotId = 29, Title = "Pinned maintenance", UnitId = 1, ActiveSafetyHoldIds = new() { 8 } }); + source.WorkOrders.Add(new Resgrid.Model.WorkOrders.ReadinessWorkOrderEvidence { WorkOrderId = "00000000-0000-0000-0000-000000000019", Revision = 3, SnapshotId = "00000000-0000-0000-0000-000000000029", Title = "Pinned maintenance", UnitId = 1, ActiveSafetyHoldIds = new() { "00000000-0000-0000-0000-000000000008" } }); var access = new Mock(); access.Setup(a => a.CanUseChecklistsAsync(Dept)).ReturnsAsync(false); var grant = Mock.Of(g => g.UserId == "author" && g.GrantToken == "synthetic-grant" && !g.IsWorkloadCaller); var pdf = new Mock(); pdf.Setup(p => p.ConvertHtmlToPdf(It.IsAny())).Returns(Encoding.ASCII.GetBytes("%PDF-1.4 synthetic evidence")); diff --git a/Tests/Resgrid.Tests/Rms/RecordEvidenceWebControllerTests.cs b/Tests/Resgrid.Tests/Rms/RecordEvidenceWebControllerTests.cs index cab8fe748..0ff614004 100644 --- a/Tests/Resgrid.Tests/Rms/RecordEvidenceWebControllerTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordEvidenceWebControllerTests.cs @@ -11,6 +11,7 @@ using Moq; using NUnit.Framework; using Resgrid.Model; +using Resgrid.Web.Helpers; using Resgrid.Model.Services; using Resgrid.Services.Records; using Resgrid.Web.Areas.User.Controllers; @@ -38,6 +39,7 @@ public void Setup() Resgrid.Web.Helpers.ClaimsAuthorizationHelper._httpContextAccessor = new HttpContextAccessor { HttpContext = http }; _controller = new RecordEvidenceController(_selection.Object, _evidence.Object, _records.Object) { ControllerContext = new ControllerContext { HttpContext = http }, TempData = new TempDataDictionary(http, Mock.Of()) }; + _controller.ViewData[nameof(DepartmentTime)] = new DepartmentTime(new Department { TimeZone = "Pacific Standard Time" }); } [TearDown] public void Cleanup() => Resgrid.Web.Helpers.ClaimsAuthorizationHelper._httpContextAccessor = null; private static RecordEvidenceForm Input() => new() { RecordId = "record", RecordKind = RmsRecordKind.Operational, @@ -71,7 +73,7 @@ public async Task Capture_uses_posted_selection_and_version_with_server_actor_te var input = Input(); input.StartUtc = new DateTime(2026, 9, 1, 8, 0, 0); input.EndUtc = input.StartUtc.Value.AddHours(1); (await _controller.Capture(input, default)).Should().BeOfType(); _evidence.Verify(e => e.CaptureAsync(It.Is(r => r.DepartmentId == 9 && r.CapturedByUserId == "officer" && r.CallId == 501 - && r.ExpectedRowVersion == 7 && r.SourceIds.Count == 2 && r.CoverageStart.Value.Kind == DateTimeKind.Utc && r.OriginClient == RmsOriginClient.Web), true, It.IsAny()), Times.Once); + && r.ExpectedRowVersion == 7 && r.SourceIds.Count == 2 && r.CoverageStart.Value.Kind == DateTimeKind.Utc && r.CoverageStart == new DateTime(2026, 9, 1, 15, 0, 0, DateTimeKind.Utc) && r.CoverageEnd == new DateTime(2026, 9, 1, 16, 0, 0, DateTimeKind.Utc) && r.OriginClient == RmsOriginClient.Web), true, It.IsAny()), Times.Once); } [Test] diff --git a/Tests/Resgrid.Tests/Rms/RecordSavedReportsServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordSavedReportsServiceTests.cs index a1a717b07..272cae7db 100644 --- a/Tests/Resgrid.Tests/Rms/RecordSavedReportsServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordSavedReportsServiceTests.cs @@ -42,6 +42,26 @@ private async Task RecordAsync(string site, string hours, string status, return draft.Record.RmsOperationalRecordId; } + [Test] + public async Task Report_timestamp_columns_use_department_time_while_calendar_fields_and_storage_keep_their_meaning() + { + _h.Departments.Setup(d => d.GetDepartmentByIdAsync(Dept, false)).ReturnsAsync(new Department { TimeZone = "Pacific Standard Time", Use24HourTime = true }); + await _h.CreateAndPublishAsync("shift-log", "Shift log", Schema(Section("shift", "Shift", Field("at", RmsFieldType.DateTime), Field("day", RmsFieldType.Date)))); + var at = new DateTime(2026, 7, 15, 2, 30, 0, DateTimeKind.Utc); + var draft = await _h.Records.CreateDraftAsync(Dept, Author, new RecordDraftInput + { + DefinitionKey = "shift-log", StartedOn = at, + Values = new() { Value("shift", "at", at.ToString("O")), Value("shift", "day", "2026-07-15") } + }); + var report = await _h.Reports.SaveAsync(Dept, Admin, Report("Department time", new RecordReportSpec + { + IncludeDrafts = true, Columns = new() { "record.started_on", "at", "day" } + })); + var result = await _h.Reports.RunAsync(Dept, Author, report.RmsSavedReportDefinitionId); + result.Rows.Single().Should().Equal("07/14/2026 19:30:00", "07/14/2026 19:30:00", "2026-07-15"); + (await _h.Records.GetAsync(Dept, draft.Record.RmsOperationalRecordId)).Record.StartedOn.Should().Be(at); + } + [Test] public async Task Validation_checks_columns_filters_group_by_and_aggregates_against_the_schema_flags() { diff --git a/Tests/Resgrid.Tests/Rms/RecordsAnalyticsReadinessTests.cs b/Tests/Resgrid.Tests/Rms/RecordsAnalyticsReadinessTests.cs index 376617b0f..3b384e230 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsAnalyticsReadinessTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsAnalyticsReadinessTests.cs @@ -71,8 +71,8 @@ public void SetUp() _workOrders.Setup(w => w.GetWorkOrderStatsAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ChecklistActor a, WorkOrderReportQuery q) => Stats(q)); _workOrders.Setup(w => w.GetWorkOrderHistoryAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ChecklistActor a, WorkOrderReportQuery q) => { - var rows = _orders.Where(o => o.Order.Id > q.AfterId).OrderBy(o => o.Order.Id).ToList(); - return new WorkOrderHistoryPage { Items = rows.Take(50).ToList(), NextAfterId = rows.Count > 50 ? rows[49].Order.Id : (int?)null }; + var rows = _orders.Where(o => string.CompareOrdinal(o.Order.Id, q.AfterId) > 0).OrderBy(o => o.Order.Id).ToList(); + return new WorkOrderHistoryPage { Items = rows.Take(50).ToList(), NextAfterId = rows.Count > 50 ? rows[49].Order.Id : null }; }); _inventory.Setup(i => i.ListAsync(It.IsAny(), It.IsAny())).ReturnsAsync((InventoryActor a, int page) => Page(_assets, page)); _inventory.Setup(i => i.ListAsync(It.IsAny(), It.IsAny())).ReturnsAsync((InventoryActor a, int page) => Page(_locations, page)); @@ -104,7 +104,7 @@ private static ChecklistComplianceGroup Group(ChecklistTargetType type, string i => new ChecklistComplianceGroup { Target = new ChecklistTarget { Type = type, Id = id, Name = name, GroupId = groupId }, Expected = expected, Completed = completed, OnTime = onTime, Missed = missed, Skipped = skipped }; private void Order(int id, int? unitId, WorkOrderStatus status, WorkOrderPriority priority = WorkOrderPriority.Normal, DateTime? due = null) - => _orders.Add(new WorkOrderReportEntry { Order = new WorkOrderSummary { Id = id, UnitId = unitId, Status = status, Priority = priority, DueOn = due, CreatedOn = T0 } }); + => _orders.Add(new WorkOrderReportEntry { Order = new WorkOrderSummary { Id = new Guid(id, 0, 0, new byte[8]).ToString("D"), UnitId = unitId, Status = status, Priority = priority, DueOn = due, CreatedOn = T0 } }); private void Record(string id, int? callId, int unitId) { @@ -121,6 +121,7 @@ public async Task Readiness_joins_checklists_work_orders_equipment_and_evidence_ Group(ChecklistTargetType.Group, "10", "Station 10", 4, 4, 4, 0), Group(ChecklistTargetType.Personnel, "u-a", "Ann Author", 3, 2, 2, 1), Group(ChecklistTargetType.InventoryAsset, "asset-1", "SCBA 12", 2, 2, 2, 0) }); _summary.Trend.Add(new ChecklistMissedTrend { DayUtc = T0, Expected = 5, Missed = 1 }); + _summary.Entries.AddRange(Enumerable.Range(0, 5).Select(i => new ChecklistReportEntry { DueUtc = T0, Expected = true, Missed = i == 0 })); _summary.UnavailableSources.Add("contractor-deployments"); Order(1, 1, WorkOrderStatus.InProgress, WorkOrderPriority.High, DateTime.UtcNow.AddDays(-2)); Order(2, 1, WorkOrderStatus.Closed); Order(3, 2, WorkOrderStatus.OnHold, WorkOrderPriority.Emergency); Order(4, null, WorkOrderStatus.Requested); Order(5, 3, WorkOrderStatus.Completed); _locations.Add(new InventoryLocation { Id = "loc-e1", DepartmentId = Dept, UnitId = 1 }); _locations.Add(new InventoryLocation { Id = "loc-bay", DepartmentId = Dept, ParentLocationId = "loc-e1" }); _locations.Add(new InventoryLocation { Id = "loc-store", DepartmentId = Dept, GroupId = 10 }); diff --git a/Tests/Resgrid.Tests/Rms/RmsDefinitionHarness.cs b/Tests/Resgrid.Tests/Rms/RmsDefinitionHarness.cs index ce33ee1a7..1f50aa7e1 100644 --- a/Tests/Resgrid.Tests/Rms/RmsDefinitionHarness.cs +++ b/Tests/Resgrid.Tests/Rms/RmsDefinitionHarness.cs @@ -101,7 +101,7 @@ public RmsDefinitionHarness() Store.AttachmentsRepo.Object, Store.RevisionsRepo.Object, Evidence.Object, Store.ScopesRepo.Object, Store.SharesRepo.Object, Store.ProjectionsRepo.Object, Store.AuditsRepo.Object, Outbox, Cutover.Object, Settings.Object, Groups.Object, Profiles.Object, Units.Object, Calls.Object, Adp.Object, Store.UnitOfWork.Object, OutboundQueue.Object, new NullRecordAttachmentScanner(), Authorization.Object, Mock.Of(), Protection, Definitions, TypedValues, Roles.Object); - Reports = new RecordSavedReportsService(Defs.ReportsRepo.Object, Definitions, Records, Store.RecordsRepo.Object, Defs.ValuesRepo.Object, Defs.GroupsRepo.Object, Authorization.Object, Store.AuditsRepo.Object); + Reports = new RecordSavedReportsService(Defs.ReportsRepo.Object, Definitions, Records, Store.RecordsRepo.Object, Defs.ValuesRepo.Object, Defs.GroupsRepo.Object, Authorization.Object, Store.AuditsRepo.Object, Departments.Object); Deployments = new RecordDeploymentsService(Defs.OrdersRepo.Object, Defs.FillsRepo.Object, Defs.ReferencesRepo.Object, Records, Store.RecordsRepo.Object, Definitions, Templates, Authorization.Object, Store.AuditsRepo.Object, Store.UnitOfWork.Object); Connectors = new Resgrid.Services.Records.Connectors.RecordDeploymentConnectorsService(Defs.ConnectorsRepo.Object, Defs.ConnectorRunsRepo.Object, Defs.OrdersRepo.Object, Defs.FillsRepo.Object, Deployments, Authorization.Object, Store.AuditsRepo.Object, new IExternalOrderFeedProvider[] { Feed, new FakeOrderFeedProvider(RmsExternalOrderConnectorProviders.Iroc, "iroc", RmsDeploymentProfiles.UsWildland, new Resgrid.Services.Records.Connectors.IrocOrderFeedProvider().ValidateOrder, Feed) }); diff --git a/Tests/Resgrid.Tests/Search/BusinessOperationsProjectionTests.cs b/Tests/Resgrid.Tests/Search/BusinessOperationsProjectionTests.cs index cd55de291..5f355f57c 100644 --- a/Tests/Resgrid.Tests/Search/BusinessOperationsProjectionTests.cs +++ b/Tests/Resgrid.Tests/Search/BusinessOperationsProjectionTests.cs @@ -38,9 +38,13 @@ public void SetUp() var protection = new Mock(); protection.Setup(p => p.IsProtectionEnforcedAsync(It.IsAny())).ReturnsAsync(false); protection.Setup(p => p.GetPinnedCatalogVersionAsync(It.IsAny())).ReturnsAsync(28); - _service = new SearchProjectionService(_repository.Object, protection.Object); + _personnel = new Mock(); + _personnel.Setup(p => p.GetByDeploymentAsync(It.IsAny())).ReturnsAsync(Enumerable.Empty()); + _service = new SearchProjectionService(_repository.Object, protection.Object, _personnel.Object); } + private Mock _personnel; + [Test] public async Task Invoice_projects_number_status_and_dates_but_never_amounts_lines_or_emails() { @@ -77,10 +81,16 @@ public async Task Bid_contract_deployment_rate_card_and_certification_type_proje contract.IsActive.Should().BeTrue(); string.Join(" ", contract.Summary, contract.Keywords, contract.MetadataJson).Should().NotContain("ap@county"); + // The roster (any personnel row, removed or not — the deployment page's access rule) is the participant set the index scopes on. + _personnel.Setup(p => p.GetByDeploymentAsync("dep-1")).ReturnsAsync(new List + { + new DeploymentPersonnel { UserId = "u-1" }, new DeploymentPersonnel { UserId = "u-2", RemovedOn = new DateTime(2026, 8, 3) }, new DeploymentPersonnel { UserId = "u-1" } + }); var deployment = await _service.BuildDeploymentAsync(new Deployment { DeploymentId = "dep-1", DepartmentId = 5, Name = "LNU Lightning Complex", Status = (int)DeploymentStatuses.Active, IncidentNumber = "CA-LNU-001234", ResourceOrderNumber = "O-1", RequestNumber = "E-12", StartOn = new DateTime(2026, 8, 1), Notes = "rgdp:1:28:protected-envelope", CallId = 99 }); deployment.Title.Should().Be("LNU Lightning Complex"); deployment.Keywords.Should().Contain("O-1").And.Contain("E-12").And.Contain("99"); deployment.IsActive.Should().BeTrue(); + deployment.ParticipantUserIds.Should().Be("u-1,u-2"); string.Join(" ", deployment.Summary, deployment.Keywords, deployment.SearchText, deployment.MetadataJson).Should().NotContain("rgdp", "the wrapper's notes are ADP catalog 27 and never leave the row"); var card = await _service.BuildRateCardAsync(new RateCard { RateCardId = "rc-1", DepartmentId = 5, Name = "Standard 2026", IsDefault = true, Active = true }); diff --git a/Tests/Resgrid.Tests/Search/GlobalSearchTests.cs b/Tests/Resgrid.Tests/Search/GlobalSearchTests.cs index 3e1c9e7ed..4b65c7d51 100644 --- a/Tests/Resgrid.Tests/Search/GlobalSearchTests.cs +++ b/Tests/Resgrid.Tests/Search/GlobalSearchTests.cs @@ -109,6 +109,31 @@ public async Task Messages_are_visible_only_to_sender_or_recipient() stranger.Total.Should().Be(0, "counts must not disclose messages the viewer cannot open"); } + [Test] + public async Task Viewer_scoped_families_return_only_the_viewers_own_rows_and_messages_stay_scoped() + { + // Two deployments: u1 is rostered on the first only. The roster is the projection's participant set. + await _indexer.IndexAsync(new[] + { + Projection(1, SearchEntityTypes.Deployment, "dep-1", "Ridge Complex strike team", keywords: "CA-LNU-001234", participants: "u1,u4", occurred: new DateTime(2026, 1, 10)), + Projection(1, SearchEntityTypes.Deployment, "dep-2", "Ridge Complex water tender", keywords: "CA-LNU-001235", participants: "u4", occurred: new DateTime(2026, 1, 11)) + }, "1.25.3"); + await _indexer.CommitAsync(); + var scoped = new List { SearchEntityTypes.Deployment }; + + var rostered = await _search.SearchAsync(1, new GlobalSearchQuery { Text = "ridge complex", ViewerUserId = "u1", ViewerScopedEntityTypes = scoped }); + var stranger = await _search.SearchAsync(1, new GlobalSearchQuery { Text = "ridge complex", ViewerUserId = "u9", ViewerScopedEntityTypes = scoped }); + var claimHolder = await _search.SearchAsync(1, new GlobalSearchQuery { Text = "ridge complex", ViewerUserId = "u9" }); + var messageStillScoped = await _search.SearchAsync(1, new GlobalSearchQuery { Text = "shift swap", ViewerUserId = "u9", ViewerScopedEntityTypes = scoped }); + + rostered.Hits.Select(h => h.EntityId).Should().Equal("dep-1"); + rostered.Total.Should().Be(1, "the index counts only the rows the scope admits"); + stranger.Hits.Should().BeEmpty(); + stranger.Total.Should().Be(0); + claimHolder.Hits.Select(h => h.EntityId).Should().BeEquivalentTo(new[] { "dep-1", "dep-2" }, "without the scope the family is department-wide, as for a claim holder"); + messageStillScoped.Hits.Should().BeEmpty("naming extra families never un-scopes messages"); + } + [Test] public async Task Admin_only_rows_are_hidden_unless_the_viewer_is_an_admin() { diff --git a/Tests/Resgrid.Tests/Search/UnifiedSearchBusinessOperationsTests.cs b/Tests/Resgrid.Tests/Search/UnifiedSearchBusinessOperationsTests.cs index c7e7c5135..172b702eb 100644 --- a/Tests/Resgrid.Tests/Search/UnifiedSearchBusinessOperationsTests.cs +++ b/Tests/Resgrid.Tests/Search/UnifiedSearchBusinessOperationsTests.cs @@ -53,6 +53,8 @@ public async Task A_rostered_member_without_the_claim_finds_only_the_deployments var result = await _service.SearchAsync(new UnifiedSearchRequest { Text = "ridge" }, Principal()); _lastQuery.EntityTypes.Should().Equal(new[] { SearchEntityTypes.Deployment }); + _lastQuery.ViewerScopedEntityTypes.Should().Equal(new[] { SearchEntityTypes.Deployment }, + "without the claim the index is asked for the caller's own deployments only, so unrostered ones never fill the candidate window"); asked.Should().BeEquivalentTo(new[] { "d1", "d2", "d3" }, "one header read covers the whole window"); result.Hits.Select(h => h.EntityId).Should().Equal(new[] { "d1" }, "the member is rostered on d1 only; d3 is deleted"); result.Total.Should().BeNull("a dropped candidate suppresses the total"); @@ -71,6 +73,7 @@ public async Task A_claim_holder_authorizes_deployments_from_the_batch_without_a var result = await _service.SearchAsync(new UnifiedSearchRequest { Text = "ridge" }, Principal("Deployments:View")); result.Hits.Select(h => h.EntityId).Should().Equal(new[] { "d1" }, "d2 belongs to another department"); + _lastQuery.ViewerScopedEntityTypes.Should().BeNull("a claim holder searches the whole family"); _deployments.Verify(d => d.GetDeploymentsForUserAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); _deployments.Verify(d => d.GetDeploymentByIdAsync(It.IsAny(), It.IsAny()), Times.Never, "no per-hit aggregate load"); } diff --git a/Tests/Resgrid.Tests/Services/CalOesMarsServiceTests.cs b/Tests/Resgrid.Tests/Services/CalOesMarsServiceTests.cs index 8bf1cbbc2..ed6c10fd6 100644 --- a/Tests/Resgrid.Tests/Services/CalOesMarsServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CalOesMarsServiceTests.cs @@ -333,6 +333,29 @@ public async Task Closed_work_items_still_protect_their_agreement_snapshot() (await _service.SelectAgreementAsync(DeptId, null, Dispatch.AddDays(10))).CalOesMarsAgreementSnapshotId.Should().Be(amended.CalOesMarsAgreementSnapshotId); } + [Test] + public async Task An_open_ended_referenced_agreement_revised_without_a_start_is_effective_today_and_keeps_the_prior_for_earlier_dates() + { + var openEnded = await _service.SaveAgreementAsync(new CalOesMarsAgreementSnapshot { DepartmentId = DeptId, DocumentKind = (int)CalOesMarsDocumentKinds.Mou, CompensationMethod = (int)CalOesMarsCompensationMethods.ActualHours, OvertimeMethod = (int)CalOesMarsOvertimeMethods.AfterEightHoursPerDay }, User, null, null); + openEnded.StartOn.Should().BeNull("an ordinary agreement keeps the open-ended start it was given"); + _items.Add(new CalOesMarsWorkItem { CalOesMarsWorkItemId = "wi-open", DepartmentId = DeptId, DeploymentId = "dep-1", RecordType = (int)CalOesMarsRecordTypes.F42, AgreementSnapshotId = openEnded.CalOesMarsAgreementSnapshotId, LocalState = (int)CalOesMarsLocalStates.Closed, MarsRecordId = "F42-1", AddedOn = Dispatch }); + + var today = DateTime.UtcNow.Date; + var revision = await _service.SaveAgreementAsync(new CalOesMarsAgreementSnapshot { CalOesMarsAgreementSnapshotId = openEnded.CalOesMarsAgreementSnapshotId, DepartmentId = DeptId, DocumentKind = openEnded.DocumentKind, CompensationMethod = openEnded.CompensationMethod, OvertimeMethod = (int)CalOesMarsOvertimeMethods.AfterTwelveHoursPerDay }, User, null, null); + + revision.CalOesMarsAgreementSnapshotId.Should().NotBe(openEnded.CalOesMarsAgreementSnapshotId); + revision.StartOn.Should().Be(today, "a referenced revision without a submitted start is effective today, matching the day the prior snapshot closes"); + (await _service.GetAgreementAsync(openEnded.CalOesMarsAgreementSnapshotId, DeptId)).EndOn.Should().Be(today.AddDays(-1)); + // Yesterday's dispatch still selects the prior terms; today's selects the revision. + (await _service.SelectAgreementAsync(DeptId, null, today.AddDays(-1))).CalOesMarsAgreementSnapshotId.Should().Be(openEnded.CalOesMarsAgreementSnapshotId); + (await _service.SelectAgreementAsync(DeptId, null, today)).CalOesMarsAgreementSnapshotId.Should().Be(revision.CalOesMarsAgreementSnapshotId); + + // An end before the effective start is rejected rather than saved as an empty range. + _items.Add(new CalOesMarsWorkItem { CalOesMarsWorkItemId = "wi-rev", DepartmentId = DeptId, DeploymentId = "dep-1", RecordType = (int)CalOesMarsRecordTypes.F42, AgreementSnapshotId = revision.CalOesMarsAgreementSnapshotId, LocalState = (int)CalOesMarsLocalStates.Closed, MarsRecordId = "F42-2", AddedOn = Dispatch }); + await FluentActions.Awaiting(() => _service.SaveAgreementAsync(new CalOesMarsAgreementSnapshot { CalOesMarsAgreementSnapshotId = revision.CalOesMarsAgreementSnapshotId, DepartmentId = DeptId, DocumentKind = revision.DocumentKind, CompensationMethod = revision.CompensationMethod, OvertimeMethod = revision.OvertimeMethod, EndOn = today.AddDays(-2) }, User, null, null)) + .Should().ThrowAsync().WithMessage("calmars_dates_invalid"); + } + [Test] public async Task Redispatch_closes_the_first_interval_and_opens_a_superseding_f42() { diff --git a/Tests/Resgrid.Tests/Services/ChecklistP2M4Tests.cs b/Tests/Resgrid.Tests/Services/ChecklistP2M4Tests.cs index 70fffefd5..f333b3d72 100644 --- a/Tests/Resgrid.Tests/Services/ChecklistP2M4Tests.cs +++ b/Tests/Resgrid.Tests/Services/ChecklistP2M4Tests.cs @@ -22,7 +22,7 @@ public async Task P2M4_packet_emits_provenance_and_rechecks_work_order_disclosur { await SeedReportMonth(); var reports = new Mock(); - var section = new ReadinessWorkOrderSection { HistoryUnavailable = true, RestrictedScope = true, Items = { new() { WorkOrderId = 19, Revision = 3, SnapshotId = 99, UnitId = 1, Title = "Synthetic immutable maintenance", SourceActivityId = 27 } } }; + var section = new ReadinessWorkOrderSection { HistoryUnavailable = true, RestrictedScope = true, Items = { new() { WorkOrderId = "00000000-0000-0000-0000-000000000019", Revision = 3, SnapshotId = "00000000-0000-0000-0000-000000000099", UnitId = 1, Title = "Synthetic immutable maintenance", SourceActivityId = "00000000-0000-0000-0000-000000000027" } } }; reports.Setup(r => r.ReadinessEvidenceAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(section); PacketService(null, workOrders: reports.Object); _access.Setup(a => a.CanUseChecklistsAsync(77)).ReturnsAsync(false); var packet = await _service.GetReadinessPacketForCallAsync(_actor, 101); diff --git a/Tests/Resgrid.Tests/Services/ChecklistPageAcceptanceTests.cs b/Tests/Resgrid.Tests/Services/ChecklistPageAcceptanceTests.cs index b2fd477df..203d6188e 100644 --- a/Tests/Resgrid.Tests/Services/ChecklistPageAcceptanceTests.cs +++ b/Tests/Resgrid.Tests/Services/ChecklistPageAcceptanceTests.cs @@ -66,6 +66,7 @@ public async Task Authenticated_pages_enforce_permission_antiforgery_localizatio builder.Services.AddControllersWithViews(o => o.Filters.Add(new PageBodyFilter())).AddApplicationPart(typeof(ChecklistsController).Assembly); builder.Services.AddSingleton(_service as IChecklistsService); builder.Services.AddSingleton(_access.Object); builder.Services.AddSingleton(Mock.Of()); builder.Services.AddSingleton(Mock.Of()); builder.Services.AddSingleton(Mock.Of()); + builder.Services.AddSingleton(Mock.Of(d => d.GetDepartmentByIdAsync(77, false) == Task.FromResult(new Department { DepartmentId = 77, TimeZone = "Pacific Standard Time", Use24HourTime = true }))); await using var app = builder.Build(); var previous = ClaimsAuthorizationHelper._httpContextAccessor; ClaimsAuthorizationHelper._httpContextAccessor = app.Services.GetRequiredService(); app.Use(async (context, next) => { try { await next(); } catch (Exception ex) { context.Response.StatusCode = 500; await context.Response.WriteAsync(ex.ToString()); } }); diff --git a/Tests/Resgrid.Tests/Services/ChecklistPr504BoundaryTests.cs b/Tests/Resgrid.Tests/Services/ChecklistPr504BoundaryTests.cs index 642907107..d015c73bb 100644 --- a/Tests/Resgrid.Tests/Services/ChecklistPr504BoundaryTests.cs +++ b/Tests/Resgrid.Tests/Services/ChecklistPr504BoundaryTests.cs @@ -100,6 +100,7 @@ public async Task Schedule_page_encodes_untrusted_names_timezones_and_all_route_ builder.Services.AddControllersWithViews(o => o.Filters.Add(new PageBodyFilter())).AddApplicationPart(typeof(ChecklistsController).Assembly); builder.Services.AddSingleton(checklists.Object); builder.Services.AddSingleton(_access.Object); builder.Services.AddSingleton(Mock.Of()); builder.Services.AddSingleton(Mock.Of()); builder.Services.AddSingleton(Mock.Of()); + builder.Services.AddSingleton(Mock.Of(d => d.GetDepartmentByIdAsync(77, false) == Task.FromResult(new Resgrid.Model.Department { DepartmentId = 77, TimeZone = "Pacific Standard Time", Use24HourTime = true }))); await using var app = builder.Build(); var previous = ClaimsAuthorizationHelper._httpContextAccessor; ClaimsAuthorizationHelper._httpContextAccessor = app.Services.GetRequiredService(); app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllerRoute("areas", "{area:exists}/{controller}/{action=Index}/{id?}"); diff --git a/Tests/Resgrid.Tests/Services/DeploymentServiceTests.cs b/Tests/Resgrid.Tests/Services/DeploymentServiceTests.cs index 936972f42..573b8414d 100644 --- a/Tests/Resgrid.Tests/Services/DeploymentServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/DeploymentServiceTests.cs @@ -141,10 +141,14 @@ public async Task Creating_a_deployment_starts_planned_publishes_created_and_aud } [Test] - public async Task Saving_rejects_a_blank_name_and_an_inverted_window() + public async Task Saving_rejects_a_blank_name_an_inverted_window_and_an_unknown_time_zone() { (await FluentActions.Awaiting(() => _service.SaveDeploymentAsync(new Deployment { DepartmentId = DeptId, Name = " " }, Manager, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("deployments_name_required"); (await FluentActions.Awaiting(() => _service.SaveDeploymentAsync(new Deployment { DepartmentId = DeptId, Name = "x", StartOn = DateTime.UtcNow, EndOn = DateTime.UtcNow.AddDays(-1) }, Manager, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("deployments_window_invalid"); + // The window, time reports and the contractor calendar all convert through the zone; a typo would silently shift every one of them. + (await FluentActions.Awaiting(() => _service.SaveDeploymentAsync(new Deployment { DepartmentId = DeptId, Name = "x", LocalTimeZoneId = "Pacific Standard Tmie" }, Manager, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("deployments_timezone_invalid"); + var zoned = await _service.SaveDeploymentAsync(new Deployment { DepartmentId = DeptId, Name = "x", LocalTimeZoneId = "Pacific Standard Time" }, Manager, null, null); + zoned.LocalTimeZoneId.Should().Be("Pacific Standard Time"); } [Test] @@ -435,6 +439,12 @@ public async Task Every_deployment_save_projects_the_row_for_search() await _service.SaveDeploymentAsync(saved, Manager, null, null); projected.Should().Equal(new[] { "Ridge Fire", "Ridge Fire Complex" }, "a created and an edited deployment reach search without waiting for a rebuild"); + + // The roster is the projection's participant set: search scopes deployments to it for members without the claim, + // so a newly rostered member must find the deployment without waiting for a rebuild. + projected.Clear(); + await _service.AddPersonnelAsync(saved.DeploymentId, DeptId, new DeploymentPersonnelInput { UserId = "alice" }, Manager, null, null); + projected.Should().Equal(new[] { "Ridge Fire Complex" }, "a roster addition reprojects the deployment"); } [Test] diff --git a/Tests/Resgrid.Tests/Services/InventoryDatabaseFixture.cs b/Tests/Resgrid.Tests/Services/InventoryDatabaseFixture.cs index 0c6b4a8a5..fe8b40b93 100644 --- a/Tests/Resgrid.Tests/Services/InventoryDatabaseFixture.cs +++ b/Tests/Resgrid.Tests/Services/InventoryDatabaseFixture.cs @@ -114,7 +114,9 @@ public async Task CreateOnlyAnIsolatedInventoryDatabase() _type == DatabaseTypes.Postgres ? new M0204_AddWorkOrderRecurrencesPg() : new M0204_AddWorkOrderRecurrences(), _type == DatabaseTypes.Postgres ? new M0205_EnforceInventoryTenantHoldersPg() : new M0205_EnforceInventoryTenantHolders(), _type == DatabaseTypes.Postgres ? new M0206_AddWorkOrderReportingPg() : new M0206_AddWorkOrderReporting(), - _type == DatabaseTypes.Postgres ? new M0207_AddWorkOrderOperationsPg() : new M0207_AddWorkOrderOperations() + _type == DatabaseTypes.Postgres ? new M0207_AddWorkOrderOperationsPg() : new M0207_AddWorkOrderOperations(), + _type == DatabaseTypes.Postgres ? new M0225_AddMaintenanceAssigneesPg() : new M0225_AddMaintenanceAssignees(), + _type == DatabaseTypes.Postgres ? new M0226_AddWorkOrderCurrencyPg() : new M0226_AddWorkOrderCurrency() }); _runner = new ServiceCollection().AddFluentMigratorCore().ConfigureRunner(r => { diff --git a/Tests/Resgrid.Tests/Services/InventoryM5NotificationTests.cs b/Tests/Resgrid.Tests/Services/InventoryM5NotificationTests.cs index ab041c340..a35cc6f65 100644 --- a/Tests/Resgrid.Tests/Services/InventoryM5NotificationTests.cs +++ b/Tests/Resgrid.Tests/Services/InventoryM5NotificationTests.cs @@ -6,9 +6,11 @@ using System.Threading.Tasks; using FluentAssertions; using Moq; +using Newtonsoft.Json; using NUnit.Framework; using Resgrid.Model; using Resgrid.Model.Inventories; +using Resgrid.Model.Repositories; using Resgrid.Model.Services; using Resgrid.Services; @@ -26,6 +28,11 @@ public sealed class InventoryM5NotificationTests private Mock _profiles; private Mock _settings; private Mock _communication; + private Mock _store; + private Mock _protection; + private Dictionary _alertRows; + private InventoryItem _item; + private InventoryLocation _location; private List _members; private Queue _deliveries; private List _notices; @@ -37,6 +44,12 @@ public sealed class InventoryM5NotificationTests public void SetUp() { _alerts = new(); _departments = new(); _profiles = new(); _settings = new(); _communication = new(); + _store = new(); _protection = new(); _alertRows = new(); + _item = new InventoryItem { DepartmentId = DepartmentId, Content = JsonConvert.SerializeObject(new InventoryItemContent { Name = "Trauma dressing", Code = "MED-014", Description = Canary }) }; + _location = new InventoryLocation { DepartmentId = DepartmentId, Content = JsonConvert.SerializeObject(new InventoryLabel { Name = "Station 2", Note = Canary }) }; + _store.Setup(x => x.GetAsync(DepartmentId, It.IsAny())).ReturnsAsync((int _, string id) => _alertRows.GetValueOrDefault(id)); + _store.Setup(x => x.GetAsync(DepartmentId, _item.Id)).ReturnsAsync(() => _item); + _store.Setup(x => x.GetAsync(DepartmentId, _location.Id)).ReturnsAsync(() => _location); _members = new() { new DepartmentMember { DepartmentId = DepartmentId, UserId = UserId, IsAdmin = false, IsActive = true } }; _deliveries = new(); _notices = new(); _department = new Department { DepartmentId = DepartmentId, Name = Canary }; @@ -52,7 +65,7 @@ public void SetUp() { _notices.Add(new Notice { UserId = user, DepartmentId = departmentId, Message = message, Number = number, Department = department, Title = title, Profile = profile }); return true; }); - _service = new InventoryAlertNotifications(_alerts.Object, _departments.Object, _profiles.Object, _settings.Object, _communication.Object, new AlertClock(_now)); + _service = new InventoryAlertNotifications(_alerts.Object, _departments.Object, _profiles.Object, _settings.Object, _communication.Object, _store.Object, _protection.Object, new AlertClock(_now)); } [Test] @@ -75,25 +88,27 @@ public async Task Alerts_only_claim_current_local_enabled_members_and_include_de } [Test] - public async Task Alerts_load_fresh_routing_then_recheck_current_access_immediately_before_generic_localized_handoff() + public async Task Alerts_load_fresh_routing_then_recheck_access_and_protection_before_localized_handoff() { var delivery = Enqueue(); _profile.Language = "fr"; var order = new List(); _profiles.Setup(x => x.GetProfileByUserIdAsync(UserId, true)).Callback(() => order.Add("profile")).ReturnsAsync(_profile); _departments.Setup(x => x.GetDepartmentByIdAsync(DepartmentId, true)).Callback(() => order.Add("department")).ReturnsAsync(_department); _settings.Setup(x => x.GetTextToCallNumberForDepartmentAsync(DepartmentId)).Callback(() => order.Add("number")).ReturnsAsync("+15555550456"); _alerts.Setup(x => x.CanReceiveAlertAsync(DepartmentId, UserId, delivery.AlertId)).Callback(() => order.Add("authorize")).ReturnsAsync(true); + _protection.Setup(x => x.IsProtectionEnforcedAsync(DepartmentId)).Callback(() => order.Add("protection")).ReturnsAsync(false); _communication.Setup(x => x.SendNotificationAsync(UserId, DepartmentId, It.IsAny(), "+15555550456", _department, It.IsAny(), _profile, false)) .Callback((string _, int _, string message, string number, Department department, string title, UserProfile profile, bool _) => { order.Add("send"); _notices.Add(new Notice { UserId = UserId, DepartmentId = DepartmentId, Message = message, Number = number, Department = department, Title = title, Profile = profile }); }).ReturnsAsync(true); (await _service.ProcessDepartmentAsync(DepartmentId, CancellationToken.None)).Should().Be(1); - order.Should().Equal("profile", "department", "number", "authorize", "send"); + order.Should().Equal("profile", "department", "number", "authorize", "protection", "send"); _profiles.Verify(x => x.GetProfileByUserIdAsync(UserId, false), Times.Never); var notice = _notices.Single(); notice.Title.Should().Be(InventoryReportDocuments.Text("M5AlertNotificationTitle", CultureInfo.GetCultureInfo("fr"))); - notice.Message.Should().StartWith(InventoryReportDocuments.Text("M5AlertNotificationMessage", CultureInfo.GetCultureInfo("fr"))) - .And.EndWith("/User/Inventory/Operations?tab=Alerts").And.NotContain(Canary).And.NotContain(UserId).And.NotContain(delivery.AlertId).And.NotContain(delivery.ClaimToken).And.NotContain("grant"); + notice.Message.Should().StartWith(InventoryReportDocuments.Text("M5AlertType0", CultureInfo.GetCultureInfo("fr")) + ": Trauma dressing (MED-014)") + .And.Contain("Station 2").And.EndWith(InventoryReportDocuments.Text("M5AlertNotificationMessage", CultureInfo.GetCultureInfo("fr"))) + .And.NotContain("http").And.NotContain("/User/").And.NotContain(Canary).And.NotContain(UserId).And.NotContain(delivery.AlertId).And.NotContain(delivery.ClaimToken).And.NotContain("grant"); notice.Title.Should().NotContain(Canary); notice.Department.Should().BeSameAs(_department); notice.Profile.Should().BeSameAs(_profile); _alerts.Verify(x => x.FinishAlertAsync(DepartmentId, delivery.Id, delivery.ClaimToken, true), Times.Once); } @@ -222,10 +237,71 @@ public async Task Alerts_bound_each_recipient_to_one_hundred_claims_per_run() _alerts.Verify(x => x.ClaimAlertAsync(DepartmentId, UserId), Times.Exactly(100)); } + [TestCase(InventoryAlertType.LowStock, "Low stock")] + [TestCase(InventoryAlertType.ExpiringSoon, "Expiring soon")] + [TestCase(InventoryAlertType.Expired, "Expired")] + [TestCase(InventoryAlertType.OverdueReturn, "Overdue return")] + public async Task Alerts_identify_the_item_and_location_without_links(InventoryAlertType type, string label) + { + var delivery = Enqueue(); _alertRows[delivery.AlertId].AlertType = (int)type; + await _service.ProcessDepartmentAsync(DepartmentId, CancellationToken.None); + _notices.Single().Message.Should().Be(label + ": Trauma dressing (MED-014); Location: Station 2. Inventory needs attention. Sign in to review current alerts."); + } + + [TestCase("department")] + [TestCase("row")] + [TestCase("envelope")] + [TestCase("redacted")] + [TestCase("malformed")] + public async Task Alerts_fall_back_to_item_and_location_ids_when_content_cannot_be_disclosed(string reason) + { + Enqueue(); + if (reason == "department") _protection.Setup(x => x.IsProtectionEnforcedAsync(DepartmentId)).ReturnsAsync(true); + if (reason == "row") { _item.IsProtected = true; _location.IsProtected = true; } + if (reason is "envelope" or "redacted" or "malformed") + _item.Content = _location.Content = reason == "envelope" ? "rgdp:1:1:" + Canary : reason == "redacted" ? "REDACTED" : "invalid-json-" + Canary; + await _service.ProcessDepartmentAsync(DepartmentId, CancellationToken.None); + _notices.Single().Message.Should().Contain(_item.Id).And.Contain(_location.Id) + .And.NotContain("Trauma dressing").And.NotContain("MED-014").And.NotContain("Station 2").And.NotContain(Canary).And.NotContain("rgdp:"); + } + + [TestCase("Trauma dressing https://example.test/details", "MED-014", "Trauma dressing (MED-014)")] + [TestCase(null, "MED-014", "MED-014")] + [TestCase("Trauma dressing", null, "Trauma dressing")] + [TestCase("https://example.test/details", "www.example.test/item", null)] + public async Task Alerts_handle_missing_labels_and_remove_links_from_item_and_location_labels(string name, string code, string expected) + { + Enqueue(); + _item.Content = JsonConvert.SerializeObject(new InventoryItemContent { Name = name, Code = code }); + _location.Content = JsonConvert.SerializeObject(new InventoryLabel { Name = "Station 2 http://example.test/location" }); + await _service.ProcessDepartmentAsync(DepartmentId, CancellationToken.None); + _notices.Single().Message.Should().StartWith("Low stock: " + (expected ?? _item.Id) + "; Location: Station 2.") + .And.NotContain("http").And.NotContain("www.").And.NotContain("example.test"); + } + + [Test] + public async Task Department_wide_alerts_identify_the_item_without_a_location() + { + var delivery = Enqueue(); _alertRows[delivery.AlertId].LocationId = null; + await _service.ProcessDepartmentAsync(DepartmentId, CancellationToken.None); + _notices.Single().Message.Should().StartWith("Low stock: Trauma dressing (MED-014). ").And.NotContain("Location:"); + } + + [TestCase("alert")] + [TestCase("item")] + public async Task Alerts_do_not_send_when_the_current_item_or_alert_is_missing(string missing) + { + var delivery = Enqueue(); + if (missing == "alert") _alertRows.Clear(); else _item = null; + (await _service.ProcessDepartmentAsync(DepartmentId, CancellationToken.None)).Should().Be(0); + NoSend(); _alerts.Verify(x => x.FinishAlertAsync(DepartmentId, delivery.Id, delivery.ClaimToken, false), Times.Once); + } + private InventoryAlertDelivery Enqueue() { var delivery = new InventoryAlertDelivery { Id = Guid.NewGuid().ToString("D"), DepartmentId = DepartmentId, UserId = UserId, AlertId = Guid.NewGuid().ToString("D"), ClaimToken = Guid.NewGuid().ToString("D"), LeaseUntil = _now.UtcDateTime.AddMinutes(5), Content = Canary }; + _alertRows[delivery.AlertId] = new InventoryAlert { Id = delivery.AlertId, DepartmentId = DepartmentId, ItemId = _item.Id, LocationId = _location.Id, AlertType = (int)InventoryAlertType.LowStock }; _deliveries.Enqueue(delivery); return delivery; } private void NoSend() => _communication.Verify(x => x.SendNotificationAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); diff --git a/Tests/Resgrid.Tests/Services/InventoryModernizationTests.cs b/Tests/Resgrid.Tests/Services/InventoryModernizationTests.cs index 2605317e4..31190105c 100644 --- a/Tests/Resgrid.Tests/Services/InventoryModernizationTests.cs +++ b/Tests/Resgrid.Tests/Services/InventoryModernizationTests.cs @@ -421,7 +421,7 @@ public async Task Joined_controlled_post_requires_the_independent_witness_path() [TestCase(true)] public async Task Work_order_closure_preserves_authorized_retries_but_blocks_new_movements(bool joined) { - var order = new WorkOrder { Id = 31, DepartmentId = Department, Status = (int)WorkOrderStatus.InProgress }; + var order = new WorkOrder { Id = "00000000-0000-0000-0000-000000000031", DepartmentId = Department, Status = (int)WorkOrderStatus.InProgress }; _workOrders.Setup(s => s.GetAsync(Department, order.Id, false)).ReturnsAsync(() => order); _workOrderAuth.Setup(a => a.CanContributeAsync(It.IsAny(), order)).ReturnsAsync(true); var item = Item(); var location = Location(); var command = Receive(item, location, 2); diff --git a/Tests/Resgrid.Tests/Services/InventoryWorkOrderDatabaseTests.cs b/Tests/Resgrid.Tests/Services/InventoryWorkOrderDatabaseTests.cs index 851859291..013ad609f 100644 --- a/Tests/Resgrid.Tests/Services/InventoryWorkOrderDatabaseTests.cs +++ b/Tests/Resgrid.Tests/Services/InventoryWorkOrderDatabaseTests.cs @@ -71,7 +71,7 @@ public async Task Asset_holds_require_separate_release_preserve_other_holds_and_ pair.Access.Setup(a=>a.CanUseMaintenanceAsync(77)).ReturnsAsync(false); await pair.Orders.ReleaseHoldAsync(reviewer,holds[1].Hold.Id,release); (await pair.Inventory.GetAsync(actor,asset.Id)).Status.Should().Be((int)(retire ? InventoryAssetStatus.Retired : InventoryAssetStatus.InService)); - (await pair.Orders.HoldsAsync(reviewer,holds[1].Hold.WorkOrderId.Value)).Single().Hold.StateRestored.Should().Be(!retire); + (await pair.Orders.HoldsAsync(reviewer,holds[1].Hold.WorkOrderId)).Single().Hold.StateRestored.Should().Be(!retire); } [Test] public async Task Cancelling_an_unposted_controlled_part_retains_evidence_and_cannot_move_stock() diff --git a/Tests/Resgrid.Tests/Services/InventoryWorkOrderOperationsDatabaseTests.cs b/Tests/Resgrid.Tests/Services/InventoryWorkOrderOperationsDatabaseTests.cs index 378bb632d..f2c2375d0 100644 --- a/Tests/Resgrid.Tests/Services/InventoryWorkOrderOperationsDatabaseTests.cs +++ b/Tests/Resgrid.Tests/Services/InventoryWorkOrderOperationsDatabaseTests.cs @@ -53,7 +53,7 @@ async Task Move(WorkOrderPartMovementKind kind,decimal quantity) await Move(WorkOrderPartMovementKind.Consume,1.5m); await Move(WorkOrderPartMovementKind.ReturnUnused,2.5m); await Move(WorkOrderPartMovementKind.ReleaseReservation,2); order=await pair.Orders.GetAsync(principal,order.Order.Id); part=order.Parts.Single(); part.ReservedQuantity.Should().Be(0); part.IssuedQuantity.Should().Be(0); part.ConsumedQuantity.Should().Be(1.5m); part.ReturnedQuantity.Should().Be(2.5m); part.Content.ConsumedCost.Should().Be(6.375m); var stocks=await Store(uow).ListAsync(77); stocks.Single(s=>s.LocationId==source.Id).Quantity.Should().Be(8.5m); stocks.Single(s=>s.LocationId==destination.Id).Quantity.Should().Be(0); - var ledger=await Store(uow).ListAsync(77); ledger.Where(t=>t.WorkOrderPartMovementId.HasValue).Should().HaveCount(3); + var ledger=await Store(uow).ListAsync(77); ledger.Where(t=>t.WorkOrderPartMovementId != null).Should().HaveCount(3); (await pair.Orders.PartMovementsAsync(principal,order.Order.Id)).Should().HaveCount(5); await pair.Orders.TransitionAsync(principal,order.Order.Id,new() {Revision=order.Order.Revision,Status=WorkOrderStatus.Cancelled,Reason="Settled"}); } diff --git a/Tests/Resgrid.Tests/Services/WorkOrderAuthorizationTests.cs b/Tests/Resgrid.Tests/Services/WorkOrderAuthorizationTests.cs index a76dc0d9c..a0be93279 100644 --- a/Tests/Resgrid.Tests/Services/WorkOrderAuthorizationTests.cs +++ b/Tests/Resgrid.Tests/Services/WorkOrderAuthorizationTests.cs @@ -15,6 +15,60 @@ namespace Resgrid.Tests.Services [TestFixture] public class WorkOrderAuthorizationTests { + [Test] + public async Task Assignment_choices_use_full_names_and_only_load_visible_profiles() + { + var actor = new ChecklistActor { DepartmentId = 77, UserId = "manager" }; + var departments = new Mock(); + departments.Setup(d => d.GetDepartmentMemberAsync(actor.UserId, 77, true)).ReturnsAsync(new DepartmentMember { DepartmentId = 77, UserId = actor.UserId }); + var assignments = new Mock(); + assignments.Setup(a => a.ChoicesAsync(actor)).ReturnsAsync(new List { + new() { Type = 1, Id = "a", Name = "login-a" }, new() { Type = 1, Id = "b", Name = "login-b" }, + new() { Type = 1, Id = "missing", Name = "fallback-login" }, new() { Type = 1, Id = "hidden", Name = "hidden-login" } + }); + var resources = new Mock(); + resources.Setup(r => r.CanUserViewPersonAsync(actor.UserId, It.IsAny(), 77)).ReturnsAsync((string caller, string user, int department) => user != "hidden"); + var profiles = new Mock(); + profiles.Setup(p => p.GetSelectedUserProfilesAsync(It.IsAny>())).ReturnsAsync(new List { + new() { UserId = "a", FirstName = "Zoe", LastName = "Taylor" }, new() { UserId = "b", FirstName = "Alex", LastName = "Smith" } + }); + var service = new WorkOrderAuthorizationService(departments.Object, Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), resources.Object, assignments.Object, profiles.Object); + var choices = await service.ChoicesAsync(actor); + choices.Users.Should().HaveCount(3); + choices.Users.Should().Contain(u => u.Id == "a" && u.Name == "Zoe Taylor").And.Contain(u => u.Id == "b" && u.Name == "Alex Smith"); + choices.Users.Should().Contain(u => u.Id == "missing" && u.Name == "fallback-login"); + profiles.Verify(p => p.GetSelectedUserProfilesAsync(It.Is>(ids => ids.Count == 3 && !ids.Contains("hidden"))), Times.Once); + } + + [Test] + public async Task Multiple_users_and_roles_share_access_and_deduplicated_current_recipients() + { + var departments = new Mock(); + departments.Setup(d => d.GetDepartmentMemberAsync(It.IsAny(), 77, true)).ReturnsAsync((string user, int d, bool fresh) => new DepartmentMember { DepartmentId = d, UserId = user }); + var roles = new Mock(); + roles.Setup(r => r.GetRolesForUserAsync(It.IsAny(), 77)).ReturnsAsync(new List()); + var assignments = new Mock(); + assignments.Setup(a => a.MembersAsync(77, 1, "primary")).ReturnsAsync(new HashSet { "primary" }); + assignments.Setup(a => a.MembersAsync(77, 1, "backup")).ReturnsAsync(new HashSet { "backup" }); + assignments.Setup(a => a.MembersAsync(77, 2, "11")).ReturnsAsync(new HashSet { "primary", "role-member" }); + assignments.Setup(a => a.MembersAsync(77, 2, "12")).ReturnsAsync(new HashSet { "role-member", "other-role-member" }); + var service = new WorkOrderAuthorizationService(departments.Object, Mock.Of(), roles.Object, + Mock.Of(), Mock.Of(), Mock.Of(), assignments.Object, Mock.Of()); + var row = new WorkOrder { DepartmentId = 77, AssignedToUserIds = new() { "primary", "backup" }, AssignedToRoleIds = new() { 11, 12 } }; + (await service.RecipientsAsync(77, row)).Should().BeEquivalentTo("primary", "backup", "role-member", "other-role-member"); + foreach (var user in new[] { "backup", "role-member", "other-role-member" }) + (await service.CanContributeAsync(new ChecklistActor { DepartmentId = 77, UserId = user }, row)).Should().BeTrue(); + (await service.CanContributeAsync(new ChecklistActor { DepartmentId = 77, UserId = "outsider" }, row)).Should().BeFalse(); + (await service.RecipientsAsync(88, row)).Should().BeEmpty(); + new WorkOrderReadScope { UserId = "backup" }.Allows(row).Should().BeTrue(); + new WorkOrderReadScope { UserId = "other-role-member", RoleIds = new[] { 12 } }.Allows(row).Should().BeTrue(); + new WorkOrderReadScope { UserId = "outsider" }.Allows(row).Should().BeFalse(); + assignments.Setup(a => a.MembersAsync(77, 1, "backup")).ReturnsAsync(new HashSet()); + assignments.Setup(a => a.MembersAsync(77, 2, "12")).ReturnsAsync(new HashSet()); + (await service.RecipientsAsync(77, row)).Should().BeEquivalentTo("primary", "role-member"); + } + [TestCase(false, 403, "ProtectedDataRequired")] [TestCase(true, 403, "ProtectedDataRequired")] [TestCase(false, 403, "MembershipRequired")] @@ -34,7 +88,7 @@ public async Task Inventory_asset_access_failures_use_the_work_order_error_contr assets.Setup(a => a.ListAsync(actor)).ThrowsAsync(new ChecklistException(statusCode, code)); var service = new WorkOrderAuthorizationService(departments.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), - Mock.Of(), assignments.Object, assets.Object); + Mock.Of(), assignments.Object, Mock.Of(), assets.Object); Func action = validateTarget ? () => service.ValidateTargetAsync(actor, new WorkOrderInput { InventoryAssetId = assetId }) diff --git a/Tests/Resgrid.Tests/Services/WorkOrderCompletionTests.cs b/Tests/Resgrid.Tests/Services/WorkOrderCompletionTests.cs new file mode 100644 index 000000000..7da810236 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/WorkOrderCompletionTests.cs @@ -0,0 +1,93 @@ +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model.WorkOrders; + +namespace Resgrid.Tests.Services +{ + public partial class WorkOrderP2M1Tests + { + [TestCase(WorkOrderStatus.Assigned, true)] + [TestCase(WorkOrderStatus.Assigned, false)] + [TestCase(WorkOrderStatus.InProgress, true)] + [TestCase(WorkOrderStatus.InProgress, false)] + [TestCase(WorkOrderStatus.OnHold, true)] + [TestCase(WorkOrderStatus.OnHold, false)] + public async Task Completion_is_offered_and_records_confirmation_from_active_work(WorkOrderStatus status, bool manager) + { + var id = await Assigned(); + var detail = await _service.GetAsync(_actor, id); + detail.Input.Content.Steps.Add(new WorkOrderTaskStep { Text = "Test repair" }); + await _service.UpdateAsync(_actor, id, detail.Input); + if (status != WorkOrderStatus.Assigned) await Transition(id, status, reason: "Awaiting test"); + _auth.Setup(a => a.CanManageAsync(_actor, It.IsAny())).ReturnsAsync(manager); + detail = await _service.GetAsync(_actor, id); + detail.Transitions.Should().Contain(WorkOrderStatus.Completed); + var input = new WorkOrderTransition { Revision = detail.Order.Revision, Status = WorkOrderStatus.Completed, Resolution = "Repaired", Cause = "Wear" }; + + await FluentActions.Awaiting(() => _service.TransitionAsync(_actor, id, input)) + .Should().ThrowAsync().Where(e => e.Code == "TasksIncomplete"); + (await _service.GetAsync(_actor, id)).Order.Status.Should().Be(status); + input.ConfirmTasksComplete = true; + await _service.TransitionAsync(_actor, id, input); + + detail = await _service.GetAsync(_actor, id); + detail.Order.Status.Should().Be(WorkOrderStatus.Completed); + detail.Input.Content.Steps.Should().OnlyContain(s => s.Completed); + detail.Input.Content.Resolution.Should().Be("Repaired"); + detail.Input.Content.Cause.Should().Be("Wear"); + detail.CompletedOn.Should().NotBeNull(); + detail.ClosedOn.Should().BeNull(); + detail.VerifiedBy.Should().BeNull(); + _store.All().Single().CompletedBy.Should().Be(_actor.UserId); + } + + [TestCase(WorkOrderStatus.Assigned)] + [TestCase(WorkOrderStatus.OnHold)] + public async Task Completion_still_requires_assignment_acceptance(WorkOrderStatus status) + { + var detail = await _service.CreateAsync(_actor, Input()); + var id = detail.Order.Id; + detail = await Transition(id, WorkOrderStatus.Accepted); + await _service.AssignAsync(_actor, id, new WorkOrderAssignment { Revision = detail.Order.Revision, UserId = _actor.UserId }); + if (status == WorkOrderStatus.OnHold) await Transition(id, status, reason: "Waiting for acceptance"); + + await FluentActions.Awaiting(() => Transition(id, WorkOrderStatus.Completed)) + .Should().ThrowAsync().Where(e => e.Code == "AcceptAssignmentFirst"); + (await _service.GetAsync(_actor, id)).Order.Status.Should().Be(status); + } + + [TestCase(WorkOrderStatus.Assigned)] + [TestCase(WorkOrderStatus.OnHold)] + public async Task Completion_still_requires_hazardous_work_safety_information(WorkOrderStatus status) + { + var id = await Assigned(); + var detail = await _service.GetAsync(_actor, id); + detail.Input.Content.HazardousWork = true; + await _service.UpdateAsync(_actor, id, detail.Input); + if (status == WorkOrderStatus.OnHold) await Transition(id, status, reason: "Waiting for permit"); + + await FluentActions.Awaiting(() => Transition(id, WorkOrderStatus.Completed)) + .Should().ThrowAsync().Where(e => e.Code == "SafetyRequirements"); + (await _service.GetAsync(_actor, id)).Order.Status.Should().Be(status); + } + + [Test] + public async Task Completion_confirmation_does_not_override_the_selected_status() + { + var id = await Assigned(); + var detail = await _service.GetAsync(_actor, id); + detail.Input.Content.Steps.Add(new WorkOrderTaskStep { Text = "Test repair" }); + await _service.UpdateAsync(_actor, id, detail.Input); + detail = await _service.GetAsync(_actor, id); + await _service.TransitionAsync(_actor, id, new WorkOrderTransition { Revision = detail.Order.Revision, Status = WorkOrderStatus.InProgress, ConfirmTasksComplete = true }); + + detail = await _service.GetAsync(_actor, id); + detail.Order.Status.Should().Be(WorkOrderStatus.InProgress); + detail.CompletedOn.Should().BeNull(); + detail.Input.Content.Steps.Should().OnlyContain(s => !s.Completed); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/WorkOrderDatabaseTests.cs b/Tests/Resgrid.Tests/Services/WorkOrderDatabaseTests.cs index 4c7d9dd14..b73e62f85 100644 --- a/Tests/Resgrid.Tests/Services/WorkOrderDatabaseTests.cs +++ b/Tests/Resgrid.Tests/Services/WorkOrderDatabaseTests.cs @@ -65,7 +65,9 @@ public async Task CreateIsolatedDatabaseAndMigrate() _type == DatabaseTypes.Postgres ? new M0203_AddWorkOrderIntegrationsPg() : new M0203_AddWorkOrderIntegrations(), _type == DatabaseTypes.Postgres ? new M0204_AddWorkOrderRecurrencesPg() : new M0204_AddWorkOrderRecurrences(), _type == DatabaseTypes.Postgres ? new M0206_AddWorkOrderReportingPg() : new M0206_AddWorkOrderReporting(), - _type == DatabaseTypes.Postgres ? new M0207_AddWorkOrderOperationsPg() : new M0207_AddWorkOrderOperations() + _type == DatabaseTypes.Postgres ? new M0207_AddWorkOrderOperationsPg() : new M0207_AddWorkOrderOperations(), + _type == DatabaseTypes.Postgres ? new M0225_AddMaintenanceAssigneesPg() : new M0225_AddMaintenanceAssignees(), + _type == DatabaseTypes.Postgres ? new M0226_AddWorkOrderCurrencyPg() : new M0226_AddWorkOrderCurrency() }); _runner = new ServiceCollection().AddFluentMigratorCore().ConfigureRunner(r => { @@ -107,7 +109,7 @@ public async Task Identity_is_allocated_empty_children_are_tenant_scoped_and_lab using var uow = new UnitOfWork(Connections()); var store = Orders(uow); await uow.CreateOrGetConnectionAsync(); await store.LockDepartmentAsync(77); var row = await Insert(store); var labor = new WorkOrderLabor { DepartmentId = 77, WorkOrderId = row.Id, UserId = "author", CreatedBy = "author", CreatedOn = DateTime.UtcNow, UpdatedOn = DateTime.UtcNow, WorkDate = DateTime.UtcNow }; - await store.AllocateAsync(labor); labor.Content = "{\"Hours\":2}"; await store.WriteAsync(labor); uow.CommitChanges(); + await store.AllocateAsync(labor); Guid.TryParseExact(row.Id, "D", out _).Should().BeTrue(); Guid.TryParseExact(labor.Id, "D", out _).Should().BeTrue(); labor.Content = "{\"Hours\":2}"; await store.WriteAsync(labor); uow.CommitChanges(); (await store.GetAsync(88, row.Id)).Should().BeNull(); (await store.ChildrenAsync(88, row.Id)).Should().BeEmpty(); (await store.ChildrenAsync(77, row.Id)).Should().ContainSingle(); diff --git a/Tests/Resgrid.Tests/Services/WorkOrderGdprTests.cs b/Tests/Resgrid.Tests/Services/WorkOrderGdprTests.cs index a46b036da..ae175cae2 100644 --- a/Tests/Resgrid.Tests/Services/WorkOrderGdprTests.cs +++ b/Tests/Resgrid.Tests/Services/WorkOrderGdprTests.cs @@ -46,17 +46,17 @@ public async Task Work_order_personal_export_masks_protected_candidates_and_neve { var store = new Mock(); store.Setup(s => s.ListAsync(DeptId, It.IsAny(), It.IsAny())).ReturnsAsync(new List { - new WorkOrder { Id = 1, DepartmentId = DeptId, CreatedBy = UserId, Content = "SYNTHETIC-PHI-CANARY", NumberYear = 2026, NumberSequence = 1 } + new WorkOrder { Id = "00000000-0000-0000-0000-000000000001", DepartmentId = DeptId, CreatedBy = UserId, Content = "SYNTHETIC-PHI-CANARY", NumberYear = 2026, NumberSequence = 1 } }); store.SetReturnsDefault(Task.FromResult(new List())); store.SetReturnsDefault(Task.FromResult(new List())); - store.SetReturnsDefault(Task.FromResult(new List {new() {Id=8,DepartmentId=DeptId,WorkOrderId=1,CreatedBy=UserId,Content="VENDOR-CANARY"}})); - store.SetReturnsDefault(Task.FromResult(new List {new() {Id=9,DepartmentId=DeptId,WorkOrderId=1,CreatedBy=UserId,Content="MOVEMENT-CANARY"}})); - store.SetReturnsDefault(Task.FromResult(new List {new() {Id=10,DepartmentId=DeptId,WorkOrderId=1,CreatedBy=UserId,Content="BULK-CANARY"}})); + store.SetReturnsDefault(Task.FromResult(new List {new() {Id="00000000-0000-0000-0000-000000000008",DepartmentId=DeptId,WorkOrderId="00000000-0000-0000-0000-000000000001",CreatedBy=UserId,Content="VENDOR-CANARY"}})); + store.SetReturnsDefault(Task.FromResult(new List {new() {Id="00000000-0000-0000-0000-000000000009",DepartmentId=DeptId,WorkOrderId="00000000-0000-0000-0000-000000000001",CreatedBy=UserId,Content="MOVEMENT-CANARY"}})); + store.SetReturnsDefault(Task.FromResult(new List {new() {Id="00000000-0000-0000-0000-000000000010",DepartmentId=DeptId,WorkOrderId="00000000-0000-0000-0000-000000000001",CreatedBy=UserId,Content="BULK-CANARY"}})); store.SetReturnsDefault(Task.FromResult(new List())); - store.SetReturnsDefault(Task.FromResult(new List { new() { DepartmentId = DeptId, WorkOrderId = 1, Revision = 2, Id = 3 } })); - store.Setup(s => s.ChildrenAsync(DeptId, 1, 0)).ReturnsAsync(new List { - new WorkOrderFile { Id=2, DepartmentId=DeptId, WorkOrderId=1, CreatedBy=UserId, Content="PII-FILENAME-CANARY", Data=new byte[]{1,2,3} } + store.SetReturnsDefault(Task.FromResult(new List { new() { DepartmentId = DeptId, WorkOrderId = "00000000-0000-0000-0000-000000000001", Revision = 2, Id = "00000000-0000-0000-0000-000000000003" } })); + store.Setup(s => s.ChildrenAsync(DeptId, "00000000-0000-0000-0000-000000000001", 0)).ReturnsAsync(new List { + new WorkOrderFile { Id="00000000-0000-0000-0000-000000000002", DepartmentId=DeptId, WorkOrderId="00000000-0000-0000-0000-000000000001", CreatedBy=UserId, Content="PII-FILENAME-CANARY", Data=new byte[]{1,2,3} } }); var policy = new Mock(); if (policyFailure) policy.Setup(p=>p.IsProtectionEnforcedAsync(DeptId)).ThrowsAsync(new InvalidOperationException()); @@ -65,14 +65,14 @@ public async Task Work_order_personal_export_masks_protected_candidates_and_neve reminders.SetReturnsDefault(Task.FromResult(new List())); var maintenance = Mock.Get(EmptyMaintenance()); maintenance.Setup(s=>s.QueryMaintenanceAsync(DeptId,null,null,0,false)).ReturnsAsync(new List { - new() { Id=3, DepartmentId=DeptId, CreatedBy="other", ReleasedBy=UserId, Content="HOLD-CANARY" }, - new() { Id=4, DepartmentId=DeptId, CreatedBy="other", Content="UNRELATED-HOLD" } + new() { Id="00000000-0000-0000-0000-000000000003", DepartmentId=DeptId, CreatedBy="other", ReleasedBy=UserId, Content="HOLD-CANARY" }, + new() { Id="00000000-0000-0000-0000-000000000004", DepartmentId=DeptId, CreatedBy="other", Content="UNRELATED-HOLD" } }); maintenance.Setup(s=>s.QueryMaintenanceAsync(DeptId,null,null,0,false)).ReturnsAsync(new List { - new() { Id=5, DepartmentId=DeptId, CreatedBy="other", AssignedToUserId=UserId, Content="TEMPLATE-CANARY" } + new() { Id="00000000-0000-0000-0000-000000000005", DepartmentId=DeptId, CreatedBy="other", AssignedToUserId=UserId, Content="TEMPLATE-CANARY" } }); maintenance.Setup(s=>s.QueryMaintenanceAsync(DeptId,null,null,0,false)).ReturnsAsync(new List { - new() { Id=6, DepartmentId=DeptId, CreatedBy=UserId, RecurrenceId=5, Content="READING-CANARY" } + new() { Id="00000000-0000-0000-0000-000000000006", DepartmentId=DeptId, CreatedBy=UserId, RecurrenceId="00000000-0000-0000-0000-000000000005", Content="READING-CANARY" } }); _service = new GdprDataExportService(_repository.Object, _userProfileService.Object, _memberSensitiveDataService.Object, _emergencyContactService.Object, _usersService.Object, _departmentsService.Object, _departmentGroupsService.Object, _personnelRolesService.Object, _actionLogsService.Object, diff --git a/Tests/Resgrid.Tests/Services/WorkOrderGuidTests.cs b/Tests/Resgrid.Tests/Services/WorkOrderGuidTests.cs new file mode 100644 index 000000000..176ee2b53 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/WorkOrderGuidTests.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using FluentMigrator; +using FluentMigrator.Expressions; +using FluentMigrator.Infrastructure; +using FluentMigrator.Model; +using Moq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Model.WorkOrders; +using Resgrid.Providers.Migrations.Migrations; +using Resgrid.Providers.MigrationsPg.Migrations; + +namespace Resgrid.Tests.Services +{ + public class WorkOrderGuidMigrationTests + { + [TestCase(false), TestCase(true)] + public void Original_migrations_use_guid_strings_for_all_work_order_keys_and_references(bool postgres) + { + var expressions = new List(); + var context = new Mock(); + context.SetupGet(c => c.Expressions).Returns(expressions); + var schema = new Mock(); + schema.Setup(s => s.TableExists(It.IsAny(), It.IsAny())).Returns(true); + context.SetupGet(c => c.QuerySchema).Returns(schema.Object); + IMigration[] migrations = postgres + ? new IMigration[] { new M0197_AddWorkOrdersPg(), new M0203_AddWorkOrderIntegrationsPg(), new M0204_AddWorkOrderRecurrencesPg(), new M0206_AddWorkOrderReportingPg(), new M0207_AddWorkOrderOperationsPg() } + : new IMigration[] { new M0197_AddWorkOrders(), new M0203_AddWorkOrderIntegrations(), new M0204_AddWorkOrderRecurrences(), new M0206_AddWorkOrderReporting(), new M0207_AddWorkOrderOperations() }; + foreach (var migration in migrations) migration.GetUpExpressions(context.Object); + var tables = expressions.OfType().ToDictionary(t => t.TableName, t => t.Columns.ToDictionary(c => c.Name, StringComparer.OrdinalIgnoreCase), StringComparer.OrdinalIgnoreCase); + foreach (var expression in expressions.OfType()) + { + if (!tables.ContainsKey(expression.TableName)) tables[expression.TableName] = new(StringComparer.OrdinalIgnoreCase); + tables[expression.TableName][expression.Column.Name] = expression.Column; + } + foreach (var expression in expressions.OfType()) tables[expression.TableName][expression.Column.Name] = expression.Column; + + foreach (var table in WorkOrderTables.All.Values.Append("WorkOrderReportSnapshots")) + { + GuidColumn(tables[table]["Id"]); + tables[table]["Id"].IsPrimaryKey.Should().BeTrue(table); + tables[table]["DepartmentId"].Type.Should().Be(DbType.Int32, table); + } + var references = new HashSet(new[] { "WorkOrderId", "DuplicateOfId", "RecurrenceId", "CurrentVersionId", "RecurrenceVersionId", "PendingWorkOrderId", "PartId", "SourceActivityId", "WorkOrderPartId", "WorkOrderPartMovementId" }, StringComparer.OrdinalIgnoreCase); + var foreignKeys = tables.Values.SelectMany(c => c.Values).Where(c => references.Contains(c.Name)).ToList(); + foreignKeys.Should().NotBeEmpty(); + foreach (var column in foreignKeys) GuidColumn(column); + tables["WorkOrderRecurrences"]["CurrentVersionId"].IsNullable.Should().BeTrue("the first version is inserted after its parent inside the transaction"); + + static void GuidColumn(ColumnDefinition column) + { + column.Type.Should().Be(DbType.String, column.Name); + column.Size.Should().Be(36, column.Name); + column.IsIdentity.Should().BeFalse(column.Name); + column.DefaultValue.Should().BeOfType(column.Name); + } + } + + [Test] + public void Workflow_routing_preserves_guid_references_and_rejects_numeric_or_malformed_keys() + { + var id = Guid.NewGuid().ToString("D"); + var payload = new JObject { ["WorkOrderId"] = id, ["RecurrenceId"] = id, ["HoldId"] = id, ["PolicyId"] = id, ["TargetUnitId"] = 12, ["Revision"] = 3, ["Title"] = "private" }; + var safe = JObject.Parse(WorkOrderWorkflowPayload.Routing(payload)); + foreach (var name in new[] { "WorkOrderId", "RecurrenceId", "HoldId", "PolicyId" }) safe[name].Value().Should().Be(id); + safe["TargetUnitId"].Value().Should().Be(12); + safe["Revision"].Value().Should().Be(3); + safe.ToString().Should().NotContain("private"); + payload["WorkOrderId"] = 19; payload["RecurrenceId"] = "19"; payload["HoldId"] = "invalid"; + safe = JObject.Parse(WorkOrderWorkflowPayload.Routing(payload)); + safe["WorkOrderId"].Should().BeNull(); safe["RecurrenceId"].Should().BeNull(); safe["HoldId"].Should().BeNull(); + } + } + + public partial class WorkOrderP2M1Tests + { + [Test] + public async Task Guid_history_cursors_preserve_chronology_across_page_boundaries() + { + var start = DateTime.UtcNow.AddDays(-1); + var rows = new List(); + for (var i = 0; i < 55; i++) rows.Add(await ReportSeed(start)); + rows = rows.OrderBy(r => r.Id, StringComparer.Ordinal).ToList(); + for (var i = 0; i < rows.Count; i++) + { + // Deliberately make GUID order the reverse of event time. + rows[i].CreatedOn = start.AddMinutes(rows.Count - i); + await _store.WriteAsync(rows[i]); + } + var first = await _service.GetWorkOrderHistoryAsync(_actor, new WorkOrderReportQuery()); + var second = await _service.GetWorkOrderHistoryAsync(_actor, new WorkOrderReportQuery { AfterId = first.NextAfterId }); + first.Items.Should().HaveCount(50); second.Items.Should().HaveCount(5); + second.NextAfterId.Should().BeNull(); + first.Items.Concat(second.Items).Select(r => r.Order.Id).Should().Equal(rows.OrderBy(r => r.CreatedOn).Select(r => r.Id)); + } + + [Test] + public async Task Guid_identity_flows_through_creation_children_snapshots_and_retry() + { + var input = Input(); + var first = await _service.CreateAsync(_actor, input); + Guid.TryParseExact(first.Order.Id, "D", out var id).Should().BeTrue(); + id.Should().NotBe(Guid.Empty); + (await _service.CreateAsync(_actor, input)).Order.Id.Should().Be(first.Order.Id); + await _service.CommentAsync(_actor, first.Order.Id, first.Order.Revision, "GUID evidence"); + var detail = await _service.GetAsync(_actor, first.Order.Id); + detail.Activities.Should().HaveCount(2); + foreach (var activity in _store.All()) + { + Guid.TryParseExact(activity.Id, "D", out _).Should().BeTrue(); + activity.WorkOrderId.Should().Be(first.Order.Id); + } + foreach (var snapshot in _store.Snapshots) + { + Guid.TryParseExact(snapshot.Id, "D", out _).Should().BeTrue(); + snapshot.WorkOrderId.Should().Be(first.Order.Id); + detail.Activities.Select(a => a.Id).Should().Contain(snapshot.SourceActivityId); + } + } + } +} diff --git a/Tests/Resgrid.Tests/Services/WorkOrderHttpTests.cs b/Tests/Resgrid.Tests/Services/WorkOrderHttpTests.cs index 037a4d953..b80314aa4 100644 --- a/Tests/Resgrid.Tests/Services/WorkOrderHttpTests.cs +++ b/Tests/Resgrid.Tests/Services/WorkOrderHttpTests.cs @@ -58,7 +58,10 @@ public void OnResultExecuted(ResultExecutedContext c) { } public async Task Http_routes_bind_forms_enforce_CSRF_preserve_conflicts_and_localize_protected_failures() { Maintenance(); - _auth.Setup(a=>a.ChoicesAsync(It.IsAny())).ReturnsAsync(new WorkOrderChoices()); + _auth.Setup(a=>a.ChoicesAsync(It.IsAny())).ReturnsAsync(new WorkOrderChoices { + Users = new() { new() { Id = "tech-a", Name = "Alex Smith" }, new() { Id = "tech-b", Name = "Zoe Taylor" } }, + Roles = new() { new() { Id = "11", Name = "Mechanics" }, new() { Id = "12", Name = "Shift leads" } } + }); var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); while(directory != null && !System.IO.File.Exists(Path.Combine(directory.FullName,"Resgrid.sln"))) directory=directory.Parent; var builder=WebApplication.CreateBuilder(new WebApplicationOptions { ContentRootPath=Path.Combine(directory.FullName,"Web","Resgrid.Web"),EnvironmentName="Testing" }); @@ -72,7 +75,7 @@ public async Task Http_routes_bind_forms_enforce_CSRF_preserve_conflicts_and_loc builder.Services.AddSingleton(_service); builder.Services.AddSingleton(_service); builder.Services.AddSingleton(_service); builder.Services.AddSingleton(_auth.Object); builder.Services.AddSingleton(_access.Object); var departments=new Mock(); departments.Setup(d=>d.GetDepartmentMemberAsync(It.IsAny(),77,true)).ReturnsAsync((string user,int dept,bool fresh)=>new DepartmentMember {DepartmentId=77,UserId=user}); - departments.Setup(d=>d.GetDepartmentByIdAsync(77,true)).ReturnsAsync(new Department {DepartmentId=77,ManagingUserId="manager"}); + departments.Setup(d=>d.GetDepartmentByIdAsync(77,It.IsAny())).ReturnsAsync(new Department {DepartmentId=77,ManagingUserId="manager",TimeZone="Pacific Standard Time",Use24HourTime=true}); var billing=new Mock();billing.Setup(b=>b.GetAsync(77)).ReturnsAsync(new ReadinessProBillingStatus {Provider="Stripe",Currency="USD",MonthlyAmount=150,CheckoutAvailable=true}); billing.Setup(b=>b.BeginCheckoutAsync(77)).ReturnsAsync(new ReadinessProCheckout {Provider="Stripe",Url="https://checkout.stripe.com/synthetic"}); builder.Services.AddSingleton(departments.Object);builder.Services.AddSingleton(billing.Object); @@ -92,12 +95,21 @@ public async Task Http_routes_bind_forms_enforce_CSRF_preserve_conflicts_and_loc var response=await client.GetAsync("/User/WorkOrders/New"); var html=await response.Content.ReadAsStringAsync(); response.StatusCode.Should().Be(HttpStatusCode.OK,html); WebUtility.HtmlDecode(html).Should().Contain("Nouvel ordre de travail").And.NotContain("New work order"); var csrf=WebUtility.HtmlDecode(Regex.Match(html,"name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\"").Groups[1].Value); - var fields=new Dictionary { ["Input.RequestId"]=Guid.NewGuid().ToString(),["Input.Content.Title"]="HTTP synthetic repair",["Input.Content.EstimatedCost"]="1234.56",["Input.Content.Currency"]="EUR",["Input.Priority"]="1",["Input.Type"]="0" }; + var fields=new Dictionary { ["Input.RequestId"]=Guid.NewGuid().ToString(),["Input.Content.Title"]="HTTP synthetic repair",["Input.Content.EstimatedCost"]="1234.56",["Input.Content.Currency"]="EUR",["Input.Priority"]="1",["Input.Type"]="0",["Input.DueOn"]="2026-09-15T11:30:00" }; (await client.PostAsync("/User/WorkOrders/Save",new FormUrlEncodedContent(fields))).StatusCode.Should().Be(HttpStatusCode.BadRequest); fields["__RequestVerificationToken"]=csrf; response=await client.PostAsync("/User/WorkOrders/Save",new FormUrlEncodedContent(fields)); - response.StatusCode.Should().Be(HttpStatusCode.OK,await response.Content.ReadAsStringAsync()); var id=JObject.Parse(await response.Content.ReadAsStringAsync())["id"].Value(); + response.StatusCode.Should().Be(HttpStatusCode.OK,await response.Content.ReadAsStringAsync()); var id=JObject.Parse(await response.Content.ReadAsStringAsync())["id"].Value(); (await _service.GetAsync(_actor,id)).Input.Content.EstimatedCost.Should().Be(1234.56m); + (await _service.GetAsync(_actor,id)).Input.DueOn.Should().Be(new DateTime(2026,9,15,18,30,0,DateTimeKind.Utc)); + response = await client.GetAsync("/User/WorkOrders/Detail?id=" + id); + html = await response.Content.ReadAsStringAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK, html); + html.Should().Contain("09/15/2026 11:30:00").And.NotContain("2026-09-15 18:30:00Z"); + response = await client.GetAsync("/User/WorkOrders/Edit?id=" + id); + html = await response.Content.ReadAsStringAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK, html); + html.Should().Contain("2026-09-15T11:30:00.000").And.Contain("2026-09-15T18:30:00.0000000Z"); response=await client.GetAsync("/User/ReadinessProBilling");response.StatusCode.Should().Be(HttpStatusCode.OK,await response.Content.ReadAsStringAsync()); (await client.PostAsync("/User/ReadinessProBilling/Checkout",new FormUrlEncodedContent(new Dictionary()))).StatusCode.Should().Be(HttpStatusCode.BadRequest); response=await client.PostAsync("/User/ReadinessProBilling/Checkout",new FormUrlEncodedContent(new Dictionary{["__RequestVerificationToken"]=csrf,["DepartmentId"]="88"})); @@ -116,33 +128,118 @@ public async Task Http_routes_bind_forms_enforce_CSRF_preserve_conflicts_and_loc var body=new StringContent(JsonConvert.SerializeObject(new { Id=id,Input=new WorkOrderTransition {Revision=1,Status=WorkOrderStatus.Accepted}}),Encoding.UTF8,"application/json"); response=await client.PostAsync("/api/v4/WorkOrders/SetWorkOrderStatus",body); response.StatusCode.Should().Be(HttpStatusCode.OK,await response.Content.ReadAsStringAsync()); response=await client.PostAsync("/api/v4/WorkOrders/SetWorkOrderStatus",body); response.StatusCode.Should().Be(HttpStatusCode.Conflict); + foreach (var activeStatus in new[] { WorkOrderStatus.Assigned, WorkOrderStatus.OnHold }) + { + var completionId = await Assigned(); + var completion = await _service.GetAsync(_actor, completionId); + completion.Input.Content.Steps.Add(new WorkOrderTaskStep { Text = "Test repair" }); + await _service.UpdateAsync(_actor, completionId, completion.Input); + if (activeStatus == WorkOrderStatus.OnHold) await Transition(completionId, activeStatus, reason: "Awaiting test"); + completion = await _service.GetAsync(_actor, completionId); + response = await client.GetAsync("/User/WorkOrders/Detail?id=" + completionId); + html = await response.Content.ReadAsStringAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK, html); + var statusOptions = Regex.Match(html, "]*id=\"transition-status\"[^>]*>.*?", RegexOptions.Singleline).Value; + statusOptions.Should().Contain("value=\"5\""); + html.Should().Contain("name=\"ConfirmTasksComplete\""); + var completionFields = new Dictionary { + ["id"] = completionId.ToString(), ["Revision"] = completion.Order.Revision.ToString(), + ["Status"] = "5", ["Resolution"] = "Repaired", ["Cause"] = "Wear", ["__RequestVerificationToken"] = csrf + }; + response = await client.PostAsync("/User/WorkOrders/Transition", new FormUrlEncodedContent(completionFields)); + response.StatusCode.Should().Be(HttpStatusCode.Conflict, await response.Content.ReadAsStringAsync()); + completionFields["ConfirmTasksComplete"] = "true"; + response = await client.PostAsync("/User/WorkOrders/Transition", new FormUrlEncodedContent(completionFields)); + response.StatusCode.Should().Be(HttpStatusCode.OK, await response.Content.ReadAsStringAsync()); + completion = await _service.GetAsync(_actor, completionId); + completion.Order.Status.Should().Be(WorkOrderStatus.Completed); + completion.Input.Content.Steps.Should().OnlyContain(s => s.Completed); + completion.CompletedOn.Should().NotBeNull(); + completion.ClosedOn.Should().BeNull(); + } response=await client.GetAsync("/User/WorkOrders/NewRecurrence"); html=await response.Content.ReadAsStringAsync(); response.StatusCode.Should().Be(HttpStatusCode.OK,html); html.Should().Contain("name=\"Input.Template.Content.Title\""); var pmFields=new Dictionary { ["Input.Template.RequestId"]=Guid.NewGuid().ToString("D"),["Input.Template.Content.Title"]="Synthetic HTTP PM",["Input.Template.Content.Currency"]="EUR", ["Input.AnchorLocal"]="2026-09-12T12:00",["Input.TimeZoneId"]="UTC",["Input.Calendar"]="3",["serviceStart"]="00:00",["serviceEnd"]="23:59",["Input.IsActive"]="true" }; (await client.PostAsync("/User/WorkOrders/SaveRecurrence",new FormUrlEncodedContent(pmFields))).StatusCode.Should().Be(HttpStatusCode.BadRequest); pmFields["__RequestVerificationToken"]=csrf; - response=await client.PostAsync("/User/WorkOrders/SaveRecurrence",new FormUrlEncodedContent(pmFields)); - response.StatusCode.Should().Be(HttpStatusCode.OK,await response.Content.ReadAsStringAsync()); var pmId=JObject.Parse(await response.Content.ReadAsStringAsync())["id"].Value(); + var pmPost = pmFields.ToList(); + pmPost.Add(new("Input.AssignedToUserIds", "tech-a")); pmPost.Add(new("Input.AssignedToUserIds", "tech-b")); + pmPost.Add(new("Input.AssignedToRoleIds", "11")); pmPost.Add(new("Input.AssignedToRoleIds", "12")); + response=await client.PostAsync("/User/WorkOrders/SaveRecurrence",new FormUrlEncodedContent(pmPost)); + response.StatusCode.Should().Be(HttpStatusCode.OK,await response.Content.ReadAsStringAsync()); var pmId=JObject.Parse(await response.Content.ReadAsStringAsync())["id"].Value(); + var assignedPm = await _service.RecurrenceAsync(_actor, pmId); + assignedPm.Settings.AssignedToUserIds.Should().Equal("tech-a", "tech-b"); + assignedPm.Settings.AssignedToRoleIds.Should().Equal(11, 12); foreach(var pmPage in new[]{"Recurrences","Recurrence?id="+pmId,"EditRecurrence?id="+pmId}) { response=await client.GetAsync("/User/WorkOrders/"+pmPage); response.StatusCode.Should().Be(HttpStatusCode.OK,await response.Content.ReadAsStringAsync()); } + html = await response.Content.ReadAsStringAsync(); + foreach (var field in new[] { "Input.AssignedToUserIds", "Input.AssignedToRoleIds" }) + { + var select = Regex.Match(html, "]*name=\"" + Regex.Escape(field) + "\"[^>]*>.*?", RegexOptions.Singleline).Value; + select.Should().Contain("multiple"); + Regex.Matches(select, "]*selected=\"selected\"[^>]*>").Should().HaveCount(2); + } response=await client.GetAsync("/api/v4/WorkOrders/GetWorkOrderRecurrence?id="+pmId); response.StatusCode.Should().Be(HttpStatusCode.OK,await response.Content.ReadAsStringAsync()); (await client.GetAsync("/api/v4/WorkOrders/GetWorkOrderRecurrence?id=9999")).StatusCode.Should().Be(HttpStatusCode.NotFound); var replayBody=new StringContent(JsonConvert.SerializeObject(new {Id=id,Input=new WorkOrderDeferralInput {Revision=2,DueOn=_maintenanceClock.Utc.AddDays(2),Reason="Approved"}}),Encoding.UTF8,"application/json"); response=await client.PostAsync("/api/v4/WorkOrders/DeferWorkOrder",replayBody); response.StatusCode.Should().Be(HttpStatusCode.OK,await response.Content.ReadAsStringAsync()); + var deadlineOrder = await _store.GetAsync(77, id); + deadlineOrder.ResponseDueOn = new DateTime(2026, 9, 15, 18, 30, 0, DateTimeKind.Utc); + deadlineOrder.RepairDueOn = deadlineOrder.ResponseDueOn.Value.AddHours(4); + await _store.WriteAsync(deadlineOrder); + response = await client.GetAsync("/User/WorkOrders/Operations?id=" + id); + html = WebUtility.HtmlDecode(await response.Content.ReadAsStringAsync()); + response.StatusCode.Should().Be(HttpStatusCode.OK, html); + html.Should().Contain("09/15/2026 11:30:00").And.Contain("09/15/2026 15:30:00").And.NotContain("(UTC)"); foreach (var operationsPage in new[] { "Operations?id="+id, "Policy", "Bulk" }) { response=await client.GetAsync("/User/WorkOrders/"+operationsPage); response.StatusCode.Should().Be(HttpStatusCode.OK,await response.Content.ReadAsStringAsync()); } - var policyFields=new Dictionary { ["Revision"]="0",["SpendingRules[0].Currency"]="EUR",["SpendingRules[0].Threshold"]="100.25",["Calendar.TimeZoneId"]="UTC",["businessStart"]="09:00",["businessEnd"]="17:00",["weekdays"]="2",["Calendar.Targets[0].Priority"]="1",["Calendar.Targets[0].ResponseMinutes"]="60",["Calendar.Targets[0].RepairMinutes"]="480" }; - (await client.PostAsync("/User/WorkOrders/SavePolicy",new FormUrlEncodedContent(policyFields))).StatusCode.Should().Be(HttpStatusCode.BadRequest); + var policyFields=new Dictionary { ["Revision"]="0",["Currency"]="EUR",["SpendingThreshold"]="100.25",["Calendar.TimeZoneId"]="UTC",["businessStart"]="09:00",["businessEnd"]="17:00",["weekdays"]="2",["Calendar.Targets[0].Priority"]="1",["Calendar.Targets[0].ResponseMinutes"]="60",["Calendar.Targets[0].RepairMinutes"]="480" }; + (await client.PostAsync("/User/WorkOrders/SaveSettings",new FormUrlEncodedContent(policyFields))).StatusCode.Should().Be(HttpStatusCode.BadRequest); policyFields["__RequestVerificationToken"]=csrf; - response=await client.PostAsync("/User/WorkOrders/SavePolicy",new FormUrlEncodedContent(policyFields)); response.StatusCode.Should().Be(HttpStatusCode.OK,await response.Content.ReadAsStringAsync()); + response=await client.PostAsync("/User/WorkOrders/SaveSettings",new FormUrlEncodedContent(policyFields)); response.StatusCode.Should().Be(HttpStatusCode.OK,await response.Content.ReadAsStringAsync()); (await _service.PolicyAsync(_actor)).SpendingRules.Single().Threshold.Should().Be(100.25m); response=await client.GetAsync("/User/WorkOrders/Policy"); (await response.Content.ReadAsStringAsync()).Should().Contain("value=\"100.25\"").And.NotContain("value=\"100,25\""); - var csvInput=new { RequestId=Guid.NewGuid().ToString("D"),Csv=Resgrid.Services.WorkOrderCsvImport.Header+"\nSynthetic import,,0,1,,,,,USD,25,," }; + response = await client.GetAsync("/User/WorkOrders/Settings"); + html = await response.Content.ReadAsStringAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK, html); + html.Should().Contain(" +
+ + +
+
+ + +
@@ -100,6 +108,9 @@ window.jQuery = function (root) { try { const page = await browser.newPage(); await page.setContent(markup); + // The theme's .btn display rule used to override the HTML hidden attribute. + await page.addStyleTag({ path: path.join(root, 'Web/Resgrid.Web/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css') }); + await page.addStyleTag({ path: path.join(root, 'Web/Resgrid.Web/wwwroot/css/workspace.css') }); await page.addScriptTag({ content: script }); const result = await page.evaluate(() => { @@ -117,6 +128,7 @@ window.jQuery = function (root) { check(!steps[0].hidden && steps[1].hidden && steps[2].hidden, 'The wizard did not open on its first step.'); check(previous.hidden && !next.hidden && submit.hidden, 'The first step offered Back or Save.'); + check(getComputedStyle(previous).display === 'none' && getComputedStyle(submit).display === 'none', 'Theme styling exposed hidden first-step buttons.'); check(indicator.children.length === 3, 'The step indicator did not list every step.'); check(indicator.children[0].className === 'current' && Array.from(indicator.children).map(li => li.textContent).join('|') === '1What|2How much|3Review', 'The step indicator did not label the steps in order.'); @@ -141,10 +153,14 @@ window.jQuery = function (root) { form.querySelector('#note').value = 'restock'; next.click(); check(!steps[2].hidden && next.hidden && !submit.hidden, 'The last step did not swap Continue for Save.'); + check(getComputedStyle(next).display === 'none' && getComputedStyle(previous).display !== 'none' && getComputedStyle(submit).display !== 'none', 'Final-step buttons were visually incorrect.'); // The review step reads back what was answered, skipping empty and hidden fields. const review = Array.from(form.querySelectorAll('.rgw-review-row')).map(row => row.textContent); - check(review.length === 3, 'The review step did not list every answered field: ' + review.join(' / ')); + check(review.length === 5, 'The review step did not list every answered field: ' + review.join(' / ')); + check(review[3] === 'UsersAlex Smith, Zoe Taylor' && review[4] === 'RolesMechanics, Shift leads', 'Review omitted a selected user or role.'); + check(new FormData(form).getAll('Input.AssignedToUserIds').join(',') === 'a,b', 'Multiple users were not posted.'); + check(new FormData(form).getAll('Input.AssignedToRoleIds').join(',') === '11,12', 'Multiple roles were not posted.'); check(review[0] === 'ItemBandages', 'The review step showed the option value instead of its label: ' + review[0]); check(review[1] === 'Amount4' && review[2] === 'Noterestock', 'The review step lost a later answer: ' + review.join(' / ')); check(!review.some(row => row.includes('abc')), 'The review step exposed a hidden field.'); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/WorkOrderMaintenanceController.cs b/Web/Resgrid.Web.Services/Controllers/v4/WorkOrderMaintenanceController.cs index c139fde21..6ae6cb025 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/WorkOrderMaintenanceController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/WorkOrderMaintenanceController.cs @@ -4,8 +4,8 @@ using Resgrid.Model.WorkOrders; namespace Resgrid.Web.Services.Controllers.v4 { - public sealed class WorkOrderMaintenanceCommand { public int Id { get; set; } public T Input { get; set; } } - public sealed class WorkOrderCancelPartWitness { public int Id { get; set; } public int PartId { get; set; } public int Revision { get; set; } public string Reason { get; set; } } + public sealed class WorkOrderMaintenanceCommand { public string Id { get; set; } public T Input { get; set; } } + public sealed class WorkOrderCancelPartWitness { public string Id { get; set; } public string PartId { get; set; } public int Revision { get; set; } public string Reason { get; set; } } public sealed partial class WorkOrdersController { private void MaintenanceAvailable() { if (_maintenance == null) throw new WorkOrderException(503, "MaintenanceUnavailable"); } @@ -14,7 +14,7 @@ public sealed partial class WorkOrdersController [HttpGet("GetWorkOrderInventoryChoices")] public async Task GetWorkOrderInventoryChoices(string kind, string itemId = null, int page = 0) { MaintenanceAvailable(); return Reply(await _maintenance.InventoryChoicesAsync(Actor, kind, itemId, page)); } [HttpGet("GetWorkOrderHolds")] - public async Task GetWorkOrderHolds(int id, int afterId = 0) { if (_reports == null) throw new WorkOrderException(503, "MaintenanceUnavailable"); var page = await _reports.GetWorkOrderHoldsAsync(Actor, id, afterId); return Reply(page.Items, page.Items.Count, page.NextAfterId.HasValue); } + public async Task GetWorkOrderHolds(string id, string afterId = null) { if (_reports == null) throw new WorkOrderException(503, "MaintenanceUnavailable"); var page = await _reports.GetWorkOrderHoldsAsync(Actor, id, afterId); return Reply(page.Items, page.Items.Count, page.NextAfterId != null); } [HttpPost("AddWorkOrderHold")] public async Task AddWorkOrderHold([FromBody] WorkOrderMaintenanceCommand command) { MaintenanceAvailable(); Required(command); await _maintenance.AddHoldAsync(Actor, command.Id, Required(command.Input)); return Reply(await _maintenance.HoldsAsync(Actor, command.Id)); } [HttpPost("ReleaseWorkOrderHold")] @@ -22,7 +22,7 @@ public sealed partial class WorkOrdersController [HttpGet("GetWorkOrderRecurrences")] public async Task GetWorkOrderRecurrences(int page = 0) { MaintenanceAvailable(); var rows = await _maintenance.RecurrencesAsync(Actor, page); return Reply(rows.Take(50), System.Math.Min(rows.Count, 50), rows.Count > 50); } [HttpGet("GetWorkOrderRecurrence")] - public async Task GetWorkOrderRecurrence(int id, int historyPage = 0) { MaintenanceAvailable(); return Reply(await _maintenance.RecurrenceAsync(Actor, id, historyPage)); } + public async Task GetWorkOrderRecurrence(string id, int historyPage = 0) { MaintenanceAvailable(); return Reply(await _maintenance.RecurrenceAsync(Actor, id, historyPage)); } [HttpPost("SaveWorkOrderRecurrence")] public async Task SaveWorkOrderRecurrence([FromBody] WorkOrderRecurrenceInput input) { MaintenanceAvailable(); var id = await _maintenance.SaveRecurrenceAsync(Actor, Required(input)); return Reply(await _maintenance.RecurrenceAsync(Actor, id)); } [HttpPost("RecordWorkOrderReading")] diff --git a/Web/Resgrid.Web.Services/Controllers/v4/WorkOrderOperationsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/WorkOrderOperationsController.cs index 8a45a7dba..77d677c9f 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/WorkOrderOperationsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/WorkOrderOperationsController.cs @@ -12,8 +12,10 @@ public sealed partial class WorkOrdersController { private IWorkOrderOperationsService OperationsService => _orders as IWorkOrderOperationsService ?? throw new WorkOrderException(503, "MaintenanceUnavailable"); [HttpGet("GetWorkOrderPolicy")] + [HttpGet("GetWorkOrderSettings")] public async Task GetWorkOrderPolicy() => Reply(await OperationsService.PolicyAsync(Actor)); [HttpPost("SaveWorkOrderPolicy")] + [HttpPost("SaveWorkOrderSettings")] public async Task SaveWorkOrderPolicy([FromBody] WorkOrderPolicyInput input) { await OperationsService.SavePolicyAsync(Actor, Required(input)); return Reply(await OperationsService.PolicyAsync(Actor)); } [HttpPost("PreviewWorkOrderBatch")] public async Task PreviewWorkOrderBatch([FromBody] WorkOrderBulkInput input) => Reply(await OperationsService.PreviewBulkAsync(Actor, Required(input))); @@ -26,7 +28,7 @@ public sealed partial class WorkOrdersController [HttpPost("DecideWorkOrderApproval")] public async Task DecideWorkOrderApproval([FromBody] WorkOrderOperationsInput input) { Required(input); await OperationsService.DecideApprovalAsync(Actor, input.Id, Required(input.Input)); return Reply(await _orders.GetAsync(Actor, input.Id)); } [HttpGet("GetWorkOrderVendorCharges")] - public async Task GetWorkOrderVendorCharges(int id, int afterId = 0) { var rows = await OperationsService.VendorChargesAsync(Actor, id, afterId); return Reply(rows, rows.Count, rows.Count == 50); } + public async Task GetWorkOrderVendorCharges(string id, string afterId = null) { var rows = await OperationsService.VendorChargesAsync(Actor, id, afterId); return Reply(rows, rows.Count, rows.Count == 50); } [HttpPost("AddWorkOrderVendorCharge")] public async Task AddWorkOrderVendorCharge([FromBody] WorkOrderOperationsInput input) { Required(input); await OperationsService.AddVendorChargeAsync(Actor, input.Id, Required(input.Input)); return Reply(await _orders.GetAsync(Actor, input.Id)); } [HttpPost("VoidWorkOrderVendorCharge")] @@ -36,10 +38,10 @@ public sealed partial class WorkOrdersController [HttpPost("MoveWorkOrderPart")] public async Task MoveWorkOrderPart([FromBody] WorkOrderOperationsInput input) { Required(input); await OperationsService.MovePartAsync(Actor, input.Id, input.ChildId, Required(input.Input)); return Reply(await _orders.GetAsync(Actor, input.Id)); } [HttpGet("GetWorkOrderPartMovements")] - public async Task GetWorkOrderPartMovements(int id, int afterId = 0) { var rows = await OperationsService.PartMovementsAsync(Actor, id, afterId); return Reply(rows, rows.Count, rows.Count == 50); } + public async Task GetWorkOrderPartMovements(string id, string afterId = null) { var rows = await OperationsService.PartMovementsAsync(Actor, id, afterId); return Reply(rows, rows.Count, rows.Count == 50); } [HttpPost("CancelWorkOrderPartMovement")] public async Task CancelWorkOrderPartMovement([FromBody] WorkOrderCommandInput input) { Required(input); await OperationsService.CancelPartMovementAsync(Actor, input.Id, input.ChildId, input.Revision, input.Note); return Reply(await _orders.GetAsync(Actor, input.Id)); } } - public sealed class WorkOrderOperationsInput where T : class { public int Id { get; set; } public int ChildId { get; set; } public T Input { get; set; } } + public sealed class WorkOrderOperationsInput where T : class { public string Id { get; set; } public string ChildId { get; set; } public T Input { get; set; } } public sealed class WorkOrderCsvInput { public string RequestId { get; set; } public string Csv { get; set; } } } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/WorkOrderReportingController.cs b/Web/Resgrid.Web.Services/Controllers/v4/WorkOrderReportingController.cs index d03f98a62..398bed925 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/WorkOrderReportingController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/WorkOrderReportingController.cs @@ -17,7 +17,7 @@ public async Task GetWorkOrderServiceHistory([FromQuery] WorkOrde { if (_reports == null) throw new WorkOrderException(503, "MaintenanceUnavailable"); var page = await _reports.GetWorkOrderHistoryAsync(Actor, query); - return Reply(page, page.Items.Count, page.NextAfterId.HasValue); + return Reply(page, page.Items.Count, page.NextAfterId != null); } [HttpPost("ExportWorkOrderHistory")] public async Task ExportWorkOrderHistory([FromBody] WorkOrderReportQuery query) diff --git a/Web/Resgrid.Web.Services/Controllers/v4/WorkOrdersController.cs b/Web/Resgrid.Web.Services/Controllers/v4/WorkOrdersController.cs index 97fb12b59..d64d5c4c2 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/WorkOrdersController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/WorkOrdersController.cs @@ -40,11 +40,11 @@ private ObjectResult Failure(WorkOrderException ex) [HttpGet("GetWorkOrders")] public async Task GetWorkOrders([FromQuery] WorkOrderFilter filter) { var page = await _orders.ListAsync(Actor, filter); return Reply(page, page.Items.Count, page.HasMore); } [HttpGet("GetWorkOrder")] - public async Task GetWorkOrder(int id) => Reply(await _orders.GetAsync(Actor, id)); + public async Task GetWorkOrder(string id) => Reply(await _orders.GetAsync(Actor, id)); [HttpGet("GetWorkOrderChoices")] public async Task GetWorkOrderChoices() => Reply(await _orders.ChoicesAsync(Actor)); [HttpGet("GetWorkOrderActivity")] - public async Task GetWorkOrderActivity(int id, int afterId = 0) { if (_reports == null) throw new WorkOrderException(503, "MaintenanceUnavailable"); var page = await _reports.GetWorkOrderActivityAsync(Actor, id, afterId); return Reply(page.Items, page.Items.Count, page.NextAfterId.HasValue); } + public async Task GetWorkOrderActivity(string id, string afterId = null) { if (_reports == null) throw new WorkOrderException(503, "MaintenanceUnavailable"); var page = await _reports.GetWorkOrderActivityAsync(Actor, id, afterId); return Reply(page.Items, page.Items.Count, page.NextAfterId != null); } [HttpGet("GetWorkOrderHistoryForAsset")] public Task GetWorkOrderHistoryForAsset(string assetId, int page = 0) { if (string.IsNullOrWhiteSpace(assetId)) throw new WorkOrderException(400, "InvalidInput"); return GetWorkOrders(new WorkOrderFilter { AssetId = assetId, Page = page }); } [HttpPost("NewWorkOrder")] @@ -66,13 +66,13 @@ private ObjectResult Failure(WorkOrderException ex) [HttpPost("RemoveWorkOrderPart")] public async Task RemoveWorkOrderPart([FromBody] WorkOrderCommandInput input) { Required(input); await _orders.VoidPartAsync(Actor, input.Id, input.ChildId, input.Revision, input.Note); return Reply(await _orders.GetAsync(Actor, input.Id)); } [HttpPost("UploadWorkOrderFile"), RequestSizeLimit(11 * 1024 * 1024)] - public async Task UploadWorkOrderFile([FromForm] int id, [FromForm] int revision, IFormFile file) + public async Task UploadWorkOrderFile([FromForm] string id, [FromForm] int revision, IFormFile file) { if (file == null || file.Length > 10 * 1024 * 1024) throw new WorkOrderException(400, "InvalidFile"); using var buffer = new MemoryStream(); await file.CopyToAsync(buffer); await _orders.AddFileAsync(Actor, id, revision, file.FileName, file.ContentType, buffer.ToArray()); return Reply(await _orders.GetAsync(Actor, id)); } [HttpGet("GetWorkOrderFile")] - public async Task GetWorkOrderFile(int id) { var file = await _orders.GetFileAsync(Actor, id); Response.Headers["X-Content-Type-Options"] = "nosniff"; return File(file.Data, file.ContentType, file.Content); } + public async Task GetWorkOrderFile(string id) { var file = await _orders.GetFileAsync(Actor, id); Response.Headers["X-Content-Type-Options"] = "nosniff"; return File(file.Data, file.ContentType, file.Content); } [HttpPost("RemoveWorkOrderFile")] public async Task RemoveWorkOrderFile([FromBody] WorkOrderCommandInput input) { Required(input); await _orders.WithdrawFileAsync(Actor, input.Id, input.ChildId, input.Revision, input.Note); return Reply(await _orders.GetAsync(Actor, input.Id)); } } diff --git a/Web/Resgrid.Web.Services/Models/v4/WorkOrders/WorkOrderApiContracts.cs b/Web/Resgrid.Web.Services/Models/v4/WorkOrders/WorkOrderApiContracts.cs index b5e5ec1b8..071e7ba3b 100644 --- a/Web/Resgrid.Web.Services/Models/v4/WorkOrders/WorkOrderApiContracts.cs +++ b/Web/Resgrid.Web.Services/Models/v4/WorkOrders/WorkOrderApiContracts.cs @@ -3,10 +3,10 @@ namespace Resgrid.Web.Services.Models.v4.WorkOrders { public sealed class WorkOrderApiResult : StandardApiResponseV4Base { public T Data { get; set; } public bool HasMore { get; set; } public int ContractVersion { get; set; } = 1; } - public sealed class WorkOrderCommandInput { public int Id { get; set; } public int Revision { get; set; } public int ChildId { get; set; } public string Note { get; set; } } - public sealed class WorkOrderUpdateInput { public int Id { get; set; } public WorkOrderInput Input { get; set; } } - public sealed class WorkOrderStatusInput { public int Id { get; set; } public WorkOrderTransition Input { get; set; } } - public sealed class WorkOrderAssignInput { public int Id { get; set; } public WorkOrderAssignment Input { get; set; } } - public sealed class WorkOrderApiLaborInput { public int Id { get; set; } public WorkOrderLaborInput Input { get; set; } } - public sealed class WorkOrderApiPartInput { public int Id { get; set; } public WorkOrderPartInput Input { get; set; } } + public sealed class WorkOrderCommandInput { public string Id { get; set; } public int Revision { get; set; } public string ChildId { get; set; } public string Note { get; set; } } + public sealed class WorkOrderUpdateInput { public string Id { get; set; } public WorkOrderInput Input { get; set; } } + public sealed class WorkOrderStatusInput { public string Id { get; set; } public WorkOrderTransition Input { get; set; } } + public sealed class WorkOrderAssignInput { public string Id { get; set; } public WorkOrderAssignment Input { get; set; } } + public sealed class WorkOrderApiLaborInput { public string Id { get; set; } public WorkOrderLaborInput Input { get; set; } } + public sealed class WorkOrderApiPartInput { public string Id { get; set; } public WorkOrderPartInput Input { get; set; } } } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/BidsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/BidsController.cs index 7a3cd03a4..27e0067c7 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/BidsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/BidsController.cs @@ -25,6 +25,7 @@ namespace Resgrid.Web.Areas.User.Controllers /// Bids_Create/Update/Delete (admins by default) edit. Bids are customer-facing and not under Advanced Data Protection. /// [Area("User"), Authorize, ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + [Resgrid.Web.Helpers.DepartmentLocalTime] public sealed class BidsController : SecureBaseController { // Line descriptions, entry and premium names are user text rendered inside a " out of the page. diff --git a/Web/Resgrid.Web/Areas/User/Controllers/CalOesMarsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/CalOesMarsController.cs index ec45aa1b4..6f3998e03 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/CalOesMarsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/CalOesMarsController.cs @@ -28,6 +28,7 @@ namespace Resgrid.Web.Areas.User.Controllers /// expense drafts. The UI says "Prepared for MARS" or "Observed in MARS", never "Submitted" from a download. /// [Area("User"), Authorize, ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + [Resgrid.Web.Helpers.DepartmentLocalTime] public sealed class CalOesMarsController : SecureBaseController { private readonly ICalOesMarsService _mars; @@ -131,7 +132,7 @@ private async Task GuardedAsync(Func> action, [HttpGet] public async Task Index(DateTime? asOf = null) { - var view = Page(new CalOesMarsDashboardView { AsOf = (asOf ?? DateTime.UtcNow).Date }); + var view = Page(new CalOesMarsDashboardView { AsOf = (asOf ?? Resgrid.Web.Helpers.DepartmentTime.From(ViewData).Today).Date }); view.Readiness = await _mars.GetAgencyReadinessAsync(DepartmentId, view.AsOf); view.Authority = CalOesMarsAuthorityProfile.Get(view.Readiness.AuthorityProfileCode) ?? CalOesMarsAuthorityProfile.Current; return View(view); @@ -229,7 +230,7 @@ public async Task Rate(string id = null, int? type = null) view.WorkforceEnabled = await _access.CanUseWorkforceAsync(DepartmentId); view.NextStatuses = Enum.GetValues().Where(s => Resgrid.Services.CostRecovery.CalOesMarsService.IsValidRateTransition((CalOesMarsRateProfileStatuses)view.Profile.Status, s)).ToList(); } - else view.Profile = new CalOesMarsRateProfile { DepartmentId = DepartmentId, SubmissionYear = DateTime.UtcNow.Year, SubmissionType = type ?? (int)CalOesMarsSubmissionTypes.SalarySurvey, EffectiveOn = new DateTime(DateTime.UtcNow.Year, 1, 1) }; + else view.Profile = new CalOesMarsRateProfile { DepartmentId = DepartmentId, SubmissionYear = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).Today.Year, SubmissionType = type ?? (int)CalOesMarsSubmissionTypes.SalarySurvey, EffectiveOn = new DateTime(Resgrid.Web.Helpers.DepartmentTime.From(ViewData).Today.Year, 1, 1) }; view.LinesJson = JsonConvert.SerializeObject(view.Profile.Lines ?? new List(), ScriptJson); view.InputsJson = JsonConvert.SerializeObject((view.Profile.AdministrativeInputs ?? new List()).Select(i => new { i.CalOesMarsAdministrativeRateInputId, i.FiscalYear, i.FunctionCode, i.CategoryCode, i.Classification, Amount = i.Amount, i.SourceSystem, i.SourceLine, i.IncidentDirectExclusion, i.DoubleCountMarker, i.ReviewStatus, i.ReviewReason }), ScriptJson); return View(view); @@ -303,7 +304,7 @@ public Task BuildSalarySurvey(string id, DateTime? asOf) => Guard { if (!IsManager) return Unauthorized(); if (!await _access.CanUseWorkforceAsync(DepartmentId)) return Refused(403, "workforce_disabled", "Rate", new { id }); - var draft = await _mars.BuildSalarySurveyDraftAsync(id, DepartmentId, asOf ?? DateTime.UtcNow.Date, UserId, Ip, Agent); + var draft = await _mars.BuildSalarySurveyDraftAsync(id, DepartmentId, asOf ?? Resgrid.Web.Helpers.DepartmentTime.From(ViewData).Today, UserId, Ip, Agent); if (!draft.IsReady) { TempData["CalOesMarsMessage"] = string.Join(" ", draft.Blockers.Select(b => ErrorText("SurveyBlocker_" + b))); return RedirectToAction("Rate", new { id }); } TempData["CalOesMarsSaved"] = true; TempData["CalOesMarsMessage"] = string.Format(_strings["SalarySurveyDraftBuilt"].Value, draft.LinesWritten, draft.EmployeesIncluded, draft.UnknownClassifications.Count, draft.Classifications.Count(c => c.SingleEmployee)); @@ -441,7 +442,13 @@ public Task SaveF42(string id, string snapshotJson) => GuardedAsy if (item == null) return NotFound(); if (!IsManager && !rostered) return Unauthorized(); var snapshot = JsonConvert.DeserializeObject(snapshotJson ?? "{}") ?? new CalOesMarsF42Snapshot(); - await _mars.SaveF42SnapshotAsync(id, DepartmentId, snapshot, UserId, Ip, Agent); + var time = Resgrid.Web.Helpers.DepartmentTime.From(ViewData); + snapshot.ReturnedOn = time.ToUtc(snapshot.ReturnedOn); + snapshot.RespondingSignedOn = time.ToUtc(snapshot.RespondingSignedOn); + snapshot.IncidentAuthorizedOn = time.ToUtc(snapshot.IncidentAuthorizedOn); + foreach (var person in snapshot.Personnel ?? new List()) + { person.CommittedOn = time.ToUtc(person.CommittedOn); person.ReleasedOn = time.ToUtc(person.ReleasedOn); } + await _mars.SaveF42SnapshotAsync(id, DepartmentId, snapshot, UserId, Ip, Agent); return Saved("WorkItem", new { id }); }, "WorkItem", new { id }); @@ -451,7 +458,8 @@ public Task SaveExpense(string id, CalOesMarsExpenseClaimSnapshot var (item, rostered) = await LoadItemAsync(id); if (item == null) return NotFound(); if (!IsManager && !rostered) return Unauthorized(); - await _mars.SaveExpenseSnapshotAsync(id, DepartmentId, input ?? new CalOesMarsExpenseClaimSnapshot(), UserId, Ip, Agent); + if (input != null) { input.SignedOn = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ToUtc(input.SignedOn); input.ApprovedOn = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ToUtc(input.ApprovedOn); } + await _mars.SaveExpenseSnapshotAsync(id, DepartmentId, input ?? new CalOesMarsExpenseClaimSnapshot(), UserId, Ip, Agent); return Saved("WorkItem", new { id }); }, "WorkItem", new { id }); @@ -565,7 +573,7 @@ public Task RecordInvoice(CalOesMarsInvoiceInput input) => Guarde var invoice = await _mars.RecordMarsInvoiceAsync(DepartmentId, input.DeploymentId, new CalOesMarsInvoiceObservation { MarsInvoiceId = input.MarsInvoiceId, InvoiceDate = input.InvoiceDate, InvoicedTotal = input.InvoicedTotal, PayingEntity = input.PayingEntity, ExternalStatus = input.ExternalStatus, - ObservedOn = input.ObservedOn, CoveredWorkItemIds = input.CoveredWorkItemIds ?? new List(), Comment = input.Comment + ObservedOn = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ToUtc(input.ObservedOn), CoveredWorkItemIds = input.CoveredWorkItemIds ?? new List(), Comment = input.Comment }, UserId, Ip, Agent); return Saved("Invoice", new { id = invoice.CalOesMarsWorkItemId }); }, "Reconciliation"); @@ -591,15 +599,15 @@ public Task DecideInvoice(string id, bool approve, string decisio public Task RecordPayment(string id, CalOesMarsPaymentInput input) => GuardedAsync(async () => { if (!CanReconcile) return Unauthorized(); - await _mars.RecordPaymentAsync(id, DepartmentId, new CalOesMarsPaymentObservation { PaidTotal = input.PaidTotal, PaidOn = input.PaidOn, PaymentReference = input.PaymentReference, PayingEntityStatus = input.PayingEntityStatus, Comment = input.Comment }, UserId, Ip, Agent); + await _mars.RecordPaymentAsync(id, DepartmentId, new CalOesMarsPaymentObservation { PaidTotal = input.PaidTotal, PaidOn = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ToUtc(input.PaidOn), PaymentReference = input.PaymentReference, PayingEntityStatus = input.PayingEntityStatus, Comment = input.Comment }, UserId, Ip, Agent); return Saved("Invoice", new { id }); }, "Invoice", new { id }); #endregion - private static CalOesMarsExternalObservation ToObservation(CalOesMarsObservationInput input) => new CalOesMarsExternalObservation + private CalOesMarsExternalObservation ToObservation(CalOesMarsObservationInput input) => new CalOesMarsExternalObservation { - ExternalId = input?.ExternalId, ExternalStatus = input?.ExternalStatus, ObservedOn = input?.ObservedOn, Comment = input?.Comment, ArtifactChecksum = input?.ArtifactChecksum, ArtifactAttachmentId = input?.ArtifactAttachmentId + ExternalId = input?.ExternalId, ExternalStatus = input?.ExternalStatus, ObservedOn = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ToUtc(input?.ObservedOn), Comment = input?.Comment, ArtifactChecksum = input?.ArtifactChecksum, ArtifactAttachmentId = input?.ArtifactAttachmentId }; } } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/CertificationsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/CertificationsController.cs index a823feac8..d58191d34 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/CertificationsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/CertificationsController.cs @@ -28,6 +28,7 @@ namespace Resgrid.Web.Areas.User.Controllers /// administrators as the fallback (plan D8). Protected values read REDACTED without a grant. /// [Area("User"), Authorize, ResponseCache(NoStore = true, Location = ResponseCacheLocation.None), RequestSizeLimit(12 * 1024 * 1024)] + [Resgrid.Web.Helpers.DepartmentLocalTime] public sealed class CertificationsController : SecureBaseController { private static readonly string[] AllowedExtensions = { "jpg", "jpeg", "png", "gif", "pdf", "doc", "docx", "txt", "xls", "xlsx" }; @@ -554,7 +555,7 @@ public async Task Record(int id) HolderName = names.TryGetValue(record.UserId, out var holder) ? holder : record.UserId, VerifiedByName = record.VerifiedByUserId != null && names.TryGetValue(record.VerifiedByUserId, out var verifier) ? verifier : record.VerifiedByUserId, Credits = await _certifications.GetCertificationCreditsAsync(id), - DaysUntilExpiry = CertificationRequirementEvaluator.DaysUntilExpiry(record.ExpiresOn, type?.NeverExpires == true, DateTime.UtcNow.Date) + DaysUntilExpiry = CertificationRequirementEvaluator.DaysUntilExpiry(record.ExpiresOn, type?.NeverExpires == true, Resgrid.Web.Helpers.DepartmentTime.From(ViewData).Today) }); return View(view); } @@ -621,7 +622,7 @@ public async Task AddCredit(CertificationCreditInput input, IForm { await _certifications.AddCertificationCreditAsync(new PersonnelCertificationCredit { - PersonnelCertificationId = input.PersonnelCertificationId, DepartmentId = DepartmentId, CreditDate = input.CreditDate ?? DateTime.UtcNow.Date, Hours = input.Hours, + PersonnelCertificationId = input.PersonnelCertificationId, DepartmentId = DepartmentId, CreditDate = input.CreditDate ?? Resgrid.Web.Helpers.DepartmentTime.From(ViewData).Today, Hours = input.Hours, Category = input.Category, Description = input.Description, Data = upload.Data, FileName = upload.FileName, FileType = upload.FileType }, UserId, cancellationToken); return Saved(nameof(Record), new { id = input.PersonnelCertificationId }); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ChecklistReportingController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ChecklistReportingController.cs index d36f6c3f9..7fca23948 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ChecklistReportingController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ChecklistReportingController.cs @@ -1,5 +1,7 @@ using System; using System.IO; +using System.Linq; +using Resgrid.Web.Helpers; using System.IO.Compression; using System.Text; using System.Threading.Tasks; @@ -13,17 +15,33 @@ namespace Resgrid.Web.Areas.User.Controllers public partial class ChecklistsController { [HttpGet] - public Task Compliance() => ComplianceView(new ChecklistReportQuery { FromUtc = DateTime.UtcNow.Date.AddDays(-30), UntilUtc = DateTime.UtcNow.Date.AddDays(1) }); + public Task Compliance() => ComplianceView(new ChecklistReportQuery { FromUtc = DepartmentTime.From(ViewData).Today.AddDays(-30), UntilUtc = DepartmentTime.From(ViewData).Today.AddDays(1) }); [HttpPost, ValidateAntiForgeryToken] public Task Compliance(ChecklistReportQuery query) => ComplianceView(query); private async Task ComplianceView(ChecklistReportQuery query) { - try { return View("Compliance", await _checklists.GetComplianceSummaryAsync(Actor, query)); } + var time = DepartmentTime.From(ViewData); + query.FromUtc = time.ToUtc(query.FromUtc); query.UntilUtc = time.ToUtc(query.UntilUtc); + try + { + var report = await _checklists.GetComplianceSummaryAsync(Actor, query); + // Buckets are local calendar days; shifting UTC midnight would mislabel the day. + report.Trend = report.Entries.Where(e => e.Expected && e.DueUtc.HasValue) + .GroupBy(e => time.Local(e.DueUtc.Value).Date) + .Select(g => new ChecklistMissedTrend { DayUtc = g.Key, Expected = g.Count(), Missed = g.Count(e => e.Missed) }) + .OrderBy(g => g.DayUtc).ToList(); + return View("Compliance", report); + } catch (ChecklistException ex) when (ex.StatusCode == 403 && ex.Message.StartsWith("Unlock", StringComparison.Ordinal)) { ViewBag.ProtectionEnforced = true; return View("Compliance", new ChecklistComplianceSummary { FromUtc = query.FromUtc, UntilUtc = query.UntilUtc, TargetType = query.TargetType, TargetId = query.TargetId, IsRedacted = true }); } } [HttpPost, ValidateAntiForgeryToken] - public async Task ComplianceCsv(ChecklistReportQuery query) => File(ChecklistReportDocuments.Csv(await _checklists.GetComplianceSummaryAsync(Actor, query)), "text/csv; charset=utf-8", "checklist-compliance.csv"); + public async Task ComplianceCsv(ChecklistReportQuery query) + { + query.FromUtc = DepartmentTime.From(ViewData).ToUtc(query.FromUtc); + query.UntilUtc = DepartmentTime.From(ViewData).ToUtc(query.UntilUtc); + return File(ChecklistReportDocuments.Csv(await _checklists.GetComplianceSummaryAsync(Actor, query)), "text/csv; charset=utf-8", "checklist-compliance.csv"); + } [HttpGet] public IActionResult ReadinessPacket() => View("ReadinessPacket"); [HttpPost, ValidateAntiForgeryToken] @@ -36,7 +54,7 @@ public async Task ReadinessPacket(int callId, int lookbackDays = public async Task ReadinessPacketDownload(int callId, int lookbackDays, [FromServices] IPdfProvider pdf) { var manifest = await _checklists.GetReadinessPacketForCallAsync(Actor, callId, lookbackDays); - var package = ChecklistReportDocuments.Package(manifest, pdf); + var package = ChecklistReportDocuments.Package(manifest, pdf, DepartmentTime.From(ViewData).Format); // Revalidate the attended source after potentially slow PDF conversion. var current = await _checklists.GetReadinessPacketForCallAsync(Actor, callId, lookbackDays); ChecklistReportDocuments.EnsureStillAuthorized(manifest, current); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ChecklistsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ChecklistsController.cs index cfe1563e1..48cad1128 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ChecklistsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ChecklistsController.cs @@ -19,6 +19,7 @@ namespace Resgrid.Web.Areas.User.Controllers { [Area("User")] [Authorize] + [Resgrid.Web.Helpers.DepartmentLocalTime] public partial class ChecklistsController : SecureBaseController { private readonly IChecklistTemplateService _templates; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ChecklistsSchedulingController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ChecklistsSchedulingController.cs index f1f2ee324..5e8f105a0 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ChecklistsSchedulingController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ChecklistsSchedulingController.cs @@ -37,8 +37,8 @@ public async Task EditSchedule(string id = null, string definitio { input.Name = definition.PublishedForm.Name; var department = _departments == null ? null : await _departments.Value.GetDepartmentByIdAsync(DepartmentId); - input.TimeZoneId = department?.TimeZone ?? "UTC"; - try { input.StartDate = TimeZoneInfo.ConvertTimeBySystemTimeZoneId(DateTime.UtcNow, input.TimeZoneId).Date; } catch (TimeZoneNotFoundException) { input.TimeZoneId = "UTC"; } + input.TimeZoneId = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ZoneId; + input.StartDate = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).Today; } var model = new ChecklistScheduleEditView { Input = input, Targets = await _checklists.TargetsAsync(Actor, definition.PublishedForm.TargetType), Assignments = await _checklists.AssignmentChoicesAsync(Actor) }; if (_workshifts != null) model.Workshifts = (await _workshifts.Value.GetAllWorkshiftsByDepartmentAsync(DepartmentId)).Where(s => !s.DeletedOn.HasValue).Select(s => new ChecklistWorkshiftChoice { Id = s.WorkshiftId, Name = s.Name }).ToList(); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ContractsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ContractsController.cs index 5792f0d80..563f1322e 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ContractsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ContractsController.cs @@ -28,6 +28,7 @@ namespace Resgrid.Web.Areas.User.Controllers /// default) edits. Contracts and compliance documents are customer-facing and not under Advanced Data Protection. /// [Area("User"), Authorize, ResponseCache(NoStore = true, Location = ResponseCacheLocation.None), RequestSizeLimit(32 * 1024 * 1024)] + [Resgrid.Web.Helpers.DepartmentLocalTime] public sealed class ContractsController : SecureBaseController { private static readonly string[] AllowedExtensions = { "jpg", "jpeg", "png", "gif", "pdf", "doc", "docx", "txt", "xls", "xlsx", "csv", "heic" }; @@ -140,7 +141,7 @@ public async Task Index(int? status = null) public async Task New(string contactId = null) { if (!CanManage) return Unauthorized(); - var view = Page(new ContractEditView { Contract = new ServiceContract { DepartmentId = DepartmentId, ContactId = contactId, StartOn = DateTime.UtcNow.Date, TermsNetDays = 30 } }); + var view = Page(new ContractEditView { Contract = new ServiceContract { DepartmentId = DepartmentId, ContactId = contactId, StartOn = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).Today, TermsNetDays = 30 } }); await FillLookupsAsync(view); return View("Edit", view); } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DeploymentWizardController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DeploymentWizardController.cs index 184c1c686..782cfe19d 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DeploymentWizardController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DeploymentWizardController.cs @@ -26,6 +26,7 @@ namespace Resgrid.Web.Areas.User.Controllers /// Deployments_Update (admins by default) plus the Invoicing.ContractorBilling entitlement. /// [Area("User"), Authorize, ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + [Resgrid.Web.Helpers.DepartmentLocalTime] public sealed class DeploymentWizardController : SecureBaseController { // Bid, contract, schedule and roster names are user text rendered inside a " out of the page. @@ -188,16 +189,9 @@ public async Task Create(WizardSubmitInput input, CancellationTok if (request == null) return BadRequest(); request.BidId = input.BidId; // The wizard's window is typed in the department's local time (datetime-local); the deployment stores UTC, like Deployments/Edit. - var timeZone = (await _departments.GetDepartmentByIdAsync(DepartmentId))?.TimeZone; - if (!string.IsNullOrWhiteSpace(timeZone)) - { - try - { - if (request.StartOn.HasValue) request.StartOn = DateTimeHelpers.ConvertToUtc(request.StartOn.Value, timeZone, lenient: true); - if (request.EndOn.HasValue) request.EndOn = DateTimeHelpers.ConvertToUtc(request.EndOn.Value, timeZone, lenient: true); - } - catch (Exception ex) { Logging.LogException(ex, "Deployment wizard: window time zone conversion failed; values kept as entered."); } - } + var time = Resgrid.Web.Helpers.DepartmentTime.From(ViewData); + request.StartOn = time.ToUtc(request.StartOn); + request.EndOn = time.ToUtc(request.EndOn); try { var result = await _bids.ConvertBidToDeploymentAsync(request, DepartmentId, UserId, Ip, Agent, cancellationToken); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DeploymentsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DeploymentsController.cs index d798300b4..ee6c53407 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DeploymentsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DeploymentsController.cs @@ -28,6 +28,7 @@ namespace Resgrid.Web.Areas.User.Controllers /// they are seated on and files time and expenses there. Approval and void need TimeReports_Approve. /// [Area("User"), Authorize, ResponseCache(NoStore = true, Location = ResponseCacheLocation.None), RequestSizeLimit(32 * 1024 * 1024)] + [Resgrid.Web.Helpers.DepartmentLocalTime] public sealed class DeploymentsController : SecureBaseController { private static readonly string[] AllowedExtensions = { "jpg", "jpeg", "png", "gif", "pdf", "doc", "docx", "txt", "xls", "xlsx", "csv", "heic" }; @@ -264,21 +265,23 @@ public async Task Delete(string id, CancellationToken cancellatio catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(400, ex.Message, "View", new { id }); } } - // The window is entered and shown in the deployment's local time zone (its LocalTimeZoneId, else the department's), like Calendar; storage is UTC. - private static string WindowTimeZone(string deploymentTimeZone, string departmentTimeZone) => string.IsNullOrWhiteSpace(deploymentTimeZone) ? departmentTimeZone : deploymentTimeZone; + // UI windows use the department time zone; deployment accounting retains its own zone. Storage is UTC. + private static string WindowTimeZone(string deploymentTimeZone, string departmentTimeZone) => string.IsNullOrWhiteSpace(departmentTimeZone) ? "Pacific Standard Time" : departmentTimeZone; private static DateTime? ToLocal(DateTime? utc, string timeZone) { if (!utc.HasValue) return null; - try { return DateTimeHelpers.GetLocalDateTime(utc.Value, timeZone); } + try { return new DepartmentTime(new Department { TimeZone = timeZone }).Local(utc.Value); } catch (Exception) { return utc; } } + // A zone that cannot be resolved is a validation error (deployments_timezone_invalid, re-rendering the submitted form); + // storing the local clock value as UTC would shift the window by the zone offset without anyone noticing. private static DateTime? ToUtc(DateTime? local, string timeZone) { if (!local.HasValue) return null; - try { return DateTimeHelpers.ConvertToUtc(local.Value, timeZone, lenient: true); } - catch (Exception) { return DateTime.SpecifyKind(local.Value, DateTimeKind.Utc); } + try { return new DepartmentTime(new Department { TimeZone = timeZone }).ToUtc(local.Value); } + catch (Exception) { throw new InvalidOperationException("deployments_timezone_invalid"); } } private static DeploymentInput ToInput(Deployment d, string departmentTimeZone) => new DeploymentInput @@ -593,7 +596,7 @@ private async Task BuildTimeReportViewAsync(DeploymentTimeRe { var view = Page(new TimeReportEditView { Report = report, Deployment = deployment, IsRostered = deployment.Personnel.Any(p => p.UserId == UserId) }); view.Department = await _departments.GetDepartmentByIdAsync(DepartmentId); - view.TimeZone = string.IsNullOrWhiteSpace(deployment.LocalTimeZoneId) ? view.Department?.TimeZone : deployment.LocalTimeZoneId; + view.TimeZone = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ZoneId; foreach (var p in deployment.Personnel) { view.SubjectNames[p.DeploymentPersonnelId] = p.DisplayName ?? p.UserId; if (p.IsActive) view.Subjects.Add((p.DeploymentPersonnelId, (int)DeploymentTimeSubjectTypes.Personnel, p.DisplayName ?? p.UserId)); } foreach (var u in deployment.Units) { view.SubjectNames[u.DeploymentUnitId] = u.UnitName ?? u.UnitId.ToString(); if (u.IsActive) view.Subjects.Add((u.DeploymentUnitId, (int)DeploymentTimeSubjectTypes.Unit, u.UnitName ?? u.UnitId.ToString())); } foreach (var e in deployment.Equipment) { var n = e.FreeTextName ?? e.InventoryAssetId ?? e.InventoryItemId; view.SubjectNames[e.DeploymentEquipmentId] = n; if (e.IsActive) view.Subjects.Add((e.DeploymentEquipmentId, (int)DeploymentTimeSubjectTypes.Equipment, n)); } @@ -609,7 +612,7 @@ public async Task SaveTimeReport(string id, string incidentNumber if (report == null) return NotFound(); var deployment = await AccessibleAsync(report.DeploymentId); if (deployment == null || (!CanManage && !deployment.Personnel.Any(p => p.UserId == UserId))) return Unauthorized(); - var timeZone = string.IsNullOrWhiteSpace(deployment.LocalTimeZoneId) ? (await _departments.GetDepartmentByIdAsync(DepartmentId))?.TimeZone : deployment.LocalTimeZoneId; + var timeZone = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ZoneId; try { report.IncidentNumber = incidentNumber; report.ResourceOrderNumber = resourceOrderNumber; report.RequestNumber = requestNumber; report.CostCode = costCode; report.PointOfHire = pointOfHire; @@ -622,7 +625,7 @@ public async Task SaveTimeReport(string id, string incidentNumber foreach (var row in rows) { if (!TryParseLocal(report.ReportDate, row.Start, timeZone, out var start) || !TryParseLocal(report.ReportDate, row.End, timeZone, out var end)) return Refused(400, "timereports_time_invalid", "TimeReport", new { id }); - if (end <= start) end = end.AddDays(1); + if (end <= start && !row.End.Contains('T')) end = end.AddDays(1); var entry = new DeploymentTimeEntry { DeploymentTimeEntryId = string.IsNullOrWhiteSpace(row.Id) ? null : row.Id, @@ -660,11 +663,12 @@ private static bool TryParseLocal(DateTime reportDate, string time, string timeZ utc = default; // "HH:mm" on the report date, or a full local "yyyy-MM-ddTHH:mm". DateTime local; - if (DateTime.TryParseExact(time, new[] { "yyyy-MM-ddTHH:mm", "yyyy-MM-ddTHH:mm:ss", "yyyy-MM-dd HH:mm" }, CultureInfo.InvariantCulture, DateTimeStyles.None, out var full)) local = full; + if (DateTime.TryParseExact(time, new[] { "yyyy-MM-ddTHH:mm", "yyyy-MM-ddTHH:mm:ss", "yyyy-MM-ddTHH:mm:ss.FFFFFFF", "yyyy-MM-dd HH:mm" }, CultureInfo.InvariantCulture, DateTimeStyles.None, out var full)) local = full; else if (TimeSpan.TryParseExact(time, new[] { "hh\\:mm", "h\\:mm" }, CultureInfo.InvariantCulture, out var span)) local = reportDate.Date.Add(span); else return false; - try { utc = string.IsNullOrWhiteSpace(timeZone) ? DateTime.SpecifyKind(local, DateTimeKind.Utc) : DateTimeHelpers.ConvertToUtc(local, timeZone, true); } - catch { utc = DateTime.SpecifyKind(local, DateTimeKind.Utc); } + // Same rule as ToUtc: a zone that cannot be resolved fails the entry (timereports_time_invalid) rather than saving a guessed UTC. + try { utc = new DepartmentTime(new Department { TimeZone = timeZone }).ToUtc(local); } + catch { return false; } return true; } @@ -734,7 +738,7 @@ public async Task SaveExpense(ExpenseInput input, IFormFile recei await _timeTracking.SaveExpenseAsync(new DeploymentExpense { DeploymentExpenseId = string.IsNullOrWhiteSpace(input.DeploymentExpenseId) ? null : input.DeploymentExpenseId, DeploymentId = input.DeploymentId, DeploymentTimeReportId = string.IsNullOrWhiteSpace(input.DeploymentTimeReportId) ? null : input.DeploymentTimeReportId, - DepartmentId = DepartmentId, ExpenseDate = input.ExpenseDate ?? DateTime.UtcNow.Date, ExpenseType = input.ExpenseType, MealCode = input.MealCode, City = input.City, Description = input.Description, Amount = input.Amount, + DepartmentId = DepartmentId, ExpenseDate = input.ExpenseDate ?? Resgrid.Web.Helpers.DepartmentTime.From(ViewData).Today, ExpenseType = input.ExpenseType, MealCode = input.MealCode, City = input.City, Description = input.Description, Amount = input.Amount, Currency = input.Currency, PreApproved = input.PreApproved, Billable = input.Billable }, upload.Data, upload.FileName, upload.FileType, UserId, Ip, Agent, cancellationToken); return Saved(back.Item1, back.Item2); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DisclosuresController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DisclosuresController.cs index e9c2413a6..6c1f2b427 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DisclosuresController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DisclosuresController.cs @@ -30,6 +30,7 @@ namespace Resgrid.Web.Areas.User.Controllers /// [Area("User")] [Authorize(Policy = ResgridResources.RecordDisclosure_Update)] + [Resgrid.Web.Helpers.DepartmentLocalTime] public class DisclosuresController : SecureBaseController { private readonly IRecordsDisclosureService _disclosures; @@ -400,10 +401,7 @@ private static List ProfileItems() { if (!local.HasValue || local.Value == DateTime.MinValue) return null; - if (department == null || string.IsNullOrWhiteSpace(department.TimeZone)) - return DateTime.SpecifyKind(local.Value, DateTimeKind.Utc); - - return DateTimeHelpers.ConvertToUtc(local.Value, department.TimeZone, true); + return new Resgrid.Web.Helpers.DepartmentTime(department).ToUtc(local.Value); } private async Task> PersonnelNamesAsync() diff --git a/Web/Resgrid.Web/Areas/User/Controllers/IncidentAnalysisController.cs b/Web/Resgrid.Web/Areas/User/Controllers/IncidentAnalysisController.cs index 79716693a..da210df1c 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/IncidentAnalysisController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/IncidentAnalysisController.cs @@ -29,6 +29,7 @@ namespace Resgrid.Web.Areas.User.Controllers /// /// [Area("User")] + [Resgrid.Web.Helpers.DepartmentLocalTime] public class IncidentAnalysisController : SecureBaseController { private readonly IIncidentAnalysisService _analysis; @@ -442,10 +443,7 @@ private static List Split(string csv) { if (!local.HasValue || local.Value == DateTime.MinValue) return null; - if (department == null || string.IsNullOrWhiteSpace(department.TimeZone)) - return DateTime.SpecifyKind(local.Value, DateTimeKind.Utc); - - return DateTimeHelpers.ConvertToUtc(local.Value, department.TimeZone, true); + return new Resgrid.Web.Helpers.DepartmentTime(department).ToUtc(local.Value); } private async Task> PersonnelNamesAsync() diff --git a/Web/Resgrid.Web/Areas/User/Controllers/IncidentReportsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/IncidentReportsController.cs index 93073c9cb..07c56eedc 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/IncidentReportsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/IncidentReportsController.cs @@ -29,6 +29,7 @@ namespace Resgrid.Web.Areas.User.Controllers /// department NERIS profile/crosswalk settings. Every read passes the per-record visibility rule. /// [Area("User")] + [Resgrid.Web.Helpers.DepartmentLocalTime] public class IncidentReportsController : SecureBaseController { private const string IncidentTypeSet = "incident_type"; @@ -1097,10 +1098,7 @@ private List Codes(string setKey) { if (!local.HasValue || local.Value == DateTime.MinValue) return null; - if (department == null || string.IsNullOrWhiteSpace(department.TimeZone)) - return DateTime.SpecifyKind(local.Value, DateTimeKind.Utc); - - return DateTimeHelpers.ConvertToUtc(local.Value, department.TimeZone, true); + return new Resgrid.Web.Helpers.DepartmentTime(department).ToUtc(local.Value); } private async Task> PersonnelNamesAsync() diff --git a/Web/Resgrid.Web/Areas/User/Controllers/InvoicingController.cs b/Web/Resgrid.Web/Areas/User/Controllers/InvoicingController.cs index 9932b8cba..bcb1ede91 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/InvoicingController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/InvoicingController.cs @@ -29,6 +29,7 @@ namespace Resgrid.Web.Areas.User.Controllers /// message the page shows through toastr. /// [Area("User"), Authorize, ResponseCache(NoStore = true, Location = ResponseCacheLocation.None), RequestSizeLimit(1024 * 1024)] + [Resgrid.Web.Helpers.DepartmentLocalTime] public sealed class InvoicingController : SecureBaseController { private const int PageSize = 50; @@ -491,7 +492,7 @@ await _invoicing.RecordPaymentAsync(new InvoicePayment Method = input.Method, Reference = input.Reference, Notes = input.Notes, - PaidOn = input.PaidOn ?? DateTime.UtcNow + PaidOn = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ToUtc(input.PaidOn) ?? DateTime.UtcNow }, UserId, Ip, UserAgent, cancellationToken); TempData["InvoicingMessage"] = _strings["PaymentRecorded"].Value; } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RateSchedulesController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RateSchedulesController.cs index 647c7b4b4..0b1c2c39e 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RateSchedulesController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RateSchedulesController.cs @@ -26,6 +26,7 @@ namespace Resgrid.Web.Areas.User.Controllers /// Invoicing_Update (admins by default) edits. /// [Area("User"), Authorize, ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + [Resgrid.Web.Helpers.DepartmentLocalTime] public sealed class RateSchedulesController : SecureBaseController { private static readonly string[] Currencies = { "USD", "CAD", "EUR", "GBP", "AUD", "NZD", "MXN", "CHF", "SEK", "NOK", "DKK", "PLN" }; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordDefinitionsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordDefinitionsController.cs index 2859ac7c2..100a59b14 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordDefinitionsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordDefinitionsController.cs @@ -23,6 +23,7 @@ namespace Resgrid.Web.Areas.User.Controllers /// [Area("User")] [Authorize(Policy = ResgridResources.RecordDefinition_Update)] + [Resgrid.Web.Helpers.DepartmentLocalTime] public class RecordDefinitionsController : SecureBaseController { private readonly IRecordDefinitionsService _definitions; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordDeploymentConnectorsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordDeploymentConnectorsController.cs index 0008af9fe..1aa882822 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordDeploymentConnectorsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordDeploymentConnectorsController.cs @@ -25,6 +25,7 @@ namespace Resgrid.Web.Areas.User.Controllers /// [Area("User")] [Authorize(Policy = ResgridResources.Record_View)] + [Resgrid.Web.Helpers.DepartmentLocalTime] public class RecordDeploymentConnectorsController : SecureBaseController { private readonly IRecordDeploymentConnectorsService _connectors; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordDeploymentsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordDeploymentsController.cs index 675d4b91a..e9d9ec796 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordDeploymentsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordDeploymentsController.cs @@ -24,6 +24,7 @@ namespace Resgrid.Web.Areas.User.Controllers /// [Area("User")] [Authorize(Policy = ResgridResources.Record_View)] + [Resgrid.Web.Helpers.DepartmentLocalTime] public class RecordDeploymentsController : SecureBaseController { private readonly IRecordDeploymentsService _deployments; @@ -87,6 +88,7 @@ public async Task New(RecordDeploymentNewView model, IFormFile ar await artifact.CopyToAsync(stream, cancellationToken); input.ArtifactData = stream.ToArray(); input.ArtifactFileName = Path.GetFileName(artifact.FileName); input.ArtifactContentType = artifact.ContentType; } + foreach (var fill in input.Fills) fill.NeededOn = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ToUtc(fill.NeededOn); var created = await _deployments.CreateFromExternalOrderAsync(DepartmentId, UserId, input, cancellationToken); TempData["RecordsMessage"] = _localizer["DeploymentCreated"].Value; return RedirectToAction("Details", new { id = created.Order.RmsExternalOrderId }); @@ -117,6 +119,7 @@ public async Task AddFill(string id, RecordDeploymentFillInput ne { try { + newFill.NeededOn = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ToUtc(newFill.NeededOn); await _deployments.AddFillAsync(DepartmentId, UserId, id, newFill, cancellationToken); TempData["RecordsMessage"] = _localizer["DeploymentFillAdded"].Value; } @@ -132,7 +135,7 @@ public async Task Transition(string id, string fillId, int status { try { - await _deployments.TransitionFillAsync(DepartmentId, UserId, fillId, new RecordDeploymentFillTransitionInput { Status = (RmsDeploymentFillStatus)status, OccurredOn = occurredOn?.ToUniversalTime(), Reason = reason, Notes = notes }, cancellationToken); + await _deployments.TransitionFillAsync(DepartmentId, UserId, fillId, new RecordDeploymentFillTransitionInput { Status = (RmsDeploymentFillStatus)status, OccurredOn = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ToUtc(occurredOn), Reason = reason, Notes = notes }, cancellationToken); TempData["RecordsMessage"] = _localizer["DeploymentFillUpdated"].Value; } catch (UnauthorizedAccessException) { return Forbid(); } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordDocumentsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordDocumentsController.cs index 451943a57..c7a9716a7 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordDocumentsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordDocumentsController.cs @@ -17,6 +17,7 @@ namespace Resgrid.Web.Areas.User.Controllers { [Area("User")] [Authorize(Policy = ResgridResources.Record_View)] + [Resgrid.Web.Helpers.DepartmentLocalTime] public class RecordDocumentsController : SecureBaseController { private readonly IRecordsDocumentService _documents; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordEvidenceController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordEvidenceController.cs index 712b258d9..47d6ba964 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordEvidenceController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordEvidenceController.cs @@ -17,6 +17,7 @@ namespace Resgrid.Web.Areas.User.Controllers [Area("User")] [Authorize(Policy = ResgridResources.Record_View)] [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + [Resgrid.Web.Helpers.DepartmentLocalTime] public class RecordEvidenceController : SecureBaseController { private readonly IRecordEvidenceSelectionService _selection; @@ -84,8 +85,8 @@ public async Task Capture(RecordEvidenceForm input, CancellationT || input.SourceKind == RmsEvidenceKind.CertificationSnapshot && input.UserIds?.Count is not > 0 || input.SourceKind == RmsEvidenceKind.ChatPromotion && input.SourceIds?.Count is not > 0 || input.SourceKind == RmsEvidenceKind.ModuleProjection && input.SourceIds?.Count != 1) return BadRequest("Select at least one source item."); - if (input.SourceKind == RmsEvidenceKind.TrackingFix && (!input.StartUtc.HasValue || !input.EndUtc.HasValue)) return BadRequest("Enter both UTC tracking times."); - if (input.SourceKind == RmsEvidenceKind.CertificationSnapshot && !input.EndUtc.HasValue) return BadRequest("Enter the UTC incident time for certification validity."); + if (input.SourceKind == RmsEvidenceKind.TrackingFix && (!input.StartUtc.HasValue || !input.EndUtc.HasValue)) return BadRequest("Enter both tracking times."); + if (input.SourceKind == RmsEvidenceKind.CertificationSnapshot && !input.EndUtc.HasValue) return BadRequest("Enter the incident time for certification validity."); await _evidence.CaptureAsync(new RecordEvidenceCaptureRequest { DepartmentId = DepartmentId, CapturedByUserId = UserId, RecordId = input.RecordId, RecordKind = input.RecordKind, Kind = input.SourceKind, ExpectedRowVersion = input.RowVersion, CallId = context.CallId, CaptureReason = input.CaptureReason, OriginClient = RmsOriginClient.Web, @@ -127,6 +128,6 @@ private void RequireArtifact(RecordEvidenceContext context, RmsEvidenceArtifact } private Task AuditAsync(string id, string revision, RmsAccessAuditAction action, string purpose) => _records.RecordAccessAsync(DepartmentId, UserId, id, revision, action, purpose, IpAddressHelper.GetRequestIP(Request, true)); - private static DateTime? Utc(DateTime? value) => value.HasValue ? DateTime.SpecifyKind(value.Value, DateTimeKind.Utc) : null; + private DateTime? Utc(DateTime? value) => Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ToUtc(value); } } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordInspectionsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordInspectionsController.cs index 893962011..72172712f 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordInspectionsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordInspectionsController.cs @@ -71,7 +71,7 @@ public async Task Schedule(string occupancyId, string programId, if (!await ModuleOnAsync(Flag)) return NotFound(); try { - var when = ParseUtc(scheduledOn) ?? DateTime.UtcNow.Date.AddDays(7); + var when = ParseUtc(scheduledOn) ?? Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ToUtc(Resgrid.Web.Helpers.DepartmentTime.From(ViewData).Today.AddDays(7)); var inspection = await _inspections.ScheduleAsync(DepartmentId, UserId, occupancyId, programId, when, string.IsNullOrWhiteSpace(inspectorUserId) ? UserId : inspectorUserId, cancellationToken); Notify("InspectionScheduled"); return RedirectToAction(nameof(Details), new { id = inspection.RmsInspectionId }); @@ -119,7 +119,7 @@ public async Task Reinspect(string id, string scheduledOn, Cancel if (!await ModuleOnAsync(Flag)) return NotFound(); try { - var next = await _inspections.ScheduleReinspectionAsync(DepartmentId, UserId, id, ParseUtc(scheduledOn) ?? DateTime.UtcNow.Date.AddDays(30), cancellationToken); + var next = await _inspections.ScheduleReinspectionAsync(DepartmentId, UserId, id, ParseUtc(scheduledOn) ?? Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ToUtc(Resgrid.Web.Helpers.DepartmentTime.From(ViewData).Today.AddDays(30)), cancellationToken); Notify("InspectionScheduled"); return RedirectToAction(nameof(Details), new { id = next.RmsInspectionId }); } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordLegalHoldsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordLegalHoldsController.cs index 4eeff7356..c5967a29f 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordLegalHoldsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordLegalHoldsController.cs @@ -10,6 +10,7 @@ namespace Resgrid.Web.Areas.User.Controllers { [Area("User"), Authorize(Policy = ResgridResources.RecordLegalHold_Update)] + [Resgrid.Web.Helpers.DepartmentLocalTime] public class RecordLegalHoldsController : SecureBaseController { private readonly IRecordsLegalHoldService _holds; @@ -26,7 +27,7 @@ public async Task Index(string recordId = null) public async Task Place(RmsRecordLegalHold input, CancellationToken cancellationToken) { if (!(await _cutover.GetModuleStateAsync(DepartmentId)).FlagEnabled) return NotFound(); - try { await _holds.PlaceAsync(DepartmentId, UserId, input, cancellationToken); TempData["HoldMessage"] = "Preservation hold placed. It remains active until explicitly released."; } + try { input.PeriodStart = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ToUtc(input.PeriodStart); input.PeriodEnd = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ToUtc(input.PeriodEnd); await _holds.PlaceAsync(DepartmentId, UserId, input, cancellationToken); TempData["HoldMessage"] = "Preservation hold placed. It remains active until explicitly released."; } catch (UnauthorizedAccessException) { return Forbid(); } catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) { TempData["HoldError"] = ex.Message; } return RedirectToAction(nameof(Index)); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordPermitsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordPermitsController.cs index 2e6229cd8..eed0b2577 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordPermitsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordPermitsController.cs @@ -112,7 +112,7 @@ public async Task Edit(RecordPermitEditView model, CancellationTo public async Task Transition(string id, int target, string reason, string effectiveOn, string expiresOn, CancellationToken cancellationToken) { if (!await ModuleOnAsync(Flag)) return NotFound(); - try { await _permits.TransitionAsync(DepartmentId, UserId, id, (RmsPermitState)target, reason, ParseUtc(effectiveOn), ParseUtc(expiresOn), cancellationToken); Notify("PermitTransitioned"); } + try { await _permits.TransitionAsync(DepartmentId, UserId, id, (RmsPermitState)target, reason, DateTime.TryParse(effectiveOn, out var effective) ? effective.Date : (DateTime?)null, DateTime.TryParse(expiresOn, out var expires) ? expires.Date : (DateTime?)null, cancellationToken); Notify("PermitTransitioned"); } catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } return RedirectToAction(nameof(Details), new { id }); } @@ -208,7 +208,7 @@ public async Task Index(string start = null, string end = null) if (!await ModuleOnAsync(Flag)) return NotFound(); try { - var model = Prepare(new RecordCrrIndexView { Start = ParseUtc(start) ?? DateTime.UtcNow.Date.AddDays(-90), End = ParseUtc(end) ?? DateTime.UtcNow.Date.AddDays(1) }); + var model = Prepare(new RecordCrrIndexView { Start = ParseUtc(start) ?? Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ToUtc(Resgrid.Web.Helpers.DepartmentTime.From(ViewData).Today.AddDays(-90)), End = ParseUtc(end) ?? Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ToUtc(Resgrid.Web.Helpers.DepartmentTime.From(ViewData).Today.AddDays(1)) }); model.Activities = await _crr.ListAsync(DepartmentId, UserId, model.Start, model.End, 500); model.Summary = await _crr.GetSummaryAsync(DepartmentId, UserId, model.Start, model.End); return View(model); @@ -224,6 +224,7 @@ public async Task Edit(string id = null) try { var model = Prepare(new RecordCrrEditView()); + model.Activity.OccurredOn = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ToUtc(Resgrid.Web.Helpers.DepartmentTime.From(ViewData).Today); if (!string.IsNullOrWhiteSpace(id)) { var activity = await _crr.GetAsync(DepartmentId, UserId, id); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordSavedReportsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordSavedReportsController.cs index d31caa90f..5b303e237 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordSavedReportsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordSavedReportsController.cs @@ -22,6 +22,7 @@ namespace Resgrid.Web.Areas.User.Controllers /// [Area("User")] [Authorize(Policy = ResgridResources.Record_View)] + [Resgrid.Web.Helpers.DepartmentLocalTime] public class RecordSavedReportsController : SecureBaseController { private readonly IRecordSavedReportsService _reports; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordSubmissionsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordSubmissionsController.cs index dd0ad79e2..62a975536 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordSubmissionsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordSubmissionsController.cs @@ -14,6 +14,7 @@ namespace Resgrid.Web.Areas.User.Controllers [Area("User")] [Authorize(Policy = ResgridResources.Record_Submit)] [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + [Resgrid.Web.Helpers.DepartmentLocalTime] public class RecordSubmissionsController : SecureBaseController { private readonly IRmsSubmissionsRepository _submissions; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordsAnalyticsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordsAnalyticsController.cs index 007399210..d08e83ddd 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordsAnalyticsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordsAnalyticsController.cs @@ -74,15 +74,16 @@ private async Task Page(T model, string start, string end, int { Prepare(model); // Dates arrive as yyyy-MM-dd from the filter form; the end date is inclusive on the page, exclusive in the query. - var endDay = ParseUtc(end)?.Date ?? DateTime.UtcNow.Date; - var startDay = ParseUtc(start)?.Date ?? endDay.AddDays(-RecordsAnalyticsLimits.DefaultWindowDays); + var time = Resgrid.Web.Helpers.DepartmentTime.From(ViewData); + var endDay = time.Local(ParseUtc(end))?.Date ?? time.Today; + var startDay = time.Local(ParseUtc(start))?.Date ?? endDay.AddDays(-RecordsAnalyticsLimits.DefaultWindowDays); var query = new RecordsAnalyticsQuery { - Start = startDay, End = endDay.AddDays(1), StationGroupId = stationGroupId > 0 ? stationGroupId : null, DefinitionKey = string.IsNullOrWhiteSpace(definitionKey) ? null : definitionKey, + Start = time.ToUtc(startDay), End = time.ToUtc(endDay.AddDays(1)), StationGroupId = stationGroupId > 0 ? stationGroupId : null, DefinitionKey = string.IsNullOrWhiteSpace(definitionKey) ? null : definitionKey, TurnoutTargetSeconds = Math.Clamp(turnoutTarget ?? 80, 0, 3600), TravelTargetSeconds = Math.Clamp(travelTarget ?? 240, 0, 7200) }; await load(model, query, HttpContext.RequestAborted); - model.Start = model.Result?.Start ?? query.Start.Value; model.End = (model.Result?.End ?? query.End.Value).AddDays(-1); + model.Start = time.Local(model.Result?.Start ?? query.Start.Value); model.End = time.Local(model.Result?.End ?? query.End.Value).AddDays(-1); model.StationGroupId = query.StationGroupId; model.DefinitionKey = query.DefinitionKey; model.TurnoutTargetSeconds = query.TurnoutTargetSeconds; model.TravelTargetSeconds = query.TravelTargetSeconds; await PopulateAsync(model); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs index 946b63f84..663efeb20 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs @@ -34,6 +34,7 @@ namespace Resgrid.Web.Areas.User.Controllers /// through IRecordsAuthorizationService on every read, never inferred from a list. /// [Area("User")] + [Resgrid.Web.Helpers.DepartmentLocalTime] public class RecordsController : SecureBaseController { private readonly IRecordsService _recordsService; @@ -149,9 +150,10 @@ public async Task Autosave(RecordEditView model, CancellationToke if (!CanEditRecord(aggregate.Record)) return Forbid(); model.DefinitionKey = aggregate.Record.DefinitionKey; model.Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); + var definitionVersion = aggregate.Record.RecordType == null ? aggregate.DefinitionVersionRow ?? await _definitions.GetVersionAsync(DepartmentId, aggregate.Record.DefinitionKey, aggregate.Record.DefinitionVersion) : null; try { - var saved = await _recordsService.SaveDraftAsync(DepartmentId, UserId, model.RecordId, model.RowVersion, BuildInput(model), cancellationToken); + var saved = await _recordsService.SaveDraftAsync(DepartmentId, UserId, model.RecordId, model.RowVersion, BuildInput(model, definitionVersion?.Schema), cancellationToken); return Json(new { rowVersion = saved.Record.RowVersion }); } catch (RecordConcurrencyException) { return Conflict(new { error = "This draft changed elsewhere. Reload it before saving again; your unsaved text remains in this form." }); } @@ -409,7 +411,7 @@ public async Task Create(RecordEditView model, ICollection Edit(RecordEditView model, ICollection(), + Values = (model.Values ?? new List()).Select(v => time.RecordInput(v, schema)).ToList(), DefinitionKey = model.DefinitionKey, CallId = model.CallId, StationGroupId = model.StationGroupId, @@ -1426,10 +1429,7 @@ private RecordDraftInput BuildInput(RecordEditView model) { if (!local.HasValue || local.Value == DateTime.MinValue) return null; - if (department == null || string.IsNullOrWhiteSpace(department.TimeZone)) - return DateTime.SpecifyKind(local.Value, DateTimeKind.Utc); - - return DateTimeHelpers.ConvertToUtc(local.Value, department.TimeZone, true); + return new Resgrid.Web.Helpers.DepartmentTime(department).ToUtc(local.Value); } public static List BuildParticipantInput(RecordEditView model) => model.ParticipantRows != null diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordsExportTemplatesController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordsExportTemplatesController.cs index 087324fac..53591767f 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordsExportTemplatesController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordsExportTemplatesController.cs @@ -21,6 +21,7 @@ namespace Resgrid.Web.Areas.User.Controllers /// [Area("User")] [Authorize(Policy = ResgridResources.Record_Export)] + [Resgrid.Web.Helpers.DepartmentLocalTime] public class RecordsExportTemplatesController : SecureBaseController { private readonly IRecordsExportService _exports; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordsInventoryController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordsInventoryController.cs index b6ecd48e9..2cba85d5a 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordsInventoryController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordsInventoryController.cs @@ -20,6 +20,7 @@ namespace Resgrid.Web.Areas.User.Controllers [Area("User")] [Authorize(Policy = ResgridResources.Record_Create)] [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None), RequestSizeLimit(1024 * 1024)] + [Resgrid.Web.Helpers.DepartmentLocalTime] public class RecordsInventoryController : SecureBaseController { private readonly IRmsInventoryUsageAdapter _usage; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordsPreventionMvcControllerBase.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordsPreventionMvcControllerBase.cs index 8dc716a24..b590bb0e8 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordsPreventionMvcControllerBase.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordsPreventionMvcControllerBase.cs @@ -17,6 +17,7 @@ namespace Resgrid.Web.Areas.User.Controllers /// (404 when off, like the v4 controllers), TempData messages and one exception-to-page mapping. Authorization is /// enforced by the services; the controllers only pre-hide buttons. /// + [Resgrid.Web.Helpers.DepartmentLocalTime] public abstract class RecordsPreventionMvcControllerBase : SecureBaseController { protected readonly IRecordsCutoverService Cutover; @@ -78,10 +79,10 @@ protected static Dictionary NameMap(IEnumerable items, Fun return map; } - protected static DateTime? ParseUtc(string value) + protected DateTime? ParseUtc(string value) { if (string.IsNullOrWhiteSpace(value)) return null; - return DateTime.TryParse(value, null, System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, out var d) ? d : (DateTime?)null; + return Resgrid.Web.Helpers.DepartmentTime.From(ViewData).Parse(value); } } } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/WorkOrderMaintenanceController.cs b/Web/Resgrid.Web/Areas/User/Controllers/WorkOrderMaintenanceController.cs index 904d4f768..a70a9fe08 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/WorkOrderMaintenanceController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/WorkOrderMaintenanceController.cs @@ -11,7 +11,7 @@ public sealed partial class WorkOrdersController { private void MaintenanceAvailable() { if (_maintenance == null) throw new WorkOrderException(503, "MaintenanceUnavailable"); } [HttpPost, ValidateAntiForgeryToken] - public async Task CancelPartWitness(int id, int partId, int revision, string reason) { MaintenanceAvailable(); await _maintenance.CancelPartWitnessAsync(Actor, id, partId, revision, reason); return Json(new { id }); } + public async Task CancelPartWitness(string id, string partId, int revision, string reason) { MaintenanceAvailable(); await _maintenance.CancelPartWitnessAsync(Actor, id, partId, revision, reason); return Json(new { id }); } [HttpPost, ValidateAntiForgeryToken] public async Task PartChoices(string kind, string itemId, int page = 0) { MaintenanceAvailable(); return Json(await _maintenance.InventoryChoicesAsync(Actor, kind, itemId, page)); } [HttpGet] @@ -25,17 +25,18 @@ public async Task NewRecurrence() { MaintenanceAvailable(); if (!await _access.CanUseMaintenanceAsync(DepartmentId)) throw new WorkOrderException(402, "ReadinessProRequired"); - return View("EditRecurrence", new WorkOrderRecurrenceEditView { Input = new WorkOrderRecurrenceInput { AnchorLocal = DateTime.UtcNow.Date.AddDays(1).AddHours(9), Template = new WorkOrderInput { RequestId = Guid.NewGuid().ToString("D"), Type = WorkOrderType.Preventive } }, Choices = await _orders.ChoicesAsync(Actor) }); + var choices = await _orders.ChoicesAsync(Actor); + return View("EditRecurrence", new WorkOrderRecurrenceEditView { Input = new WorkOrderRecurrenceInput { AnchorLocal = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).Today.AddDays(1).AddHours(9), TimeZoneId = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ZoneId, Template = new WorkOrderInput { RequestId = Guid.NewGuid().ToString("D"), Type = WorkOrderType.Preventive, Content = new WorkOrderContent { Currency = choices.Currency } } }, Choices = choices }); } [HttpGet] - public async Task EditRecurrence(int id) + public async Task EditRecurrence(string id) { MaintenanceAvailable(); if (!await _access.CanUseMaintenanceAsync(DepartmentId)) throw new WorkOrderException(402, "ReadinessProRequired"); return View("EditRecurrence", new WorkOrderRecurrenceEditView { Input = (await _maintenance.RecurrenceAsync(Actor, id)).Settings, Choices = await _orders.ChoicesAsync(Actor) }); } [HttpGet] - public async Task Recurrence(int id, int historyPage = 0) + public async Task Recurrence(string id, int historyPage = 0) { MaintenanceAvailable(); return View("Recurrence", new WorkOrderRecurrenceDetailView { Detail = await _maintenance.RecurrenceAsync(Actor, id, historyPage), CanWrite = await _access.CanUseMaintenanceAsync(DepartmentId) }); } @@ -48,17 +49,17 @@ public async Task SaveRecurrence(WorkOrderRecurrenceInput input, return Json(new { id = await _maintenance.SaveRecurrenceAsync(Actor, input) }); } [HttpPost, ValidateAntiForgeryToken] - public async Task Reading(int id, WorkOrderReadingInput input) { MaintenanceAvailable(); await _maintenance.RecordReadingAsync(Actor, id, input); return Json(new { id }); } + public async Task Reading(string id, WorkOrderReadingInput input) { MaintenanceAvailable(); input.ObservedOn = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ToUtc(input.ObservedOn); await _maintenance.RecordReadingAsync(Actor, id, input); return Json(new { id }); } [HttpPost, ValidateAntiForgeryToken] - public async Task Hold(int id, WorkOrderHoldInput input) { MaintenanceAvailable(); await _maintenance.AddHoldAsync(Actor, id, input); return Json(new { id }); } + public async Task Hold(string id, WorkOrderHoldInput input) { MaintenanceAvailable(); await _maintenance.AddHoldAsync(Actor, id, input); return Json(new { id }); } [HttpPost, ValidateAntiForgeryToken] - public async Task ReleaseHold(int id, int holdId, WorkOrderReleaseInput input) + public async Task ReleaseHold(string id, string holdId, WorkOrderReleaseInput input) { MaintenanceAvailable(); if (!(await _maintenance.HoldsAsync(Actor, id)).Any(h => h.Hold.Id == holdId)) throw new WorkOrderException(404, "Unavailable"); await _maintenance.ReleaseHoldAsync(Actor, holdId, input); return Json(new { id }); } [HttpPost, ValidateAntiForgeryToken] - public async Task Defer(int id, WorkOrderDeferralInput input) { MaintenanceAvailable(); await _maintenance.DeferAsync(Actor, id, input); return Json(new { id }); } + public async Task Defer(string id, WorkOrderDeferralInput input) { MaintenanceAvailable(); input.DueOn = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ToUtc(input.DueOn); await _maintenance.DeferAsync(Actor, id, input); return Json(new { id }); } } } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/WorkOrderOperationsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/WorkOrderOperationsController.cs index d67ff6510..66aaa6b96 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/WorkOrderOperationsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/WorkOrderOperationsController.cs @@ -16,9 +16,17 @@ namespace Resgrid.Web.Areas.User.Controllers public sealed partial class WorkOrdersController { [HttpGet] - public async Task Operations(int id, int vendorAfterId = 0, int movementAfterId = 0) => View("Operations", new WorkOrderOperationsView { Detail = await _orders.GetAsync(Actor, id), Choices = await _orders.ChoicesAsync(Actor), VendorCharges = await OperationsService.VendorChargesAsync(Actor, id, vendorAfterId), Movements = await OperationsService.PartMovementsAsync(Actor, id, movementAfterId) }); + public async Task Operations(string id, string vendorAfterId = null, string movementAfterId = null) => View("Operations", new WorkOrderOperationsView { Detail = await _orders.GetAsync(Actor, id), Choices = await _orders.ChoicesAsync(Actor), VendorCharges = await OperationsService.VendorChargesAsync(Actor, id, vendorAfterId), Movements = await OperationsService.PartMovementsAsync(Actor, id, movementAfterId) }); [HttpGet] - public async Task Policy() => View("Policy", new WorkOrderPolicyView { Input = await OperationsService.PolicyAsync(Actor), CanWrite = await _access.CanUseMaintenanceAsync(DepartmentId) }); + public Task Policy() => Settings(); + [HttpGet] + public async Task Settings() => View("Settings", new WorkOrderPolicyView { Input = await OperationsService.PolicyAsync(Actor), CanWrite = await _access.CanUseMaintenanceAsync(DepartmentId) }); + [HttpPost, ValidateAntiForgeryToken] + public Task SaveSettings(WorkOrderPolicyInput input, string holidays, string businessStart, string businessEnd, List weekdays) + { + if (!WorkOrderCurrencies.IsSupported(input?.Currency)) throw new WorkOrderException(400, "OperationsPolicyInvalid"); + return SavePolicy(input, holidays, businessStart, businessEnd, weekdays); + } [HttpPost, ValidateAntiForgeryToken] public async Task SavePolicy(WorkOrderPolicyInput input, string holidays, string businessStart, string businessEnd, List weekdays) { @@ -30,7 +38,7 @@ public async Task SavePolicy(WorkOrderPolicyInput input, string h input.SpendingRules = input.SpendingRules.Where(r => !string.IsNullOrWhiteSpace(r.Currency)).ToList(); input.Calendar.Targets = input.Calendar.Targets.Where(t => t.ResponseMinutes != 0 || t.RepairMinutes != 0).ToList(); input.Calendar.Holidays = (holidays ?? "").Split(new[] { '\r', '\n', ',' }, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToList(); - await OperationsService.SavePolicyAsync(Actor, input); return Json(new { id = 0 }); + await OperationsService.SavePolicyAsync(Actor, input); return Json(new { id = (string)null }); } [HttpGet] public async Task Bulk(int page = 0) @@ -58,18 +66,18 @@ public async Task ApplyBulk(string payload) return View("Bulk", new WorkOrderBulkView { Input = input, Result = await OperationsService.ApplyBulkAsync(Actor, input), Applied = true }); } [HttpPost, ValidateAntiForgeryToken] - public async Task RequestApproval(int id, WorkOrderApprovalInput input) { await OperationsService.RequestApprovalAsync(Actor, id, input); return Json(new { id }); } + public async Task RequestApproval(string id, WorkOrderApprovalInput input) { await OperationsService.RequestApprovalAsync(Actor, id, input); return Json(new { id }); } [HttpPost, ValidateAntiForgeryToken] - public async Task DecideApproval(int id, WorkOrderApprovalInput input) { await OperationsService.DecideApprovalAsync(Actor, id, input); return Json(new { id }); } + public async Task DecideApproval(string id, WorkOrderApprovalInput input) { await OperationsService.DecideApprovalAsync(Actor, id, input); return Json(new { id }); } [HttpPost, ValidateAntiForgeryToken] - public async Task AddVendorCharge(int id, WorkOrderVendorChargeInput input) { await OperationsService.AddVendorChargeAsync(Actor, id, input); return Json(new { id }); } + public async Task AddVendorCharge(string id, WorkOrderVendorChargeInput input) { await OperationsService.AddVendorChargeAsync(Actor, id, input); return Json(new { id }); } [HttpPost, ValidateAntiForgeryToken] - public async Task VoidVendorCharge(int id, int chargeId, int revision, string reason) { await OperationsService.VoidVendorChargeAsync(Actor, id, chargeId, revision, reason); return Json(new { id }); } + public async Task VoidVendorCharge(string id, string chargeId, int revision, string reason) { await OperationsService.VoidVendorChargeAsync(Actor, id, chargeId, revision, reason); return Json(new { id }); } [HttpPost, ValidateAntiForgeryToken] - public async Task ReservePart(int id, WorkOrderPartInput input) { await OperationsService.ReservePartAsync(Actor, id, input); return Json(new { id }); } + public async Task ReservePart(string id, WorkOrderPartInput input) { await OperationsService.ReservePartAsync(Actor, id, input); return Json(new { id }); } [HttpPost, ValidateAntiForgeryToken] - public async Task MovePart(int id, int partId, WorkOrderPartMovementInput input) { await OperationsService.MovePartAsync(Actor, id, partId, input); return Json(new { id }); } + public async Task MovePart(string id, string partId, WorkOrderPartMovementInput input) { await OperationsService.MovePartAsync(Actor, id, partId, input); return Json(new { id }); } [HttpPost, ValidateAntiForgeryToken] - public async Task CancelPartMovement(int id, int movementId, int revision, string reason) { await OperationsService.CancelPartMovementAsync(Actor, id, movementId, revision, reason); return Json(new { id }); } + public async Task CancelPartMovement(string id, string movementId, int revision, string reason) { await OperationsService.CancelPartMovementAsync(Actor, id, movementId, revision, reason); return Json(new { id }); } } } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/WorkOrderReportingController.cs b/Web/Resgrid.Web/Areas/User/Controllers/WorkOrderReportingController.cs index 9ff65cc8f..cc6ed92b5 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/WorkOrderReportingController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/WorkOrderReportingController.cs @@ -11,20 +11,26 @@ public sealed partial class WorkOrdersController public async Task Reports(WorkOrderReportQuery query) { if (_reports == null) throw new WorkOrderException(503, "MaintenanceUnavailable"); + query.FromUtc = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ToUtc(query.FromUtc); + query.UntilUtc = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ToUtc(query.UntilUtc); var stats = await _reports.GetWorkOrderStatsAsync(Actor, query); query.FromUtc = stats.FromUtc; query.UntilUtc = stats.UntilUtc; - return View("Reports", new WorkOrderReportView { Query = query, Stats = stats, History = await _reports.GetWorkOrderHistoryAsync(Actor, query), Choices = await _orders.ChoicesAsync(Actor) }); + return View("Reports", new WorkOrderReportView { Query = query, Stats = stats, History = await _reports.GetWorkOrderHistoryAsync(Actor, query), Choices = await _orders.ChoicesAsync(Actor), CanWrite = await _access.CanUseMaintenanceAsync(DepartmentId) }); } [HttpGet] public async Task History(WorkOrderReportQuery query) { if (_reports == null) throw new WorkOrderException(503, "MaintenanceUnavailable"); - return View("Reports", new WorkOrderReportView { Query = query, History = await _reports.GetWorkOrderHistoryAsync(Actor, query), Choices = await _orders.ChoicesAsync(Actor) }); + query.FromUtc = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ToUtc(query.FromUtc); + query.UntilUtc = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ToUtc(query.UntilUtc); + return View("Reports", new WorkOrderReportView { Query = query, History = await _reports.GetWorkOrderHistoryAsync(Actor, query), Choices = await _orders.ChoicesAsync(Actor), CanWrite = await _access.CanUseMaintenanceAsync(DepartmentId) }); } [HttpPost, ValidateAntiForgeryToken] public async Task ExportCsv(WorkOrderReportQuery query) { if (_reports == null) throw new WorkOrderException(503, "MaintenanceUnavailable"); + query.FromUtc = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ToUtc(query.FromUtc); + query.UntilUtc = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ToUtc(query.UntilUtc); Response.Headers["X-Content-Type-Options"] = "nosniff"; return File(await _reports.ExportWorkOrdersAsync(Actor, query), "text/csv; charset=utf-8", "work-order-history.csv"); } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/WorkOrdersController.cs b/Web/Resgrid.Web/Areas/User/Controllers/WorkOrdersController.cs index 030037909..36c873d85 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/WorkOrdersController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/WorkOrdersController.cs @@ -18,6 +18,7 @@ namespace Resgrid.Web.Areas.User.Controllers { [WorkOrderFormCulture] [Area("User"), Authorize, ResponseCache(NoStore = true, Location = ResponseCacheLocation.None), RequestSizeLimit(1024 * 1024)] + [Resgrid.Web.Helpers.DepartmentLocalTime] public sealed partial class WorkOrdersController : SecureBaseController { private readonly IWorkOrdersService _orders; @@ -45,7 +46,7 @@ public override async Task OnActionExecutionAsync(ActionExecutingContext context executed.ExceptionHandled = true; if (HttpMethods.IsGet(Request.Method) && ex.Code == "ProtectedDataRequired") { - int.TryParse(Request.Query["id"].ToString() is { Length: > 0 } id ? id : context.RouteData.Values["id"]?.ToString(), out var orderId); + var orderId = Request.Query["id"].ToString() is { Length: > 0 } id ? id : context.RouteData.Values["id"]?.ToString(); executed.Result = View("Locked", new WorkOrderLockedView { Query = context.ActionArguments.TryGetValue("query", out var reportQuery) && reportQuery is WorkOrderReportQuery rq ? rq : new WorkOrderReportQuery(), Page = context.RouteData.Values["action"]?.ToString(), Id = orderId, Filter = context.ActionArguments.TryGetValue("filter", out var filters) && filters is WorkOrderFilter workOrderFilter ? workOrderFilter : new WorkOrderFilter() }); } else executed.Result = StatusCode(ex.StatusCode, new { message = _strings[ex.Code].Value, code = ex.Code }); @@ -57,48 +58,50 @@ public override async Task OnActionExecutionAsync(ActionExecutingContext context public async Task New() { if (!await _access.CanUseMaintenanceAsync(DepartmentId)) throw new WorkOrderException(402, "ReadinessProRequired"); - return View("Edit", new WorkOrderEditView { Input = new WorkOrderInput { RequestId = Guid.NewGuid().ToString("D") }, Choices = await _orders.ChoicesAsync(Actor), CanManage = await _authorization.CanManageAsync(Actor, null) }); + var choices = await _orders.ChoicesAsync(Actor); + return View("Edit", new WorkOrderEditView { Input = new WorkOrderInput { RequestId = Guid.NewGuid().ToString("D"), Content = new WorkOrderContent { Currency = choices.Currency } }, Choices = choices, CanManage = await _authorization.CanManageAsync(Actor, null) }); } [HttpGet] - public async Task Detail(int id) { var detail = await _orders.GetAsync(Actor, id); return View("Detail", new WorkOrderDetailView { Detail = detail, Choices = await _orders.ChoicesAsync(Actor), Holds = _maintenance == null ? new() : await _maintenance.HoldsAsync(Actor, id), CanRelease = await _authorization.CanManageAsync(Actor, detail.Order.GroupId) }); } + public async Task Detail(string id) { var detail = await _orders.GetAsync(Actor, id); return View("Detail", new WorkOrderDetailView { Detail = detail, Choices = await _orders.ChoicesAsync(Actor), Holds = _maintenance == null ? new() : await _maintenance.HoldsAsync(Actor, id), CanRelease = await _authorization.CanManageAsync(Actor, detail.Order.GroupId) }); } [HttpGet] - public async Task Edit(int id) + public async Task Edit(string id) { var detail = await _orders.GetAsync(Actor, id); if (!detail.CanEdit) throw new WorkOrderException(403, "PermissionRequired"); return View("Edit", new WorkOrderEditView { Id = id, Input = detail.Input, Choices = await _orders.ChoicesAsync(Actor), CanManage = detail.CanManage }); } [HttpPost, ValidateAntiForgeryToken] - public async Task Save(int id, WorkOrderInput input) + public async Task Save(string id, WorkOrderInput input) { - if (id == 0) id = (await _orders.CreateAsync(Actor, input)).Order.Id; else await _orders.UpdateAsync(Actor, id, input); return Json(new { id }); + input.DueOn = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ToUtc(input.DueOn); + if (id == null) id = (await _orders.CreateAsync(Actor, input)).Order.Id; else await _orders.UpdateAsync(Actor, id, input); return Json(new { id }); } [HttpPost, ValidateAntiForgeryToken] - public async Task Transition(int id, WorkOrderTransition input) { await _orders.TransitionAsync(Actor, id, input); return Json(new { id }); } + public async Task Transition(string id, WorkOrderTransition input) { await _orders.TransitionAsync(Actor, id, input); return Json(new { id }); } [HttpPost, ValidateAntiForgeryToken] - public async Task Assign(int id, WorkOrderAssignment input) { await _orders.AssignAsync(Actor, id, input); return Json(new { id }); } + public async Task Assign(string id, WorkOrderAssignment input) { await _orders.AssignAsync(Actor, id, input); return Json(new { id }); } [HttpPost, ValidateAntiForgeryToken] - public async Task Accept(int id, int revision) { await _orders.AcceptAssignmentAsync(Actor, id, revision); return Json(new { id }); } + public async Task Accept(string id, int revision) { await _orders.AcceptAssignmentAsync(Actor, id, revision); return Json(new { id }); } [HttpPost, ValidateAntiForgeryToken] - public async Task Comment(int id, int revision, string note) { await _orders.CommentAsync(Actor, id, revision, note); return Json(new { id }); } + public async Task Comment(string id, int revision, string note) { await _orders.CommentAsync(Actor, id, revision, note); return Json(new { id }); } [HttpPost, ValidateAntiForgeryToken] - public async Task Labor(int id, WorkOrderLaborInput input) { await _orders.AddLaborAsync(Actor, id, input); return Json(new { id }); } + public async Task Labor(string id, WorkOrderLaborInput input) { await _orders.AddLaborAsync(Actor, id, input); return Json(new { id }); } [HttpPost, ValidateAntiForgeryToken] - public async Task Part(int id, WorkOrderPartInput input) { await _orders.AddPartAsync(Actor, id, input); return Json(new { id }); } + public async Task Part(string id, WorkOrderPartInput input) { await _orders.AddPartAsync(Actor, id, input); return Json(new { id }); } [HttpPost, ValidateAntiForgeryToken] - public async Task VoidPart(int id, int partId, int revision, string reason) { await _orders.VoidPartAsync(Actor, id, partId, revision, reason); return Json(new { id }); } + public async Task VoidPart(string id, string partId, int revision, string reason) { await _orders.VoidPartAsync(Actor, id, partId, revision, reason); return Json(new { id }); } [HttpPost, ValidateAntiForgeryToken, RequestSizeLimit(11 * 1024 * 1024)] - public async Task Upload(int id, int revision, IFormFile file) + public async Task Upload(string id, int revision, IFormFile file) { if (file == null || file.Length > 10 * 1024 * 1024) throw new WorkOrderException(400, "InvalidFile"); using var buffer = new MemoryStream(); await file.CopyToAsync(buffer); await _orders.AddFileAsync(Actor, id, revision, file.FileName, file.ContentType, buffer.ToArray()); return Json(new { id }); } [HttpPost, ValidateAntiForgeryToken] - public async Task WithdrawFile(int id, int fileId, int revision, string reason) { await _orders.WithdrawFileAsync(Actor, id, fileId, revision, reason); return Json(new { id }); } + public async Task WithdrawFile(string id, string fileId, int revision, string reason) { await _orders.WithdrawFileAsync(Actor, id, fileId, revision, reason); return Json(new { id }); } [HttpGet] - public async Task Evidence(int id) { var file = await _orders.GetFileAsync(Actor, id); Response.Headers["X-Content-Type-Options"] = "nosniff"; return File(file.Data, file.ContentType, file.Content); } + public async Task Evidence(string id) { var file = await _orders.GetFileAsync(Actor, id); Response.Headers["X-Content-Type-Options"] = "nosniff"; return File(file.Data, file.ContentType, file.Content); } [HttpPost, ValidateAntiForgeryToken] - public async Task Export(int id) { var detail = await _orders.GetAsync(Actor, id); return File(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(detail, Formatting.Indented)), "application/json", "work-order-" + id + ".json"); } + public async Task Export(string id) { var detail = await _orders.GetAsync(Actor, id); return File(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(detail, Formatting.Indented)), "application/json", "work-order-" + id + ".json"); } [HttpPost, ValidateAntiForgeryToken] - public Task Reopen(string destination, int id, WorkOrderFilter filter, WorkOrderReportQuery query = null) => destination switch { "Operations" => Operations(id), "Policy" => Policy(), "Bulk" => Bulk(filter?.Page ?? 0), "Reports" => Reports(query ?? new WorkOrderReportQuery()), "History" => History(query ?? new WorkOrderReportQuery()), "ExportCsv" => ExportCsv(query ?? new WorkOrderReportQuery()), "Detail" => Detail(id), "Edit" => Edit(id), "New" => New(), "Recurrence" => Recurrence(id), "EditRecurrence" => EditRecurrence(id), "NewRecurrence" => NewRecurrence(), "Recurrences" => Recurrences(filter?.Page ?? 0), "Index" => Index(filter ?? new WorkOrderFilter()), _ => Task.FromResult(BadRequest()) }; + public Task Reopen(string destination, string id, WorkOrderFilter filter, WorkOrderReportQuery query = null) => destination switch { "Operations" => Operations(id), "Policy" => Policy(), "Settings" => Settings(), "Bulk" => Bulk(filter?.Page ?? 0), "Reports" => Reports(query ?? new WorkOrderReportQuery()), "History" => History(query ?? new WorkOrderReportQuery()), "ExportCsv" => ExportCsv(query ?? new WorkOrderReportQuery()), "Detail" => Detail(id), "Edit" => Edit(id), "New" => New(), "Recurrence" => Recurrence(id), "EditRecurrence" => EditRecurrence(id), "NewRecurrence" => NewRecurrence(), "Recurrences" => Recurrences(filter?.Page ?? 0), "Index" => Index(filter ?? new WorkOrderFilter()), _ => Task.FromResult(BadRequest()) }; } } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/WorkforceController.cs b/Web/Resgrid.Web/Areas/User/Controllers/WorkforceController.cs index 5fda1b857..012ef5dca 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/WorkforceController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/WorkforceController.cs @@ -27,6 +27,7 @@ namespace Resgrid.Web.Areas.User.Controllers /// reaches their own demographic response. Protected values render REDACTED without a current grant. /// [Area("User"), Authorize, ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + [Resgrid.Web.Helpers.DepartmentLocalTime] public sealed class WorkforceController : SecureBaseController { private readonly IWorkforceService _workforce; @@ -163,7 +164,7 @@ private async Task WorkerNameAsync(WorkforceWorker worker) [HttpGet] public async Task Index() { - var view = Page(new WorkforceDashboardView { ReportingYear = DateTime.UtcNow.Year - 1 }); + var view = Page(new WorkforceDashboardView { ReportingYear = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).Today.Year - 1 }); if (_workforceEnabled && CanView) { view.Employer = await _workforce.GetEmployerProfileAsync(DepartmentId); @@ -316,7 +317,7 @@ public async Task Worker(string id, string employment = null, str view.Contractors = (await _workforce.GetLaborContractorsAsync(DepartmentId)).Select(c => new SelectListItem(c.LegalName, c.WorkforceLaborContractorId)).ToList(); view.Affiliates = (await _workforce.GetAffiliatesAsync(DepartmentId)).Select(a => new SelectListItem(a.LegalName, a.WorkforceAffiliatedEntityId)).ToList(); view.Roles = await RoleItemsAsync(); - if (employment == "new") view.EditingEmployment = new WorkforceEmployment { DepartmentId = DepartmentId, WorkforceWorkerId = worker.WorkforceWorkerId, StartOn = DateTime.UtcNow.Date }; + if (employment == "new") view.EditingEmployment = new WorkforceEmployment { DepartmentId = DepartmentId, WorkforceWorkerId = worker.WorkforceWorkerId, StartOn = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).Today }; else if (!string.IsNullOrWhiteSpace(employment)) view.EditingEmployment = view.Employments.FirstOrDefault(e => e.WorkforceEmploymentId == employment); if (!string.IsNullOrWhiteSpace(assignment)) { @@ -394,7 +395,7 @@ public async Task CompensationProfile(string id = null, string em { if (!CanViewCompensation) return Unauthorized(); EmployeeCompensationProfile profile; - if (string.IsNullOrWhiteSpace(id)) profile = new EmployeeCompensationProfile { DepartmentId = DepartmentId, Scope = string.IsNullOrWhiteSpace(employmentId) ? scope : (int)CompensationScopes.Employee, WorkforceEmploymentId = employmentId, EffectiveOn = DateTime.UtcNow.Date, StandardHoursPerDay = 8, StandardHoursPerWeek = 40 }; + if (string.IsNullOrWhiteSpace(id)) profile = new EmployeeCompensationProfile { DepartmentId = DepartmentId, Scope = string.IsNullOrWhiteSpace(employmentId) ? scope : (int)CompensationScopes.Employee, WorkforceEmploymentId = employmentId, EffectiveOn = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).Today, StandardHoursPerDay = 8, StandardHoursPerWeek = 40 }; else { profile = await _compensation.GetProfileAsync(id, DepartmentId); if (profile == null) return NotFound(); } var view = Page(new WorkforceCompensationProfileView { Profile = profile, PayComponentsJson = JsonConvert.SerializeObject(profile.PayComponents.Select(c => new { c.EmployeePayComponentId, c.Category, c.Name, c.Basis, Amount = c.Amount, c.EligiblePayCodesCsv, c.PaidForEachOvertimeHour, c.EffectiveOn, c.ExpiresOn, c.SourceAgreement }), ScriptJson), CostComponentsJson = JsonConvert.SerializeObject(profile.CostComponents.Select(c => new { c.EmployeeCostComponentId, c.Category, c.Name, c.Basis, RateAmount = c.RateAmount, c.Cap, c.EligiblePayCodesCsv, c.EffectiveOn, c.ExpiresOn, c.Source }), ScriptJson) }); view.Roles = await RoleItemsAsync(); @@ -404,7 +405,7 @@ public async Task CompensationProfile(string id = null, string em view.WorkerName = employment == null ? null : await WorkerNameAsync(await _workforce.GetWorkerAsync(employment.WorkforceWorkerId, DepartmentId)); } if (!string.IsNullOrWhiteSpace(id) && !WorkforceProtectionSeamHelper.IsUnavailable(profile.BaseAmount)) - view.Preview = Resgrid.Services.Workforce.FieldCostCalculator.CalculateLabor(new LaborCostInput { Work = new LaborWorkQuantity { Hours = 8, PayCode = (int)PayCodes.Regular }, Profile = profile, AsOf = DateTime.UtcNow.Date, Currency = profile.Currency }); + view.Preview = Resgrid.Services.Workforce.FieldCostCalculator.CalculateLabor(new LaborCostInput { Work = new LaborWorkQuantity { Hours = 8, PayCode = (int)PayCodes.Regular }, Profile = profile, AsOf = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).Today, Currency = profile.Currency }); return View(view); } @@ -444,7 +445,7 @@ public Task DeleteCompensationProfile(string id, string employmen public async Task AnnualFacts(int? year = null, int type = 0, string edit = null) { if (!CanViewCompensation) return Unauthorized(); - var view = Page(new WorkforceAnnualFactsView { ReportingYear = year ?? DateTime.UtcNow.Year - 1, ReportType = (PayDataReportTypes)type }); + var view = Page(new WorkforceAnnualFactsView { ReportingYear = year ?? Resgrid.Web.Helpers.DepartmentTime.From(ViewData).Today.Year - 1, ReportType = (PayDataReportTypes)type }); view.Facts = await _workforce.GetAnnualPayFactsAsync(DepartmentId, view.ReportingYear, view.ReportType); await LabelEmploymentsAsync(view); if (edit == "new") view.Editing = new WorkforceAnnualPayFact { DepartmentId = DepartmentId, ReportingYear = view.ReportingYear, ReportType = type }; @@ -489,13 +490,13 @@ public Task ImportAnnualFacts(string csv, bool commit, int year, public async Task WorkEntries(DateTime? from = null, DateTime? to = null, string edit = null) { if (!CanViewCompensation) return Unauthorized(); - var view = Page(new WorkforceWorkEntriesView { From = (from ?? DateTime.UtcNow.AddDays(-30)).Date, To = (to ?? DateTime.UtcNow).Date }); + var view = Page(new WorkforceWorkEntriesView { From = (from ?? Resgrid.Web.Helpers.DepartmentTime.From(ViewData).Today.AddDays(-30)).Date, To = (to ?? Resgrid.Web.Helpers.DepartmentTime.From(ViewData).Today).Date }); view.Entries = await _workforce.GetWorkEntriesAsync(DepartmentId, view.From, view.To); var workers = await _workforce.GetWorkersAsync(DepartmentId); view.WorkerNames = workers.ToDictionary(w => w.WorkforceWorkerId, w => w.DisplayName ?? w.WorkforceWorkerId); view.Workers = workers.OrderBy(w => w.DisplayName).Select(w => new SelectListItem(w.DisplayName ?? w.WorkforceWorkerId, w.WorkforceWorkerId)).ToList(); view.Establishments = await EstablishmentItemsAsync(); - if (edit == "new") view.Editing = new WorkforceWorkEntry { DepartmentId = DepartmentId, WorkDate = DateTime.UtcNow.Date, WorkCountry = "US", WorkSubdivision = "CA" }; + if (edit == "new") view.Editing = new WorkforceWorkEntry { DepartmentId = DepartmentId, WorkDate = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).Today, WorkCountry = "US", WorkSubdivision = "CA" }; else if (!string.IsNullOrWhiteSpace(edit)) view.Editing = view.Entries.FirstOrDefault(e => e.WorkforceWorkEntryId == edit); return View(view); } @@ -519,7 +520,7 @@ public async Task ResourceCosts(string edit = null) { if (!CanViewInternalCosts) return Unauthorized(); var view = Page(new WorkforceResourceCostsView { Profiles = await _costing.GetResourceProfilesAsync(DepartmentId), Units = await UnitItemsAsync() }); - if (edit == "new") view.Editing = new ResourceCostProfile { DepartmentId = DepartmentId, EffectiveOn = DateTime.UtcNow.Date }; + if (edit == "new") view.Editing = new ResourceCostProfile { DepartmentId = DepartmentId, EffectiveOn = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).Today }; else if (!string.IsNullOrWhiteSpace(edit)) view.Editing = await _costing.GetResourceProfileAsync(edit, DepartmentId); view.ComponentsJson = JsonConvert.SerializeObject((view.Editing?.Components ?? new List()).Select(c => new { c.ResourceCostComponentId, c.Category, c.Basis, c.Rate, c.ConsumptionQuantity, c.ConsumptionUnit, c.UnitPrice, c.Source, c.SourceWindowStart, c.SourceWindowEnd, c.SourceMeterStart, c.SourceMeterEnd, c.IsApproved, c.EffectiveOn, c.ExpiresOn }), ScriptJson); return View(view); @@ -553,7 +554,7 @@ public async Task Usage(string deploymentId = null, int? callId = view.UnitNames = view.Units.ToDictionary(u => int.Parse(u.Value), u => u.Text); if (!string.IsNullOrWhiteSpace(deploymentId)) { view.ContextLabel = (await _deployments.GetDeploymentByIdAsync(deploymentId, DepartmentId))?.Name ?? deploymentId; view.Entries = await _costing.GetUsageForDeploymentAsync(deploymentId, DepartmentId); } else { view.ContextLabel = (await _calls.GetCallByIdAsync(callId.Value))?.Name ?? $"#{callId}"; view.Entries = await _costing.GetUsageForCallAsync(callId.Value, DepartmentId); } - if (edit == "new") view.Editing = new ResourceUsageEntry { DepartmentId = DepartmentId, DeploymentId = deploymentId, CallId = callId, UsageDate = DateTime.UtcNow.Date, Phase = (int)UsagePhases.Incident }; + if (edit == "new") view.Editing = new ResourceUsageEntry { DepartmentId = DepartmentId, DeploymentId = deploymentId, CallId = callId, UsageDate = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).Today, Phase = (int)UsagePhases.Incident }; else if (!string.IsNullOrWhiteSpace(edit)) view.Editing = view.Entries.FirstOrDefault(e => e.ResourceUsageEntryId == edit); return View(view); } @@ -654,7 +655,7 @@ public Task DeleteCostRun(string id) => GuardedAsync(async () => public async Task PayData(int? year = null) { if (!CanViewPayData) return Unauthorized(); - var view = Page(new WorkforcePayDataView { ReportingYear = year ?? DateTime.UtcNow.Year - 1 }); + var view = Page(new WorkforcePayDataView { ReportingYear = year ?? Resgrid.Web.Helpers.DepartmentTime.From(ViewData).Today.Year - 1 }); view.Runs = await _reporting.GetRunsAsync(DepartmentId, null); view.Readiness = await _reporting.GetReadinessAsync(DepartmentId, view.ReportingYear); view.Completeness = await _demographics.GetCompletenessAsync(DepartmentId, new DateTime(view.ReportingYear, 12, 31)); diff --git a/Web/Resgrid.Web/Areas/User/Models/Records/RecordsRms5ViewModels.cs b/Web/Resgrid.Web/Areas/User/Models/Records/RecordsRms5ViewModels.cs index b3eea6573..4c25ee47d 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Records/RecordsRms5ViewModels.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Records/RecordsRms5ViewModels.cs @@ -83,7 +83,6 @@ public static string StateClass(RmsInvestigationCaseState state) } } - public static string Utc(DateTime? value) => value.HasValue ? value.Value.ToString("yyyy-MM-dd HH:mm") + " UTC" : ""; public static string Day(DateTime? value) => value.HasValue ? value.Value.ToString("yyyy-MM-dd") : ""; } diff --git a/Web/Resgrid.Web/Areas/User/Models/WorkOrders/WorkOrderOperationsViews.cs b/Web/Resgrid.Web/Areas/User/Models/WorkOrders/WorkOrderOperationsViews.cs index 0873df91b..5b2b50936 100644 --- a/Web/Resgrid.Web/Areas/User/Models/WorkOrders/WorkOrderOperationsViews.cs +++ b/Web/Resgrid.Web/Areas/User/Models/WorkOrders/WorkOrderOperationsViews.cs @@ -11,7 +11,7 @@ public sealed class WorkOrderOperationsView public List VendorCharges { get; set; } = new(); public List Movements { get; set; } = new(); } - public sealed class WorkOrderBulkSelection { public bool Selected { get; set; } public int Id { get; set; } public int Revision { get; set; } } + public sealed class WorkOrderBulkSelection { public bool Selected { get; set; } public string Id { get; set; } public int Revision { get; set; } } public sealed class WorkOrderBulkForm { public string RequestId { get; set; } = Guid.NewGuid().ToString("D"); diff --git a/Web/Resgrid.Web/Areas/User/Models/WorkOrders/WorkOrderSettingChoices.cs b/Web/Resgrid.Web/Areas/User/Models/WorkOrders/WorkOrderSettingChoices.cs new file mode 100644 index 000000000..28d22bda6 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/WorkOrders/WorkOrderSettingChoices.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Resgrid.Web.Areas.User.Models.WorkOrders +{ + public static class WorkOrderSettingChoices + { + public static IEnumerable TimeZones(string selected) + { + var zones = TimeZoneInfo.GetSystemTimeZones().ToList(); + // Stored schedules may use the other OS's zone identifiers. Keep their valid selection. + if (!string.IsNullOrWhiteSpace(selected) && zones.All(z => z.Id != selected)) + zones.Add(TimeZoneInfo.FindSystemTimeZoneById(selected)); + return zones.OrderBy(z => z.BaseUtcOffset).ThenBy(z => z.DisplayName); + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Models/WorkOrders/WorkOrderViews.cs b/Web/Resgrid.Web/Areas/User/Models/WorkOrders/WorkOrderViews.cs index d0096c7d3..e272ac6d6 100644 --- a/Web/Resgrid.Web/Areas/User/Models/WorkOrders/WorkOrderViews.cs +++ b/Web/Resgrid.Web/Areas/User/Models/WorkOrders/WorkOrderViews.cs @@ -3,10 +3,10 @@ namespace Resgrid.Web.Areas.User.Models.WorkOrders { public sealed class WorkOrderIndexView { public WorkOrderPage Orders { get; set; } public WorkOrderFilter Filter { get; set; } public WorkOrderChoices Choices { get; set; } } - public sealed class WorkOrderEditView { public int Id { get; set; } public WorkOrderInput Input { get; set; } public WorkOrderChoices Choices { get; set; } public bool CanManage { get; set; } } + public sealed class WorkOrderEditView { public string Id { get; set; } public WorkOrderInput Input { get; set; } public WorkOrderChoices Choices { get; set; } public bool CanManage { get; set; } } public sealed class WorkOrderDetailView { public System.Collections.Generic.List Holds { get; set; } = new(); public bool CanRelease { get; set; } public WorkOrderDetail Detail { get; set; } public WorkOrderChoices Choices { get; set; } } - public sealed class WorkOrderReportView { public WorkOrderReportQuery Query { get; set; } public WorkOrderStats Stats { get; set; } public WorkOrderHistoryPage History { get; set; } public WorkOrderChoices Choices { get; set; } } - public sealed class WorkOrderLockedView { public WorkOrderReportQuery Query { get; set; } = new(); public WorkOrderFilter Filter { get; set; } = new WorkOrderFilter(); public string Page { get; set; } public int Id { get; set; } } + public sealed class WorkOrderReportView { public WorkOrderReportQuery Query { get; set; } public WorkOrderStats Stats { get; set; } public WorkOrderHistoryPage History { get; set; } public WorkOrderChoices Choices { get; set; } public bool CanWrite { get; set; } } + public sealed class WorkOrderLockedView { public WorkOrderReportQuery Query { get; set; } = new(); public WorkOrderFilter Filter { get; set; } = new WorkOrderFilter(); public string Page { get; set; } public string Id { get; set; } } public sealed class WorkOrderRecurrencePageView { public System.Collections.Generic.List Rows { get; set; } = new(); public int Page { get; set; } public bool CanWrite { get; set; } } public sealed class WorkOrderRecurrenceEditView { public WorkOrderRecurrenceInput Input { get; set; } = new(); public WorkOrderChoices Choices { get; set; } = new(); } public sealed class WorkOrderRecurrenceDetailView { public WorkOrderRecurrenceView Detail { get; set; } public bool CanWrite { get; set; } } diff --git a/Web/Resgrid.Web/Areas/User/Views/Bids/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Bids/Edit.cshtml index 27fe0c80f..ab8c620be 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Bids/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Bids/Edit.cshtml @@ -8,11 +8,16 @@ ViewData["RootController"] = "Bids"; ViewData["RootTitle"] = localizer["Bids"].Value; var lineTypes = Enum.GetValues(typeof(Resgrid.Model.Invoicing.BidLineTypes)).Cast().ToList(); + + Func headerActions = @ + @localizer["ViewBid"] + ; + ViewData["HeaderActions"] = headerActions; } @await Html.PartialAsync("_ContractorShell") -
+
@await Html.PartialAsync("_ContractorMessage", (Resgrid.Web.Areas.User.Models.ContractorBilling.ContractorPageView)Model)
@Html.AntiForgeryToken() @@ -21,7 +26,7 @@
-
@localizer["Bid"] @await Html.PartialAsync("_BidStatusBadge", b.Status)
+
@localizer["Bid"] @await Html.PartialAsync("_BidStatusBadge", b.Status)

@Model.ContactName

@@ -138,3 +143,7 @@ })(); } + +@section Styles { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Bids/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Bids/Index.cshtml index 1ab11ecf3..af68b7765 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Bids/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Bids/Index.cshtml @@ -8,19 +8,22 @@ ViewData["RootTitle"] = localizer["Bids"].Value; var statuses = Enum.GetValues(typeof(Resgrid.Model.Invoicing.BidStatuses)).Cast().ToList(); var pages = Math.Max(1, (int)Math.Ceiling(Model.Total / (double)Model.PageSize)); + + Func headerActions = @ + @if (Model.CanManageContracts) { @localizer["Contracts"] } + @if (Model.CanManageBids) { @localizer["NewBid"] } + ; + ViewData["HeaderActions"] = headerActions; } @await Html.PartialAsync("_ContractorShell") -
+
@await Html.PartialAsync("_ContractorMessage", (Resgrid.Web.Areas.User.Models.ContractorBilling.ContractorPageView)Model)
@localizer["Bids"]
-
- @if (Model.CanManageContracts) { @localizer["Contracts"] } - @if (Model.CanManageBids) { @localizer["NewBid"] } -
+
@localizer["HelpBids"]
@@ -65,3 +68,7 @@
+ +@section Styles { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Bids/New.cshtml b/Web/Resgrid.Web/Areas/User/Views/Bids/New.cshtml index 0dfa8cad9..4cbec85d9 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Bids/New.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Bids/New.cshtml @@ -10,7 +10,7 @@ @await Html.PartialAsync("_ContractorShell") -
+
@await Html.PartialAsync("_ContractorMessage", (Resgrid.Web.Areas.User.Models.ContractorBilling.ContractorPageView)Model)
@@ -43,3 +43,7 @@ })(); } + +@section Styles { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Bids/View.cshtml b/Web/Resgrid.Web/Areas/User/Views/Bids/View.cshtml index 01b2c4a93..4dace9d3b 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Bids/View.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Bids/View.cshtml @@ -1,6 +1,7 @@ @model Resgrid.Web.Areas.User.Models.ContractorBilling.BidDetailView @inject IStringLocalizer localizer @{ + var departmentTime = DepartmentTime.From(ViewData); var b = Model.Bid; ViewBag.Title = "Resgrid | " + localizer["BidNumber"] + " " + b.BidNumber; ViewData["Title"] = localizer["BidNumber"].Value + " " + b.BidNumber + " — " + b.Title; @@ -8,21 +9,24 @@ ViewData["RootTitle"] = localizer["Bids"].Value; var status = (Resgrid.Model.Invoicing.BidStatuses)b.Status; string Money(decimal v) => v.ToString("N2") + " " + Model.Currency; + + Func headerActions = @ + @localizer["Pdf"] + @localizer["Preview"] + @if (Model.CanManageBids && b.IsEditable) { @localizer["Edit"] } + ; + ViewData["HeaderActions"] = headerActions; } @await Html.PartialAsync("_ContractorShell") -
+
@await Html.PartialAsync("_ContractorMessage", (Resgrid.Web.Areas.User.Models.ContractorBilling.ContractorPageView)Model)
@localizer["Bid"] @await Html.PartialAsync("_BidStatusBadge", b.Status)
-
- @localizer["Pdf"] - @localizer["Preview"] - @if (Model.CanManageBids && b.IsEditable) { @localizer["Edit"] } -
+
@@ -34,9 +38,9 @@
@localizer["DeliveryLocation"]
@b.DeliveryLocation
@localizer["Requested"]
@(b.RequestedStartOn?.ToString("yyyy-MM-dd") ?? "—") → @(b.RequestedEndOn?.ToString("yyyy-MM-dd") ?? "—")
@localizer["ValidUntil"]
@(b.ValidUntil?.ToString("yyyy-MM-dd") ?? "—")
- @if (b.SentOn.HasValue) {
@localizer["SentOn"]
@b.SentOn.Value.ToString("yyyy-MM-dd HH:mm") · @b.SentToEmail
} - @if (b.AcceptedOn.HasValue) {
@localizer["AcceptedOn"]
@b.AcceptedOn.Value.ToString("yyyy-MM-dd HH:mm")
} - @if (b.DeclinedOn.HasValue) {
@localizer["DeclinedOn"]
@b.DeclinedOn.Value.ToString("yyyy-MM-dd HH:mm") @b.DeclineReason
} + @if (b.SentOn.HasValue) {
@localizer["SentOn"]
@departmentTime.Format(b.SentOn) · @b.SentToEmail
} + @if (b.AcceptedOn.HasValue) {
@localizer["AcceptedOn"]
@departmentTime.Format(b.AcceptedOn)
} + @if (b.DeclinedOn.HasValue) {
@localizer["DeclinedOn"]
@departmentTime.Format(b.DeclinedOn) @b.DeclineReason
} @if (!string.IsNullOrWhiteSpace(b.Notes)) {
@localizer["Notes"]
@b.Notes
} @if (b.IsConverted) {
@localizer["Deployment"]
@(Model.ConvertedDeployment?.Name ?? b.ConvertedDeploymentId) @if (b.ConvertedCallId.HasValue) { · @localizer["Call"] @b.ConvertedCallId }
}
@@ -110,3 +114,7 @@
+ +@section Styles { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Agency.cshtml b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Agency.cshtml index 6a1c246ec..f20035429 100644 --- a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Agency.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Agency.cshtml @@ -1,6 +1,7 @@ @model Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsAgencyView @inject IStringLocalizer localizer @{ + var departmentTime = DepartmentTime.From(ViewData); ViewBag.Title = "Resgrid | " + localizer["AgencyProfile"]; ViewData["Title"] = localizer["AgencyProfile"].Value; ViewData["Subtitle"] = localizer["AgencyIntro"].Value; @@ -12,7 +13,7 @@ @await Html.PartialAsync("_CalOesMarsShell") -
+
@await Html.PartialAsync("_CalOesMarsMessage", (Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsPageView)Model)
@@ -50,7 +51,7 @@
@localizer["Verification"]
-

@localizer["VerifiedOn"]: @(a.VerifiedOn?.ToString("yyyy-MM-dd") ?? "—")

+

@localizer["VerifiedOn"]: @(departmentTime.Date(a.VerifiedOn) ?? "—")

@localizer["VerificationHelp"]

@localizer["NoCredentialsStored"]

@@ -58,3 +59,7 @@
+ +@section Styles { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Agreements.cshtml b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Agreements.cshtml index 78f78cb2c..8cbd2bc49 100644 --- a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Agreements.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Agreements.cshtml @@ -11,12 +11,12 @@ var kinds = Enum.GetValues(); var methods = Enum.GetValues(); var overtime = Enum.GetValues(); - var today = DateTime.UtcNow; + var today = DepartmentTime.From(ViewData).Today; } @await Html.PartialAsync("_CalOesMarsShell") -
+
@await Html.PartialAsync("_CalOesMarsMessage", (Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsPageView)Model)
@@ -91,3 +91,7 @@
+ +@section Styles { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Handoff.cshtml b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Handoff.cshtml index 2b7a7c607..d62df984d 100644 --- a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Handoff.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Handoff.cshtml @@ -1,6 +1,7 @@ @model Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsHandoffView @inject IStringLocalizer localizer @{ + var departmentTime = DepartmentTime.From(ViewData); var w = Model.Item; var m = Model.Manifest; ViewBag.Title = "Resgrid | " + localizer["PortalHandoff"]; @@ -9,21 +10,24 @@ ViewData["MarsTab"] = "queue"; ViewData["MarsIsManager"] = Model.IsManager; ViewData["MarsCanReconcile"] = Model.CanReconcile; + + Func headerActions = @ + @localizer["OpenPortal"] + @localizer["Back"] + ; + ViewData["HeaderActions"] = headerActions; } @await Html.PartialAsync("_CalOesMarsShell") -
+
@localizer["HandoffWarning"]
@localizer["RecordType" + (Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsRecordTypes)w.RecordType] @await Html.PartialAsync("_WorkItemStateBadge", w.LocalState)
- +
@@ -57,7 +61,7 @@
@localizer["RateProfileVersion"]
@(m.RateProfileVersion ?? "—")
@localizer["Agreement"]
@(m.AgreementSnapshotId ?? "—")
@localizer["Checksum"]
@m.Checksum
-
@localizer["GeneratedOn"]
@m.GeneratedOn.ToString("yyyy-MM-dd HH:mm") UTC
+
@localizer["GeneratedOn"]
@departmentTime.Format(m.GeneratedOn)

@localizer["NotAnImportFile"]

@if (m.Validation != null && !m.Validation.IsReadyForPortal) {

@localizer["HandoffWithErrors"]

} @@ -76,3 +80,7 @@ }); } + +@section Styles { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Index.cshtml index 5a0d2cb4e..d1f6498d3 100644 --- a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Index.cshtml @@ -1,6 +1,7 @@ @model Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsDashboardView @inject IStringLocalizer localizer @{ + var departmentTime = DepartmentTime.From(ViewData); ViewBag.Title = "Resgrid | " + localizer["CalOesMars"]; ViewData["Title"] = localizer["ReadinessDashboard"].Value; ViewData["Subtitle"] = localizer["ReadinessIntro"].Value; @@ -14,7 +15,7 @@ @await Html.PartialAsync("_CalOesMarsShell") -
+
@await Html.PartialAsync("_CalOesMarsMessage", (Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsPageView)Model)
@localizer["HelpReadiness"] @@ -97,7 +98,7 @@
@localizer["Uei"]
@(string.IsNullOrWhiteSpace(r.Agency.UeiReference) ? "—" : localizer["Recorded"].Value)
@localizer["FiscalSupplier"]
@(string.IsNullOrWhiteSpace(r.Agency.FiscalSupplierReference) ? "—" : localizer["Recorded"].Value)
@localizer["PortalRole"]
@(r.Agency.PortalAccountRole ?? "—")
-
@localizer["VerifiedOn"]
@(r.Agency.VerifiedOn?.ToString("yyyy-MM-dd") ?? "—")
+
@localizer["VerifiedOn"]
@(departmentTime.Date(r.Agency.VerifiedOn) ?? "—")
} @if (Model.IsManager) @@ -137,3 +138,7 @@
+ +@section Styles { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Invoice.cshtml b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Invoice.cshtml index 2b8d1c95d..8a95295c6 100644 --- a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Invoice.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Invoice.cshtml @@ -1,6 +1,7 @@ @model Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsInvoiceView @inject IStringLocalizer localizer @{ + var departmentTime = DepartmentTime.From(ViewData); var r = Model.Reconciliation; var i = r.Invoice; var s = r.Snapshot; @@ -12,17 +13,22 @@ ViewData["MarsCanReconcile"] = Model.CanReconcile; var pendingLocal = i.LocalState == (int)Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsLocalStates.PendingLocalAgencyApproval; var payable = i.LocalState == (int)Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsLocalStates.PendingPayingEntityApproval; + + Func headerActions = @ + @localizer["Back"] + ; + ViewData["HeaderActions"] = headerActions; } @await Html.PartialAsync("_CalOesMarsShell") -
+
@await Html.PartialAsync("_CalOesMarsMessage", (Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsPageView)Model)
@localizer["MarsInvoice"] @await Html.PartialAsync("_WorkItemStateBadge", i.LocalState)
- +

@localizer["MarsInvoiceNotPhaseB"]

@@ -30,9 +36,9 @@
@localizer["MarsInvoiceId"]
@i.MarsInvoiceId
@localizer["InvoiceDate"]
@(s.InvoiceDate?.ToString("yyyy-MM-dd") ?? "—")
@localizer["PayingEntity"]
@(s.PayingEntity ?? "—")
-
@localizer["ObservedStatus"]
@(i.ObservedExternalStatus ?? "—") @(i.ObservedOn?.ToString("yyyy-MM-dd"))
-
@localizer["LocalDecision"]
@(s.LocalDecisionTitle ?? "—") @(string.IsNullOrWhiteSpace(s.LocalDecisionComment) ? "" : "— " + s.LocalDecisionComment) @(i.ApprovedOn?.ToString("yyyy-MM-dd") ?? i.RejectedOn?.ToString("yyyy-MM-dd"))
-
@localizer["Paid"]
@(i.PaidTotal?.ToString("N2") ?? "—") @(i.PaidOn?.ToString("yyyy-MM-dd")) @i.PaymentReference
+
@localizer["ObservedStatus"]
@(i.ObservedExternalStatus ?? "—") @(departmentTime.Date(i.ObservedOn))
+
@localizer["LocalDecision"]
@(s.LocalDecisionTitle ?? "—") @(string.IsNullOrWhiteSpace(s.LocalDecisionComment) ? "" : "— " + s.LocalDecisionComment) @(departmentTime.Date(i.ApprovedOn) ?? departmentTime.Date(i.RejectedOn))
+
@localizer["Paid"]
@(i.PaidTotal?.ToString("N2") ?? "—") @(departmentTime.Date(i.PaidOn)) @i.PaymentReference

@localizer["ExpectedVsObserved"]

@@ -92,3 +98,7 @@ + +@section Styles { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Queue.cshtml b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Queue.cshtml index 6b11f0e5e..27a75d452 100644 --- a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Queue.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Queue.cshtml @@ -1,6 +1,7 @@ @model Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsQueueView @inject IStringLocalizer localizer @{ + var departmentTime = DepartmentTime.From(ViewData); ViewBag.Title = "Resgrid | " + localizer["ActionQueue"]; ViewData["Title"] = localizer["ActionQueue"].Value; ViewData["Subtitle"] = localizer["QueueIntro"].Value; @@ -13,7 +14,7 @@ @await Html.PartialAsync("_CalOesMarsShell") -
+
@await Html.PartialAsync("_CalOesMarsMessage", (Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsPageView)Model)
@@ -43,7 +44,7 @@
- + @@ -82,3 +83,7 @@ + +@section Styles { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Rate.cshtml b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Rate.cshtml index da2d30557..ab9e2ae53 100644 --- a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Rate.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Rate.cshtml @@ -1,6 +1,7 @@ @model Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsRateEditView @inject IStringLocalizer localizer @{ + var departmentTime = DepartmentTime.From(ViewData); var p = Model.Profile; var typeName = localizer["SubmissionType" + (Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsSubmissionTypes)p.SubmissionType].Value; ViewBag.Title = "Resgrid | " + typeName; @@ -23,7 +24,7 @@ @await Html.PartialAsync("_CalOesMarsShell") -
+
@await Html.PartialAsync("_CalOesMarsMessage", (Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsPageView)Model) @if (!editable) {
@localizer["RateLocked"]
}
@@ -54,8 +55,8 @@
@localizer["AuthorityProfile"]
@p.AuthorityProfileCode
-
@localizer["SignedOn"]
@(p.SignedOn?.ToString("yyyy-MM-dd") ?? "—") @p.SignedByName
-
@localizer["ObservedStatus"]
@(p.ObservedExternalStatus ?? "—") @(p.ObservedOn?.ToString("yyyy-MM-dd"))
+
@localizer["SignedOn"]
@(departmentTime.Date(p.SignedOn) ?? "—") @p.SignedByName
+
@localizer["ObservedStatus"]
@(p.ObservedExternalStatus ?? "—") @(departmentTime.Date(p.ObservedOn))
@localizer["Version"]
@p.RowVersion
@foreach (var next in Model.NextStatuses) @@ -126,7 +127,7 @@ @localizer["SalarySurveyFromWorkforceHelp"] @Html.AntiForgeryToken() - +
@@ -250,3 +251,7 @@ })(); } + +@section Styles { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Rates.cshtml b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Rates.cshtml index 03bbab6eb..59ba41d8b 100644 --- a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Rates.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Rates.cshtml @@ -1,6 +1,7 @@ @model Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsRatesView @inject IStringLocalizer localizer @{ + var departmentTime = DepartmentTime.From(ViewData); ViewBag.Title = "Resgrid | " + localizer["AnnualRates"]; ViewData["Title"] = localizer["AnnualRates"].Value; ViewData["Subtitle"] = localizer["RatesIntro"].Value; @@ -9,26 +10,30 @@ ViewData["MarsCanReconcile"] = Model.CanReconcile; var types = Enum.GetValues(); var years = Model.Profiles.Select(p => p.SubmissionYear).Distinct().OrderByDescending(y => y).ToList(); + + Func headerActions = @ + @if (Model.IsManager) + { +
+ + +
+ + } +
; + ViewData["HeaderActions"] = headerActions; } @await Html.PartialAsync("_CalOesMarsShell") -
+
@await Html.PartialAsync("_CalOesMarsMessage", (Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsPageView)Model)
@localizer["AnnualRates"]
- @if (Model.IsManager) - { -
-
- - -
-
- } +
@localizer["HelpRates"]
@@ -50,7 +55,7 @@
- + @@ -61,3 +66,7 @@ + +@section Styles { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Reconciliation.cshtml b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Reconciliation.cshtml index de7d0835c..71a85825a 100644 --- a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Reconciliation.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Reconciliation.cshtml @@ -1,6 +1,7 @@ @model Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsReconciliationView @inject IStringLocalizer localizer @{ + var departmentTime = DepartmentTime.From(ViewData); ViewBag.Title = "Resgrid | " + localizer["Reconciliation"]; ViewData["Title"] = localizer["Reconciliation"].Value; ViewData["Subtitle"] = localizer["ReconciliationIntro"].Value; @@ -12,7 +13,7 @@ @await Html.PartialAsync("_CalOesMarsShell") -
+
@await Html.PartialAsync("_CalOesMarsMessage", (Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsPageView)Model)
@@ -35,7 +36,7 @@
- + } @@ -70,3 +71,7 @@ + +@section Styles { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Resources.cshtml b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Resources.cshtml index af0936a5d..bbb3b864e 100644 --- a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Resources.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Resources.cshtml @@ -10,12 +10,12 @@ var editing = Model.Editing ?? new Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsResourceProfile { ResourceKind = "Apparatus" }; var ownerships = Enum.GetValues(); var subjectTypes = Enum.GetValues(); - var today = DateTime.UtcNow; + var today = DepartmentTime.From(ViewData).Today; } @await Html.PartialAsync("_CalOesMarsShell") -
+
@await Html.PartialAsync("_CalOesMarsMessage", (Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsPageView)Model)
@@ -95,3 +95,7 @@
+ +@section Styles { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/WorkItem.cshtml b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/WorkItem.cshtml index 0e7cf32b5..af4fb0a3a 100644 --- a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/WorkItem.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/WorkItem.cshtml @@ -1,6 +1,7 @@ @model Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsWorkItemView @inject IStringLocalizer localizer @{ + var departmentTime = DepartmentTime.From(ViewData); var w = Model.Item; var recordType = (Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsRecordTypes)w.RecordType; var typeName = localizer["RecordType" + recordType].Value; @@ -17,22 +18,25 @@ var recordStatuses = Model.Authority.RecordStatusMap.Keys.ToList(); // JSON emitted inside } } + +@section Styles { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Certifications/EditType.cshtml b/Web/Resgrid.Web/Areas/User/Views/Certifications/EditType.cshtml index 9e18412a9..bde6020dc 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Certifications/EditType.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Certifications/EditType.cshtml @@ -10,7 +10,7 @@ @await Html.PartialAsync("_Shell") -
+
@await Html.PartialAsync("_Message", (Resgrid.Web.Areas.User.Models.Certifications.CertificationPageView)Model)
@title
@@ -96,3 +96,7 @@ }); } + +@section Styles { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Certifications/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Certifications/Index.cshtml index 9c975db96..3a0a56fc7 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Certifications/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Certifications/Index.cshtml @@ -1,13 +1,14 @@ @model Resgrid.Web.Areas.User.Models.Certifications.CertificationDashboardView @inject IStringLocalizer localizer @{ + var departmentTime = DepartmentTime.From(ViewData); ViewBag.Title = "Resgrid | " + localizer["Certifications"]; ViewData["Tab"] = "Dashboard"; ViewData["Title"] = localizer["Certifications"].Value; ViewData["Subtitle"] = localizer["DashboardIntro"].Value; var unit = Model.Scope == "unit"; var types = Model.VisibleTypes; - var today = DateTime.UtcNow.Date; + var today = departmentTime.Today; string CellClass(Resgrid.Model.CertificationDashboardCell c) { if (unit) @@ -24,27 +25,30 @@ if (c.DaysUntilExpiry.HasValue && c.DaysUntilExpiry <= Model.Horizon) return "warning"; return "success"; } + + Func headerActions = @ + @localizer["ExportCsv"] + ; + ViewData["HeaderActions"] = headerActions; } @await Html.PartialAsync("_Shell") -
+
@await Html.PartialAsync("_Message", (Resgrid.Web.Areas.User.Models.Certifications.CertificationPageView)Model)

@Model.Dashboard.ExpiredCount

@localizer["Expired"]

@Model.Dashboard.ExpiringCount

@string.Format(localizer["ExpiringWithin"].Value, Model.Horizon)
-

@Model.Dashboard.SuspendedCount

@localizer["SuspendedOrRevoked"]
+

@Model.Dashboard.SuspendedCount

@localizer["SuspendedOrRevoked"]

@Model.Dashboard.PendingVerificationCount

@localizer["StatusPendingVerification"]
@localizer["Dashboard"]
- +
- @await Html.PartialAsync("_Tabs") +
@@ -165,3 +169,7 @@
+ +@section Styles { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Certifications/Record.cshtml b/Web/Resgrid.Web/Areas/User/Views/Certifications/Record.cshtml index 7c39ebed0..3c57121f3 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Certifications/Record.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Certifications/Record.cshtml @@ -1,6 +1,7 @@ @model Resgrid.Web.Areas.User.Models.Certifications.CertificationRecordView @inject IStringLocalizer localizer @{ + var departmentTime = DepartmentTime.From(ViewData); ViewBag.Title = "Resgrid | " + localizer["CertificationRecord"]; ViewData["Tab"] = "Dashboard"; ViewData["Title"] = localizer["CertificationRecord"].Value; @@ -22,7 +23,7 @@ @await Html.PartialAsync("_Shell") -
+
@await Html.PartialAsync("_Message", (Resgrid.Web.Areas.User.Models.Certifications.CertificationPageView)Model)
@@ -52,7 +53,7 @@ } @if (r.VerifiedOn.HasValue) { -
@localizer["VerifiedBy"]
@Model.VerifiedByName @r.VerifiedOn.Value.ToString("yyyy-MM-dd")
+
@localizer["VerifiedBy"]
@Model.VerifiedByName @departmentTime.Date(r.VerifiedOn)
} @if (!string.IsNullOrWhiteSpace(r.Filename)) { @@ -152,7 +153,7 @@

@localizer["RenewHint"]

@if (t?.NeverExpires != true) { -
+
}
@@ -170,7 +171,7 @@
@(item.RequestNumber ?? w.MarsInvoiceId ?? "—") @await Html.PartialAsync("_WorkItemStateBadge", w.LocalState) @if (item.ErrorCount > 0) { @item.ErrorCount } @if (item.WarningCount > 0) { @item.WarningCount } @if (item.ErrorCount == 0 && item.WarningCount == 0 && w.LocalState >= (int)Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsLocalStates.ReadyForPortal) { }@(w.ObservedExternalStatus ?? "—") @(w.ObservedOn?.ToString("yyyy-MM-dd"))@(w.ObservedExternalStatus ?? "—") @(departmentTime.Date(w.ObservedOn)) @(Model.IsManager && w.ExpectedTotal.HasValue ? w.ExpectedTotal.Value.ToString("N2") : "—") @item.AgeDays d @localizer["Open"] @localizer["RateStatus" + (Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsRateProfileStatuses)p.Status] @(p.EffectiveOn?.ToString("yyyy-MM-dd") ?? "—") → @(p.ExpiresOn?.ToString("yyyy-MM-dd") ?? "—") @(p.AdministrativeRateMethod == (int)Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsAdministrativeRateMethods.None ? "—" : $"{p.AdministrativeRateValue:0.##} % ({localizer["AdminMethod" + (Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsAdministrativeRateMethods)p.AdministrativeRateMethod]})")@(p.SignedOn?.ToString("yyyy-MM-dd") ?? "—")@(departmentTime.Date(p.SignedOn) ?? "—") @(p.ObservedExternalStatus ?? "—") @if (Model.IsManager) { @localizer["Open"] }
@((i.ExpectedTotal ?? 0).ToString("N2")) @(i.ApprovedTotal?.ToString("N2") ?? "—") @(i.PaidTotal?.ToString("N2") ?? "—")@(i.ObservedOn?.ToString("yyyy-MM-dd") ?? "—")@(departmentTime.Date(i.ObservedOn) ?? "—") @localizer["Open"]
- + @foreach (var day in Model.Trend) { @@ -165,5 +166,6 @@ @section Styles { + } @section Scripts { @await Html.PartialAsync("_ProtectionScripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/Detail.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/Detail.cshtml index 3974e6d5f..3e962ea56 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Checklists/Detail.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/Detail.cshtml @@ -10,35 +10,36 @@ }; } @{ + var departmentTime = DepartmentTime.From(ViewData); ViewBag.Title = "Resgrid | " + Model.Definition.Form.Name; var definition = Model.Definition.Definition; ViewData["Tab"] = "Index"; ViewData["Title"] = Model.Definition.Form.Name; + + Func headerActions = @ + @localizer["Back"] + @if (Model.CanStart) + { + + } + @if (Model.CanManage) + { + @localizer["EditDraft"] + @localizer["Schedules"] + + } + ; + ViewData["HeaderActions"] = headerActions; } @await Html.PartialAsync("_Shell") -
+
@Model.Definition.Form.Name
- @await Html.PartialAsync("_Tabs") - @await Html.PartialAsync("_Protection") -
- @localizer["Back"] -
- @if (Model.CanStart) - { - - } - @if (Model.CanManage) - { - @localizer["EditDraft"] - @localizer["Schedules"] - - } -
+ @await Html.PartialAsync("_Protection")

@(definition.Retired ? localizer["Retired"] : localizer["Active"]) @@ -65,7 +66,7 @@

@localizer["Date"] (UTC)@localizer["ExpectedChecks"]@localizer["MissedChecks"]
@localizer["Date"]@localizer["ExpectedChecks"]@localizer["MissedChecks"]
- + @@ -78,7 +79,7 @@ var run = entry.Completion; var state = (ChecklistRunState)run.State; - + @@ -201,5 +202,6 @@ @section Styles { + } @section Scripts { @await Html.PartialAsync("_ProtectionScripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/Due.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/Due.cshtml index 3e4b0ac88..eebc8477e 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Checklists/Due.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/Due.cshtml @@ -17,6 +17,7 @@ }; } @{ + var departmentTime = DepartmentTime.From(ViewData); ViewBag.Title = "Resgrid | " + localizer["DueChecks"]; ViewData["Tab"] = "Due"; ViewData["Title"] = localizer["DueChecks"].Value; @@ -25,11 +26,11 @@ @await Html.PartialAsync("_Shell") -
+
@localizer["DueChecks"]
- @await Html.PartialAsync("_Tabs") + @await Html.PartialAsync("_Protection")
@localizer["DueGuidance"]
@@ -49,8 +50,8 @@
- - + + @@ -63,8 +64,8 @@ - - + + - + } @@ -62,3 +66,7 @@ + +@section Styles { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Deployments/TimeReport.cshtml b/Web/Resgrid.Web/Areas/User/Views/Deployments/TimeReport.cshtml index 5c4021322..2e2c26df7 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Deployments/TimeReport.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Deployments/TimeReport.cshtml @@ -1,24 +1,30 @@ @model Resgrid.Web.Areas.User.Models.Deployments.TimeReportEditView @inject IStringLocalizer localizer @{ + var departmentTime = DepartmentTime.From(ViewData); var r = Model.Report; var d = Model.Deployment; ViewBag.Title = "Resgrid | " + string.Format(localizer["TimeReportTitle"].Value, r.ReportNumber); ViewData["Title"] = string.Format(localizer["TimeReportTitle"].Value, r.ReportNumber); ViewData["Subtitle"] = $"{d.Name} · {r.ReportDate:yyyy-MM-dd}"; - string LocalTime(DateTime utc) { try { return string.IsNullOrWhiteSpace(Model.TimeZone) ? utc.ToString("HH:mm") : Resgrid.Framework.DateTimeHelpers.GetLocalDateTime(utc, Model.TimeZone).ToString("HH:mm"); } catch { return utc.ToString("HH:mm"); } } - string LocalStamp(DateTime? utc) => utc.HasValue ? (Model.Department == null ? utc.Value.ToString("yyyy-MM-dd HH:mm") : Resgrid.Model.Helpers.TimeConverterHelper.TimeConverter(utc.Value, Model.Department).ToString("yyyy-MM-dd HH:mm")) : "—"; + string LocalStamp(DateTime? utc) => departmentTime.Format(utc) ?? "—"; string SubjectName(string id) => id != null && Model.SubjectNames.TryGetValue(id, out var n) ? n : id; var entries = r.Entries.OrderBy(e => e.SortOrder).ThenBy(e => e.StartTime).ToList(); var status = (Resgrid.Model.Invoicing.DeploymentTimeReportStatuses)r.Status; var canSubmit = Model.CanEdit && status == Resgrid.Model.Invoicing.DeploymentTimeReportStatuses.Draft; var canApprove = Model.CanApprove && status == Resgrid.Model.Invoicing.DeploymentTimeReportStatuses.Submitted; var canVoid = Model.CanApprove && status != Resgrid.Model.Invoicing.DeploymentTimeReportStatuses.Void && status != Resgrid.Model.Invoicing.DeploymentTimeReportStatuses.Billed; + + Func headerActions = @ + @localizer["BackToDeployment"] + PDF + ; + ViewData["HeaderActions"] = headerActions; } @await Html.PartialAsync("_Shell") -
+
@await Html.PartialAsync("_Message", (Resgrid.Web.Areas.User.Models.Deployments.DeploymentPageView)Model) @foreach (var issue in Model.Validation.Errors) { @@ -36,10 +42,7 @@
@localizer["TimeReport"] #@r.ReportNumber @await Html.PartialAsync("_ReportStatusBadge", r.Status)
- +
@@ -67,8 +70,8 @@
- - + + @@ -195,7 +198,7 @@ clone = document.createElement('tr'); clone.innerHTML = '' + '' + - '' + + '' + '' + '' + ''; @@ -205,3 +208,7 @@ })(); } + +@section Styles { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Deployments/View.cshtml b/Web/Resgrid.Web/Areas/User/Views/Deployments/View.cshtml index fe93916c7..24c21012a 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Deployments/View.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Deployments/View.cshtml @@ -3,6 +3,7 @@ @inject IStringLocalizer contractorStrings @inject IStringLocalizer workforceStrings @{ + var departmentTime = DepartmentTime.From(ViewData); var d = Model.Deployment; ViewBag.Title = "Resgrid | " + d.Name; ViewData["Title"] = d.Name; @@ -11,7 +12,8 @@ var activeUnits = d.Units.Where(u => u.IsActive).ToList(); var activePeople = d.Personnel.Where(p => p.IsActive).ToList(); var activeEquipment = d.Equipment.Where(e => e.IsActive).ToList(); - string Local(DateTime? value) => value.HasValue ? (Model.Department == null ? value.Value.ToString("yyyy-MM-dd HH:mm") : Resgrid.Model.Helpers.TimeConverterHelper.TimeConverter(value.Value, Model.Department).ToString("yyyy-MM-dd HH:mm")) : "—"; + string Local(DateTime? value) => departmentTime.Format(value) ?? "—"; + string Window(DateTime? value) => Local(value); string UserName(string userId) => string.IsNullOrWhiteSpace(userId) ? "" : Model.UserNames.TryGetValue(userId, out var n) ? n : userId; string UnitName(string deploymentUnitId) { var u = d.Units.FirstOrDefault(x => x.DeploymentUnitId == deploymentUnitId); return u == null ? "" : (u.UnitName ?? u.UnitId.ToString()); } // ADP reveal (plan 7.2): catalog 28 values render REDACTED; the step-up modal and the Reveal action fill the marked spans. @@ -28,11 +30,20 @@ { if (Resgrid.Services.Invoicing.DeploymentService.IsValidTransition(status, candidate)) next.Add(candidate); } + + Func headerActions = @ + @if (Model.CanManage) + { + @localizer["Edit"] + } + @localizer["ExportCsv"] + ; + ViewData["HeaderActions"] = headerActions; } @await Html.PartialAsync("_Shell") -
+
@await Html.PartialAsync("_Message", (Resgrid.Web.Areas.User.Models.Deployments.DeploymentPageView)Model) @foreach (var warning in Model.Warnings) { @@ -47,13 +58,7 @@
@localizer["Deployment"] @await Html.PartialAsync("_StatusBadge", d.Status)
-
- @if (Model.CanManage) - { - @localizer["Edit"] - } - @localizer["ExportCsv"] -
+
@@ -61,7 +66,7 @@
@localizer["ResourceOrderNumber"]
@(d.ResourceOrderNumber ?? "—") @(string.IsNullOrWhiteSpace(d.RequestNumber) ? "" : $"/ {d.RequestNumber}")
@localizer["CostCode"]
@(d.CostCode ?? "—")
@localizer["PointOfHire"]
@(d.PointOfHire ?? "—")
-
@localizer["Window"]
@Local(d.StartOn) → @Local(d.EndOn) @(d.MaxDays.HasValue ? $"({d.MaxDays} {localizer["Days"]})" : "")
+
@localizer["Window"]
@Window(d.StartOn) → @Window(d.EndOn) @(d.MaxDays.HasValue ? $"({d.MaxDays} {localizer["Days"]})" : "")
@localizer["Contact"]
@(Model.ContactName ?? "—")
@localizer["Call"]
@if (Model.Call != null) { @Model.Call.Number @Model.Call.Name } else { — }
@if (!string.IsNullOrWhiteSpace(d.RmsExternalOrderId)) @@ -118,7 +123,7 @@
-
- + @@ -477,3 +482,7 @@ })(); } + +@section Styles { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Deployments/_ExpenseForm.cshtml b/Web/Resgrid.Web/Areas/User/Views/Deployments/_ExpenseForm.cshtml index 7ab6f10ff..47ee07907 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Deployments/_ExpenseForm.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Deployments/_ExpenseForm.cshtml @@ -1,3 +1,4 @@ +@{ var departmentTime = DepartmentTime.From(ViewData); } @model (string DeploymentId, string TimeReportId, string Currency) @inject IStringLocalizer localizer
@@ -5,7 +6,7 @@
-
+
@localizer["Started"] (UTC)@localizer["Started"] @localizer["Target"] @localizer["State"] @localizer["Score"]
@run.CreatedOn.ToString("yyyy-MM-dd HH:mm:ss")@departmentTime.Format(run.CreatedOn) @entry.TargetName @localizer[state.ToString()] @(run.Score.HasValue ? run.Score + "%" : "—")
@localizer["Name"] @localizer["Target"]@localizer["ScheduledFor"] (UTC)@localizer["WindowEnds"] (UTC)@localizer["ScheduledFor"]@localizer["WindowEnds"] @localizer["State"] @localizer["Actions"]
@entry.Name @entry.Target.Name@row.PeriodStartUtc?.ToString("yyyy-MM-dd HH:mm")@row.WindowEndUtc?.ToString("yyyy-MM-dd HH:mm")@departmentTime.Format(row.PeriodStartUtc)@departmentTime.Format(row.WindowEndUtc) @localizer[state.ToString()] @if (entry.CanStart) @@ -143,5 +144,6 @@ @section Styles { + } @section Scripts { @await Html.PartialAsync("_ProtectionScripts") @await Html.PartialAsync("_LiveScripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/Edit.cshtml index 4782f1927..27d76ea34 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Checklists/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/Edit.cshtml @@ -14,7 +14,7 @@ @await Html.PartialAsync("_Shell") -
+
@localizer["EditChecklist"]
@@ -43,6 +43,7 @@ @section Styles { + } @section Scripts { @await Html.PartialAsync("_ProtectionScripts") diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/EditSchedule.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/EditSchedule.cshtml index 657972b20..f80b69a67 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Checklists/EditSchedule.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/EditSchedule.cshtml @@ -17,7 +17,7 @@ @await Html.PartialAsync("_Shell") -
+
@localizer["EditSchedule"]
@@ -188,6 +188,7 @@ @section Styles { + } @section Scripts { @await Html.PartialAsync("_ProtectionScripts") diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/Index.cshtml index 73b3be16d..804e69230 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Checklists/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/Index.cshtml @@ -5,27 +5,27 @@ ViewData["Tab"] = "Index"; ViewData["Title"] = localizer["Checklists"].Value; ViewData["Subtitle"] = localizer["ChecklistsIntro"].Value; + + Func headerActions = @ + @if (Model.CanManage) + { + @localizer["NewChecklist"] + } + ; + ViewData["HeaderActions"] = headerActions; } @await Html.PartialAsync("_Shell") -
+
@localizer["Checklists"]
- @await Html.PartialAsync("_Tabs") + @await Html.PartialAsync("_Protection")
@localizer["HelpDefinitions"]
- @if (Model.CanManage) - { - - } - @if (Model.Definitions.Count == 0) {
@@ -82,5 +82,6 @@ @section Styles { + } @section Scripts { @await Html.PartialAsync("_ProtectionScripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/Locked.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/Locked.cshtml index bcb1cea95..bac830d8b 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Checklists/Locked.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/Locked.cshtml @@ -8,7 +8,7 @@ @await Html.PartialAsync("_Shell") -
+
@localizer["Protected checklists"]
@@ -31,5 +31,6 @@ @section Styles { + } @section Scripts { @await Html.PartialAsync("_ProtectionScripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/Occurrence.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/Occurrence.cshtml index fced35b19..2847d1845 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Checklists/Occurrence.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/Occurrence.cshtml @@ -11,48 +11,49 @@ }; } @{ + var departmentTime = DepartmentTime.From(ViewData); ViewBag.Title = "Resgrid | " + localizer["DueChecks"]; var state = (ChecklistOccurrenceState)Model.Occurrence.State; ViewData["Tab"] = "Due"; ViewData["Title"] = Model.Name; ViewData["ParentText"] = localizer["DueChecks"].Value; ViewData["ParentAction"] = "Due"; + + Func headerActions = @ + @localizer["DueChecks"] + @if (Model.CanStart) + { +
+ @Html.AntiForgeryToken() + + +
+ } + @if (Model.CanSkip) + { + + } +
; + ViewData["HeaderActions"] = headerActions; } @await Html.PartialAsync("_Shell") -
+
@Model.Name
- @await Html.PartialAsync("_Tabs") - @await Html.PartialAsync("_Protection") -
- @localizer["DueChecks"] -
- @if (Model.CanStart) - { -
- @Html.AntiForgeryToken() - - -
- } - @if (Model.CanSkip) - { - - } -
+ @await Html.PartialAsync("_Protection")

@localizer[state.ToString()] @Model.Target.Name

- + — - +

@if (Model.CanSkip) @@ -93,5 +94,6 @@ @section Styles { + } @section Scripts { @await Html.PartialAsync("_ProtectionScripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/ReadinessPacket.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/ReadinessPacket.cshtml index e35dadfa3..200f7cd6f 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Checklists/ReadinessPacket.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/ReadinessPacket.cshtml @@ -9,11 +9,11 @@ @await Html.PartialAsync("_Shell") -
+
@localizer["ReadinessPacketReport"]
- @await Html.PartialAsync("_Tabs") + @await Html.PartialAsync("_Protection")
@localizer["PacketMethod"]
@@ -41,7 +41,7 @@ @if (Model != null) { - + } else { @@ -60,5 +60,6 @@ @section Styles { + } @section Scripts { @await Html.PartialAsync("_ProtectionScripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/Reminders.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/Reminders.cshtml index 70cdb64ef..952bc8117 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Checklists/Reminders.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/Reminders.cshtml @@ -9,11 +9,11 @@ @await Html.PartialAsync("_Shell") -
+
@localizer["ReminderSettings"]
- @await Html.PartialAsync("_Tabs") + @await Html.PartialAsync("_Protection")
@localizer["ReminderHelp"]
@@ -59,5 +59,6 @@ @section Styles { + } @section Scripts { @await Html.PartialAsync("_ProtectionScripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/Run.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/Run.cshtml index ed2fce3f0..7bc8968fd 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Checklists/Run.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/Run.cshtml @@ -6,6 +6,7 @@ only supplies the chrome, the guidance and the form contract that script depends on. *@ @{ + var departmentTime = DepartmentTime.From(ViewData); ViewBag.Title = "Resgrid | " + Model.Form.Name; ViewData["Tab"] = "Index"; ViewData["Title"] = Model.Form.Name; @@ -17,13 +18,13 @@ @await Html.PartialAsync("_Shell") -
+
@Model.Form.Name
@await Html.PartialAsync("_Protection") -

@Model.Target.Name · @localizer["Started"] @Model.Completion.CreatedOn.ToString("u")

+

@Model.Target.Name · @localizer["Started"] @departmentTime.Format(Model.Completion.CreatedOn)

@if (!string.IsNullOrEmpty(Model.Form.Instructions)) {

@Model.Form.Instructions

@@ -53,6 +54,7 @@ @section Styles { + } @section Scripts { @await Html.PartialAsync("_ProtectionScripts") diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/Schedules.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/Schedules.cshtml index 55578bc68..fb56b70e6 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Checklists/Schedules.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/Schedules.cshtml @@ -6,28 +6,28 @@ ViewData["Tab"] = "Index"; ViewData["Title"] = localizer["Schedules"].Value; ViewData["Subtitle"] = localizer["ScheduleVersionGuidance"].Value; + + Func headerActions = @ + @localizer["Back"] + @if (Model.CanEdit) + { + @localizer["NewSchedule"] + } + ; + ViewData["HeaderActions"] = headerActions; } @await Html.PartialAsync("_Shell") -
+
@localizer["Schedules"]
- @await Html.PartialAsync("_Tabs") + @await Html.PartialAsync("_Protection")
@localizer["ScheduleVersionGuidance"]
-
- @localizer["Back"] -
- @if (Model.CanEdit) - { - @localizer["NewSchedule"] - } -
- @if (Model.Schedules.Count == 0) {
@@ -83,5 +83,6 @@ @section Styles { + } @section Scripts { @await Html.PartialAsync("_ProtectionScripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/Template.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/Template.cshtml index 289a78669..c9a9bf962 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Checklists/Template.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/Template.cshtml @@ -11,11 +11,10 @@ @await Html.PartialAsync("_Shell") -
+
@Model.Name
- @await Html.PartialAsync("_Tabs")
@localizer["Back"] @@ -67,4 +66,5 @@ @section Styles { + } diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/Templates.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/Templates.cshtml index cd6318fa4..8f6833db1 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Checklists/Templates.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/Templates.cshtml @@ -9,11 +9,10 @@ @await Html.PartialAsync("_Shell") -
+
@localizer["Templates"]
- @await Html.PartialAsync("_Tabs")
@localizer["Guidance"]
@@ -64,4 +63,5 @@ @section Styles { + } diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/_Shell.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/_Shell.cshtml index 3d2f9fbe4..363571657 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Checklists/_Shell.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/_Shell.cshtml @@ -16,8 +16,8 @@ var parentId = (string)ViewData["ParentId"]; var isRoot = string.Equals(title, localizer["Checklists"].Value, StringComparison.Ordinal); } -
-
+
+

@title

+ @await Html.PartialAsync("_WorkspaceHeaderActions") + @if (ViewData["Tab"] != null) + { +
+ @await Html.PartialAsync("_Tabs") +
+ }
diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/_Tabs.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/_Tabs.cshtml index 3e8808acf..b7bf4e54a 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Checklists/_Tabs.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/_Tabs.cshtml @@ -4,8 +4,8 @@ The module's tab strip. ViewData["Tab"] names the tab that owns the current screen, so a sub page (a definition, a run, a schedule) still shows where in the module it sits. - These are plain links: unlike inventory, checklist navigation is GET, so the theme's own - nav-tabs markup works without any help. + These are plain GET links in the page heading. Their permissions are unchanged when + moving between definition, run and scheduling screens. *@ @{ var current = (string)ViewData["Tab"] ?? "Index"; @@ -22,7 +22,7 @@ tabs.Add(("Reminders", "fa-bell-o", localizer["ReminderSettings"].Value)); } } -
@d.IncidentNumber @d.ResourceOrderNumber @(string.IsNullOrWhiteSpace(d.ContactId) ? "" : Model.ContactNames.TryGetValue(d.ContactId, out var contact) ? contact : "")@(d.StartOn.HasValue ? d.StartOn.Value.ToString("yyyy-MM-dd") : "—") → @(d.EndOn.HasValue ? d.EndOn.Value.ToString("yyyy-MM-dd") : "—")@(d.StartOn.HasValue ? departmentTime.Date(d.StartOn) : "—") → @(d.EndOn.HasValue ? departmentTime.Date(d.EndOn) : "—") @localizer["Open"]
@run.AddedOn.ToString("yyyy-MM-dd")@workforceStrings["RunType" + (Resgrid.Model.Workforce.FieldCostRunTypes)run.RunType]@departmentTime.Date(run.AddedOn)@workforceStrings["RunType" + (Resgrid.Model.Workforce.FieldCostRunTypes)run.RunType] @workforceStrings["RunStatus" + (Resgrid.Model.Workforce.FieldCostRunStatuses)run.Status] @run.TotalLoadedCost.ToString("N2") @run.Currency@(run.RevenueAmount?.ToString("N2") ?? "—")@(run.ContributionMargin?.ToString("N2") ?? "—")
@@ -66,3 +67,7 @@ + +@section Styles { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Invoicing/BillingProfile.cshtml b/Web/Resgrid.Web/Areas/User/Views/Invoicing/BillingProfile.cshtml index fe13ad715..f76e0aa8f 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Invoicing/BillingProfile.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Invoicing/BillingProfile.cshtml @@ -12,11 +12,16 @@ var write = Model.CanWrite && Model.CanManage; var components = Model.TaxComponents.Take(3).ToList(); while (components.Count < 3) { components.Add(new TaxComponent()); } + + Func headerActions = @ + @localizer["OpenContact"] + ; + ViewData["HeaderActions"] = headerActions; } @await Html.PartialAsync("_Shell") -
+
@if (Model.SaveSuccess) {
@localizer["Saved"]
@@ -30,9 +35,7 @@
@localizer["BillingProfile"] — @contactName
- +
@if (isNew) @@ -217,3 +220,7 @@ }); } + +@section Styles { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Invoicing/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Invoicing/Edit.cshtml index a448d208c..866a6719d 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Invoicing/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Invoicing/Edit.cshtml @@ -15,7 +15,7 @@ @await Html.PartialAsync("_Shell") -
+
@if (!string.IsNullOrWhiteSpace(Model.Message)) {
@Model.Message
@@ -273,3 +273,7 @@ }); } + +@section Styles { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Invoicing/EditRateCard.cshtml b/Web/Resgrid.Web/Areas/User/Views/Invoicing/EditRateCard.cshtml index e662d5e4a..999bad797 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Invoicing/EditRateCard.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Invoicing/EditRateCard.cshtml @@ -15,7 +15,7 @@ @await Html.PartialAsync("_Shell") -
+
@if (!string.IsNullOrWhiteSpace(Model.Message)) {
@Model.Message
@@ -241,3 +241,7 @@ } } + +@section Styles { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Invoicing/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Invoicing/Index.cshtml index 5f609e857..ca76b1810 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Invoicing/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Invoicing/Index.cshtml @@ -3,17 +3,28 @@ @using Resgrid.Web.Areas.User.Models.Invoicing @inject IStringLocalizer localizer @{ + var departmentTime = DepartmentTime.From(ViewData); ViewBag.Title = "Resgrid | " + localizer["Invoicing"]; ViewData["Tab"] = "Index"; + ViewData["Title"] = localizer["Invoicing"].Value; ViewData["Subtitle"] = localizer["InvoicingIntro"].Value; var message = ViewBag.Message as string; var currency = Model.Invoices.FirstOrDefault()?.Currency ?? "USD"; var pages = (int)Math.Ceiling(Model.TotalCount / (double)Model.PageSize); + + Func headerActions = @ + @if (Model.CanWrite && Model.CanManage) + { + @localizer["NewInvoice"] + + } + ; + ViewData["HeaderActions"] = headerActions; } @await Html.PartialAsync("_Shell") -
+
@if (!string.IsNullOrWhiteSpace(message)) {
@message
@@ -59,15 +70,9 @@
@localizer["Invoices"]
- @if (Model.CanWrite && Model.CanManage) - { - - } +
- @await Html.PartialAsync("_Tabs")
@@ -117,7 +122,7 @@
- + @@ -151,3 +156,7 @@ + +@section Styles { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Invoicing/New.cshtml b/Web/Resgrid.Web/Areas/User/Views/Invoicing/New.cshtml index ddea6e7b8..6458fc878 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Invoicing/New.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Invoicing/New.cshtml @@ -11,7 +11,7 @@ @await Html.PartialAsync("_Shell") -
+
@localizer["NewInvoice"]
@@ -89,3 +89,7 @@ }); } + +@section Styles { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Invoicing/RateCards.cshtml b/Web/Resgrid.Web/Areas/User/Views/Invoicing/RateCards.cshtml index 18f55e482..545547052 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Invoicing/RateCards.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Invoicing/RateCards.cshtml @@ -7,11 +7,20 @@ ViewData["Subtitle"] = localizer["RateCardsIntro"].Value; var message = ViewBag.Message as string; var write = Model.CanWrite && Model.CanManage; + + Func headerActions = @ + @if (write) + { + @localizer["NewRateCard"] + + } + ; + ViewData["HeaderActions"] = headerActions; } @await Html.PartialAsync("_Shell") -
+
@if (!string.IsNullOrWhiteSpace(message)) {
@message
@@ -19,15 +28,10 @@
@localizer["RateCards"]
- @if (write) - { - - } +
- @await Html.PartialAsync("_Tabs") +
@localizer["HelpRateCards"]
@if (Model.RateCards.Count == 0) { @@ -63,3 +67,7 @@
+ +@section Styles { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Invoicing/Settings.cshtml b/Web/Resgrid.Web/Areas/User/Views/Invoicing/Settings.cshtml index cbbeb706f..4206519d5 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Invoicing/Settings.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Invoicing/Settings.cshtml @@ -1,6 +1,7 @@ @model Resgrid.Web.Areas.User.Models.Invoicing.BillingSettingsView @inject IStringLocalizer localizer @{ + var departmentTime = DepartmentTime.From(ViewData); ViewBag.Title = "Resgrid | " + localizer["BillingSettings"]; ViewData["Tab"] = "Settings"; ViewData["Title"] = localizer["BillingSettings"].Value; @@ -16,7 +17,7 @@ @await Html.PartialAsync("_Shell") -
+
@if (Model.SaveSuccess) {
@localizer["Saved"]
@@ -28,9 +29,8 @@
@localizer["BillingSettings"]
- @await Html.PartialAsync("_Tabs") -
+ +@section Styles { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Invoicing/View.cshtml b/Web/Resgrid.Web/Areas/User/Views/Invoicing/View.cshtml index ec048c98a..783a80c6f 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Invoicing/View.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Invoicing/View.cshtml @@ -8,6 +8,7 @@ } } @{ + var departmentTime = DepartmentTime.From(ViewData); var invoice = Model.Invoice; ViewBag.Title = "Resgrid | " + localizer["Invoice"] + " #" + invoice.InvoiceNumber; ViewData["Tab"] = "Index"; @@ -19,17 +20,53 @@ var write = Model.CanWrite && Model.CanManage; var lines = (invoice.LineItems ?? new List()).OrderBy(x => x.SortOrder).ToList(); var payments = (invoice.Payments ?? new List()).OrderBy(x => x.PaidOn).ToList(); + + Func headerActions = @ + @if (isDraft && write) + { + @localizer["EditDraft"] + } + @localizer["DownloadPdf"] + @if (Model.ContractorPacket) + { + @localizer["DownloadPacket"] + @localizer["OpenDeployment"] + } + @if ((isDraft || isOpen) && write) + { + + } + @if (isDraft && write) + { + + @Html.AntiForgeryToken() + + + } + @if (isOpen && write) + { + + } + @if (canVoid && write) + { + + } + + @localizer["BackToInvoices"] + @localizer["BillingProfile"] + ; + ViewData["HeaderActions"] = headerActions; } @await Html.PartialAsync("_Shell") -
+
@if (!string.IsNullOrWhiteSpace(Model.Message)) {
@Model.Message
}
-
+
@localizer["Invoice"] #@invoice.InvoiceNumber — @(Model.ContactName(invoice.ContactId) ?? "—")
@@ -41,15 +78,15 @@
@localizer["Customer"]
@(Model.ContactName(invoice.ContactId) ?? "—")
@localizer["BillingEmail"]
@(Model.Profile?.BillingEmail ?? "—")
-
@localizer["IssuedOn"]
@(invoice.IssuedOn?.ToString("yyyy-MM-dd") ?? "—")
+
@localizer["IssuedOn"]
@(departmentTime.Date(invoice.IssuedOn) ?? "—")
@localizer["DueOn"]
@(invoice.DueOn?.ToString("yyyy-MM-dd") ?? "—")
@if (invoice.SentOn.HasValue) { -
@localizer["SentOn"]
@invoice.SentOn.Value.ToString("yyyy-MM-dd HH:mm") @if (!string.IsNullOrWhiteSpace(invoice.SentToEmail)) { (@invoice.SentToEmail) }
+
@localizer["SentOn"]
@departmentTime.Format(invoice.SentOn) @if (!string.IsNullOrWhiteSpace(invoice.SentToEmail)) { (@invoice.SentToEmail) }
} @if (invoice.VoidedOn.HasValue) { -
@localizer["VoidedOn"]
@invoice.VoidedOn.Value.ToString("yyyy-MM-dd") @invoice.VoidReason
+
@localizer["VoidedOn"]
@departmentTime.Date(invoice.VoidedOn) @invoice.VoidReason
}
@@ -104,7 +141,7 @@ @foreach (var payment in payments) {
- + @@ -128,43 +165,6 @@
-
-
@localizer["Actions"]
-
- @if (isDraft && write) - { - @localizer["EditDraft"] - } - @localizer["DownloadPdf"] - @if (Model.ContractorPacket) - { - @localizer["DownloadPacket"] - @localizer["OpenDeployment"] - } - @if ((isDraft || isOpen) && write) - { - - } - @if (isDraft && write) - { -
- @Html.AntiForgeryToken() - - - } - @if (isOpen && write) - { - - } - @if (canVoid && write) - { - - } -
- @localizer["BackToInvoices"] - @localizer["BillingProfile"] -
-
@if (Model.OnlinePayments != null) { @@ -182,7 +182,7 @@

@localizer["PayLinkHint"]

@if (Model.OpenRequest != null) { -

@localizer["OpenRequest"]: @localizer["RequestStatus" + (InvoicePaymentStatusesHelper.RequestStatus(Model.OpenRequest.Status))] @localizer["ExpiresOn"] @Model.OpenRequest.ExpiresOn.ToString("yyyy-MM-dd HH:mm") UTC

+

@localizer["OpenRequest"]: @localizer["RequestStatus" + (InvoicePaymentStatusesHelper.RequestStatus(Model.OpenRequest.Status))] @localizer["ExpiresOn"] @departmentTime.Format(Model.OpenRequest.ExpiresOn)

} @if (isOpen && write) { @@ -209,7 +209,7 @@ @foreach (var request in Model.PaymentRequests.Take(10)) {
- + @@ -271,7 +271,7 @@
- +
@@ -342,3 +342,7 @@ }); } + +@section Styles { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Invoicing/_Shell.cshtml b/Web/Resgrid.Web/Areas/User/Views/Invoicing/_Shell.cshtml index 4e5cb4232..d81798d76 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Invoicing/_Shell.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Invoicing/_Shell.cshtml @@ -15,8 +15,8 @@ var isRoot = string.Equals(title, localizer["Invoicing"].Value, StringComparison.Ordinal); var canWrite = ViewData["InvoicingCanWrite"] as bool? ?? false; } -
-
+
+

@title

+ @await Html.PartialAsync("_WorkspaceHeaderActions") + @if (ViewData["Tab"] != null) + { +
+ @await Html.PartialAsync("_Tabs") +
+ }
@if (!canWrite) { diff --git a/Web/Resgrid.Web/Areas/User/Views/Invoicing/_Tabs.cshtml b/Web/Resgrid.Web/Areas/User/Views/Invoicing/_Tabs.cshtml index 326a2d380..7b0e72423 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Invoicing/_Tabs.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Invoicing/_Tabs.cshtml @@ -16,7 +16,7 @@ tabs.Add(("Settings", "fa-cog", localizer["BillingSettings"].Value)); } } -
- + diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordEvidence/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordEvidence/Index.cshtml index 7939f4326..0e2f5f0dc 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordEvidence/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordEvidence/Index.cshtml @@ -2,6 +2,7 @@ @model Resgrid.Web.Areas.User.Models.Records.RecordEvidenceView @inject IStringLocalizer localizer @{ + var departmentTime = DepartmentTime.From(ViewData); var context = Model.Context; var recordController = context.RecordKind == RmsRecordKind.IncidentReport ? "IncidentReports" : "Records"; ViewBag.Title = "Resgrid | " + localizer["Evidence"]; @@ -68,7 +69,7 @@

@artifact.Reason

@localizer["EvidenceItems"]
@artifact.Items
-
@localizer["CapturedOn"]
@artifact.CapturedOn.ToString("yyyy-MM-dd HH:mm:ss") UTC
+
@localizer["CapturedOn"]
@departmentTime.Format(artifact.CapturedOn)
@localizer["EvidenceSourceVersion"]
@artifact.SourceVersion
@localizer["Checksum"]
@artifact.Checksum
diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordEvidence/Select.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordEvidence/Select.cshtml index 92b04758d..f4f4fe1bf 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordEvidence/Select.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordEvidence/Select.cshtml @@ -7,6 +7,7 @@ workspace screens. The field names the capture posts are unchanged. *@ @{ + var departmentTime = DepartmentTime.From(ViewData); var selection = Model.Selection; var context = selection.Context; var source = selection.Sources.FirstOrDefault(s => s.Kind == selection.SourceKind); @@ -127,11 +128,11 @@
- +
- +
} @@ -140,7 +141,7 @@
- +
} @@ -164,11 +165,11 @@ @(selection.SourceKind == RmsEvidenceKind.ModuleProjection ? localizer["Projection" + choice.Id].Value : choice.Label) @if (choice.Sequence.HasValue) { - · #@choice.Sequence · @choice.OccurredOn?.ToString("yyyy-MM-dd HH:mm:ss") UTC + · #@choice.Sequence · @departmentTime.Format(choice.OccurredOn) } @if (choice.EditedOn.HasValue) { - · @localizer["EvidenceEdited"] @choice.EditedOn.Value.ToString("yyyy-MM-dd HH:mm:ss") UTC + · @localizer["EvidenceEdited"] @departmentTime.Format(choice.EditedOn) } @if (choice.Body != null) { diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordHydrants/Details.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordHydrants/Details.cshtml index d368fe35e..ee5946b40 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordHydrants/Details.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordHydrants/Details.cshtml @@ -3,6 +3,7 @@ @model RecordHydrantDetailsView @inject IStringLocalizer L @{ + var departmentTime = DepartmentTime.From(ViewData); var h = Model.Hydrant; ViewBag.Title = "Resgrid | " + L["Hydrants"] + " | " + h.HydrantNumber; var returnUrl = Url.Action("Details", "RecordHydrants", new { area = "User", id = h.RmsHydrantId }); @@ -39,10 +40,10 @@
@L["MainSize"]
@h.MainSizeInches
@L["FlowClass"]
@(RmsEnumDisplay.Label(h.FlowClass)) · @h.FlowGpm gpm
@L["StaticPressure"]
@h.StaticPressurePsi psi · @L["ResidualPressure"] @h.ResidualPressurePsi psi
-
@L["LastTestedOn"]
@RmsEnumDisplay.Day(h.LastTestedOn)
-
@L["LastMaintainedOn"]
@RmsEnumDisplay.Day(h.LastMaintainedOn)
+
@L["LastTestedOn"]
@departmentTime.Date(h.LastTestedOn)
+
@L["LastMaintainedOn"]
@departmentTime.Date(h.LastMaintainedOn)
@L["Source"]
@h.Source
- @if (!h.InService) {
@L["OutOfServiceReason"]
@h.OutOfServiceReason (@RmsEnumDisplay.Utc(h.OutOfServiceSince))
} + @if (!h.InService) {
@L["OutOfServiceReason"]
@h.OutOfServiceReason (@departmentTime.Format(h.OutOfServiceSince))
} @if (!string.IsNullOrWhiteSpace(h.Notes)) {

@h.Notes

} @if (Model.CanAdminister) @@ -70,7 +71,7 @@
@foreach (var t in Model.Aggregate.FlowTests.OrderByDescending(t => t.TestedOn)) { - + }
#@invoice.InvoiceNumber @(Model.ContactName(invoice.ContactId) ?? "—") @await Html.PartialAsync("_Status", invoice.Status)@(invoice.IssuedOn?.ToString("yyyy-MM-dd") ?? "—")@(departmentTime.Date(invoice.IssuedOn) ?? "—") @(invoice.DueOn?.ToString("yyyy-MM-dd") ?? "—") @invoice.Total.ToString("N2") @invoice.Currency @invoice.Balance.ToString("N2") @invoice.Currency
@payment.PaidOn.ToString("yyyy-MM-dd")@departmentTime.Date(payment.PaidOn) @localizer["Method" + (InvoicePaymentMethods)payment.Method] @payment.PaymentMethodSummary @payment.Reference @localizer["PaymentStatus" + (InvoicePaymentStatuses)payment.Status]@(payment.RefundedAmount > 0 ? " (-" + payment.RefundedAmount.ToString("N2") + ")" : "")
@request.AddedOn.ToString("yyyy-MM-dd HH:mm")@departmentTime.Format(request.AddedOn) @localizer["RequestStatus" + InvoicePaymentStatusesHelper.RequestStatus(request.Status)] @request.Amount.ToString("N2") @request.Currency
@RmsEnumDisplay.Day(a.OccurredOn)@departmentTime.Date(a.OccurredOn) @(RmsEnumDisplay.Label(a.Kind)) @a.Title @a.LocationText
@RmsEnumDisplay.Day(t.TestedOn)@t.StaticPressurePsi@t.ResidualPressurePsi@t.PitotPressurePsi@t.OutletDiameterInches (c=@t.Coefficient)@t.FlowGpm@(RmsEnumDisplay.Label(t.FlowClass))
@departmentTime.Date(t.TestedOn)@t.StaticPressurePsi@t.ResidualPressurePsi@t.PitotPressurePsi@t.OutletDiameterInches (c=@t.Coefficient)@t.FlowGpm@(RmsEnumDisplay.Label(t.FlowClass))
@@ -80,7 +81,7 @@
@Html.AntiForgeryToken()
-
+
@@ -101,7 +102,7 @@ @foreach (var m in Model.Aggregate.Maintenance.OrderByDescending(m => m.PerformedOn)) { - @RmsEnumDisplay.Day(m.PerformedOn)@(RmsEnumDisplay.Label(m.Kind))@m.Notes@(m.ReturnedToService ? L["Yes"].Value : "") + @departmentTime.Date(m.PerformedOn)@(RmsEnumDisplay.Label(m.Kind))@m.Notes@(m.ReturnedToService ? L["Yes"].Value : "") } @@ -110,7 +111,7 @@ { @Html.AntiForgeryToken() - + diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordHydrants/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordHydrants/Index.cshtml index 145f8ada8..4e39d4618 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordHydrants/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordHydrants/Index.cshtml @@ -3,6 +3,7 @@ @model RecordHydrantsIndexView @inject IStringLocalizer L @{ + var departmentTime = DepartmentTime.From(ViewData); ViewBag.Title = "Resgrid | " + L["Hydrants"]; }
@@ -54,7 +55,7 @@ @h.FlowGpm @h.MainSizeInches @(h.InService ? L["Yes"].Value : L["No"].Value) - @RmsEnumDisplay.Day(h.LastTestedOn) + @departmentTime.Date(h.LastTestedOn) @L["ViewRecordHeader"] } diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordInspections/Details.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordInspections/Details.cshtml index f90f656ba..bc8420463 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordInspections/Details.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordInspections/Details.cshtml @@ -3,6 +3,7 @@ @model RecordInspectionDetailsView @inject IStringLocalizer L @{ + var departmentTime = DepartmentTime.From(ViewData); var i = Model.Inspection; ViewBag.Title = "Resgrid | " + L["Inspections"] + " | " + i.InspectionNumber; var returnUrl = Url.Action("Details", "RecordInspections", new { area = "User", id = i.RmsInspectionId }); @@ -30,13 +31,13 @@
@L["Occupancies"]
@if (Model.Aggregate.Occupancy != null) { @Model.Aggregate.Occupancy.OccupancyNumber @Text(Model.Aggregate.Occupancy.Name)
@Text(Model.Aggregate.Occupancy.AddressText) } else { @i.RmsOccupancyId }
@L["InspectionPrograms"]
@Model.Aggregate.Program?.Name
-
@L["ScheduledOn"]
@RmsEnumDisplay.Utc(i.ScheduledOn)
+
@L["ScheduledOn"]
@departmentTime.Format(i.ScheduledOn)
@L["Inspector"]
@i.InspectorUserId
-
@L["StartedOn"]
@RmsEnumDisplay.Utc(i.StartedOn)
-
@L["CompletedOn"]
@RmsEnumDisplay.Utc(i.CompletedOn)
+
@L["StartedOn"]
@departmentTime.Format(i.StartedOn)
+
@L["CompletedOn"]
@departmentTime.Format(i.CompletedOn)
@L["InspectionResult"]
@(RmsEnumDisplay.Label(i.Result))
-
@L["SignatureName"]
@Text(i.SignatureName) @RmsEnumDisplay.Utc(i.SignedOn)
-
@L["NoticeReference"]
@Text(i.NoticeReference) @RmsEnumDisplay.Utc(i.NoticeIssuedOn)
+
@L["SignatureName"]
@Text(i.SignatureName) @departmentTime.Format(i.SignedOn)
+
@L["NoticeReference"]
@Text(i.NoticeReference) @departmentTime.Format(i.NoticeIssuedOn)
@if (!string.IsNullOrWhiteSpace(i.ParentInspectionId)) {
@L["ReinspectionOf"]
@i.ParentInspectionId
}
@if (!string.IsNullOrWhiteSpace(i.Notes)) {

@Text(i.Notes)

} @@ -53,7 +54,7 @@ @Html.AntiForgeryToken()
@Html.AntiForgeryToken() -
+
@Html.AntiForgeryToken()
} @if (i.State == (int)RmsInspectionState.Scheduled || i.State == (int)RmsInspectionState.InProgress) @@ -118,7 +119,7 @@ @Text(v.Description)
@v.ChecklistItemKey @v.RmsCodeSectionId @Text(v.CorrectiveAction) @RmsEnumDisplay.Day(v.DueOn) - @(RmsEnumDisplay.Label(v.State))
@RmsEnumDisplay.Day(v.CorrectedOn) @RmsEnumDisplay.Day(v.VerifiedOn) + @(RmsEnumDisplay.Label(v.State))
@departmentTime.Date(v.CorrectedOn) @departmentTime.Date(v.VerifiedOn) @if (Model.CanAdminister) { diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordInspections/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordInspections/Index.cshtml index 4bd312bdd..b80691668 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordInspections/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordInspections/Index.cshtml @@ -3,6 +3,7 @@ @model RecordInspectionsIndexView @inject IStringLocalizer L @{ + var departmentTime = DepartmentTime.From(ViewData); ViewBag.Title = "Resgrid | " + L["Inspections"]; var now = DateTime.UtcNow; string Occ(string id) => Model.OccupancyNames.TryGetValue(id ?? "", out var n) ? n : id; @@ -51,8 +52,8 @@ @i.InspectionNumber @Occ(i.RmsOccupancyId) @(RmsEnumDisplay.Label(i.State)) - @RmsEnumDisplay.Day(i.ScheduledOn) - @RmsEnumDisplay.Day(i.CompletedOn) + @departmentTime.Date(i.ScheduledOn) + @departmentTime.Date(i.CompletedOn) @(RmsEnumDisplay.Label(i.Result)) @L["ViewRecordHeader"] diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Custody.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Custody.cshtml index fa872f138..1fb6aff7a 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Custody.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Custody.cshtml @@ -3,6 +3,7 @@ @model RecordInvestigationCustodyView @inject IStringLocalizer L @{ + var departmentTime = DepartmentTime.From(ViewData); var e = Model.Evidence; ViewBag.Title = "Resgrid | " + L["CustodyChain"] + " | " + e.EvidenceNumber; string Text(string v) => Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(v); @@ -28,7 +29,7 @@
@L["Kind"]
@(RmsEnumDisplay.Label(e.Kind))
@L["DefinitionDescription"]
@Text(e.Description)
-
@L["CollectedOn"]
@RmsEnumDisplay.Utc(e.CollectedOn) · @Model.UserName(e.CollectedByUserId)
+
@L["CollectedOn"]
@departmentTime.Format(e.CollectedOn) · @Model.UserName(e.CollectedByUserId)
@L["CollectedFrom"]
@Text(e.CollectedFrom)
@L["State"]
@(RmsEnumDisplay.Label(e.State))
@L["Custodian"]
@(string.IsNullOrWhiteSpace(e.CurrentCustodianUserId) ? Text(e.CurrentCustodianExternal) : Model.UserName(e.CurrentCustodianUserId))
@@ -53,7 +54,7 @@ @foreach (var t in Model.Chain.OrderBy(t => t.Sequence)) { - @t.Sequence@RmsEnumDisplay.Utc(t.TransferredOn)@(string.IsNullOrWhiteSpace(t.FromUserId) ? Text(t.FromExternal) : Model.UserName(t.FromUserId))@(string.IsNullOrWhiteSpace(t.ToUserId) ? Text(t.ToExternal) : Model.UserName(t.ToUserId))@(RmsEnumDisplay.Label(t.ResultingState))@Text(t.Reason)@Model.UserName(t.RecordedByUserId) + @t.Sequence@departmentTime.Format(t.TransferredOn)@(string.IsNullOrWhiteSpace(t.FromUserId) ? Text(t.FromExternal) : Model.UserName(t.FromUserId))@(string.IsNullOrWhiteSpace(t.ToUserId) ? Text(t.ToExternal) : Model.UserName(t.ToUserId))@(RmsEnumDisplay.Label(t.ResultingState))@Text(t.Reason)@Model.UserName(t.RecordedByUserId) } diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Details.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Details.cshtml index 0a63c7f0c..6c0f2af05 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Details.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Details.cshtml @@ -3,6 +3,7 @@ @model RecordInvestigationDetailsView @inject IStringLocalizer L @{ + var departmentTime = DepartmentTime.From(ViewData); var c = Model.Case; ViewBag.Title = "Resgrid | " + L["Investigations"] + " | " + c.CaseNumber; var returnUrl = Url.Action("Details", "RecordInvestigations", new { area = "User", id = c.RmsInvestigationCaseId }); @@ -36,7 +37,7 @@
@Html.AntiForgeryToken()
-

@RmsEnumDisplay.Utc(c.OpenedOn) · @Model.UserName(c.OpenedByUserId)

+

@departmentTime.Format(c.OpenedOn) · @Model.UserName(c.OpenedByUserId)

@if (Model.IsLead && !c.IsClosed) { } else {

@Model.UserName(c.LeadInvestigatorUserId)

}
@if (!string.IsNullOrWhiteSpace(c.RmsOccupancyId)) { @L["ViewRecordHeader"] }
@@ -45,8 +46,8 @@
@L["Findings"]
- @if (c.FindingsApprovedOn.HasValue) {

@L["FindingsApprovedNotice"] @RmsEnumDisplay.Utc(c.FindingsApprovedOn) · @Model.UserName(c.FindingsApprovedByUserId)

} - else if (c.FindingsRecordedOn.HasValue) {

@L["FindingsPendingNotice"] @RmsEnumDisplay.Utc(c.FindingsRecordedOn) · @Model.UserName(c.FindingsAuthorUserId)

} + @if (c.FindingsApprovedOn.HasValue) {

@L["FindingsApprovedNotice"] @departmentTime.Format(c.FindingsApprovedOn) · @Model.UserName(c.FindingsApprovedByUserId)

} + else if (c.FindingsRecordedOn.HasValue) {

@L["FindingsPendingNotice"] @departmentTime.Format(c.FindingsRecordedOn) · @Model.UserName(c.FindingsAuthorUserId)

}
@Html.AntiForgeryToken()
@@ -69,7 +70,7 @@ @foreach (var m in Model.Aggregate.Members.Where(m => m.IsActive)) { - @Model.UserName(m.UserId)@(RmsEnumDisplay.Label(m.Role))@RmsEnumDisplay.Day(m.AddedOn)@if (Model.IsLead && !c.IsClosed && m.UserId != c.LeadInvestigatorUserId) { @Html.AntiForgeryToken() } + @Model.UserName(m.UserId)@(RmsEnumDisplay.Label(m.Role))@departmentTime.Date(m.AddedOn)@if (Model.IsLead && !c.IsClosed && m.UserId != c.LeadInvestigatorUserId) {
@Html.AntiForgeryToken()
} } @@ -87,7 +88,7 @@

@L["LinkedIncidentsHelp"]

@foreach (var inc in Model.Aggregate.Incidents) { -

@inc.RecordNumber · @L["PinnedRevision"] @inc.PinnedRevisionId · @RmsEnumDisplay.Day(inc.LinkedOn) +

@inc.RecordNumber · @L["PinnedRevision"] @inc.PinnedRevisionId · @departmentTime.Date(inc.LinkedOn) @if (Model.CanWrite) {

@Html.AntiForgeryToken()
}

} @if (Model.CanWrite) @@ -102,7 +103,7 @@
@L["Referrals"]
@foreach (var r in Model.Aggregate.Referrals) { -

@Text(r.Agency) · @(RmsEnumDisplay.Label(r.State)) · @RmsEnumDisplay.Day(r.ReferredOn) · @Text(r.ReferenceNumber)
@Text(r.Reason) +

@Text(r.Agency) · @(RmsEnumDisplay.Label(r.State)) · @departmentTime.Date(r.ReferredOn) · @Text(r.ReferenceNumber)
@Text(r.Reason) @if (Model.CanWrite) {

@Html.AntiForgeryToken()
}

} @if (Model.CanWrite) @@ -120,7 +121,7 @@ @foreach (var n in Model.Aggregate.Notes.OrderByDescending(n => n.OccurredOn)) {
-

@Text(n.Subject) @(RmsEnumDisplay.Label(n.Kind)) · @RmsEnumDisplay.Utc(n.OccurredOn) · @Model.UserName(n.AuthorUserId) @(n.IsLocked ? Html.Raw("") : null)

+

@Text(n.Subject) @(RmsEnumDisplay.Label(n.Kind)) · @departmentTime.Format(n.OccurredOn) · @Model.UserName(n.AuthorUserId) @(n.IsLocked ? Html.Raw("") : null)

@Text(n.Body)

} @@ -142,7 +143,7 @@ @foreach (var e in Model.Aggregate.Evidence) { - @e.EvidenceNumber@(RmsEnumDisplay.Label(e.Kind))@Text(e.Description)
@RmsEnumDisplay.Day(e.CollectedOn) @Text(e.CollectedFrom)@(RmsEnumDisplay.Label(e.State))@(string.IsNullOrWhiteSpace(e.CurrentCustodianUserId) ? Text(e.CurrentCustodianExternal) : Model.UserName(e.CurrentCustodianUserId))@L["CustodyChain"] + @e.EvidenceNumber@(RmsEnumDisplay.Label(e.Kind))@Text(e.Description)
@departmentTime.Date(e.CollectedOn) @Text(e.CollectedFrom)@(RmsEnumDisplay.Label(e.State))@(string.IsNullOrWhiteSpace(e.CurrentCustodianUserId) ? Text(e.CurrentCustodianExternal) : Model.UserName(e.CurrentCustodianUserId))@L["CustodyChain"] } @@ -171,7 +172,7 @@ } else { -

@L["ClosedOn"] @RmsEnumDisplay.Utc(c.ClosedOn) · @Model.UserName(c.ClosedByUserId)
@Text(c.ClosureReason)

+

@L["ClosedOn"] @departmentTime.Format(c.ClosedOn) · @Model.UserName(c.ClosedByUserId)
@Text(c.ClosureReason)

@Html.AntiForgeryToken() @@ -181,7 +182,7 @@
@L["AccessAudit"]
- @foreach (var a in Model.Audit) { } + @foreach (var a in Model.Audit) { }
@RmsEnumDisplay.Utc(a.OccurredOn)@Model.UserName(a.ActorUserId)@(RmsEnumDisplay.Label(a.Action))@(a.Successful ? "" : L["Rejected"].Value)
@departmentTime.Format(a.OccurredOn)@Model.UserName(a.ActorUserId)@(RmsEnumDisplay.Label(a.Action))@(a.Successful ? "" : L["Rejected"].Value)
} diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Index.cshtml index 1258faa62..599f137f6 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Index.cshtml @@ -3,6 +3,7 @@ @model RecordInvestigationsIndexView @inject IStringLocalizer L @{ + var departmentTime = DepartmentTime.From(ViewData); ViewBag.Title = "Resgrid | " + L["Investigations"]; }
@@ -42,8 +43,8 @@ @Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(c.Title) @(RmsEnumDisplay.Label(c.State)) @(RmsEnumDisplay.Label(c.CauseClassification)) - @RmsEnumDisplay.Day(c.OpenedOn) - @RmsEnumDisplay.Day(c.ClosedOn) + @departmentTime.Date(c.OpenedOn) + @departmentTime.Date(c.ClosedOn) @L["ViewRecordHeader"] } diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordLegalHolds/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordLegalHolds/Index.cshtml index 3aba46343..8d16dad77 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordLegalHolds/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordLegalHolds/Index.cshtml @@ -6,6 +6,7 @@ a written basis, so each opens in a dialog rather than sitting inline under the list. *@ @{ + var departmentTime = DepartmentTime.From(ViewData); ViewBag.Title = "Resgrid | " + localizer["LegalHoldsHeader"]; ViewData["Title"] = localizer["LegalHoldsHeader"].Value; ViewData["Subtitle"] = localizer["LegalHoldsIntro"].Value; @@ -68,19 +69,19 @@ @(hold.DefinitionKey ?? localizer["AllDefinitions"].Value) - @(hold.PeriodStart?.ToString("u") ?? localizer["HoldAnyStart"].Value) + @(departmentTime.Format(hold.PeriodStart) ?? localizer["HoldAnyStart"].Value) — - @(hold.PeriodEnd?.ToString("u") ?? localizer["HoldAnyEnd"].Value) + @(departmentTime.Format(hold.PeriodEnd) ?? localizer["HoldAnyEnd"].Value) @localizer[hold.IsActive ? "HoldActive" : "Released"] @if (hold.IsActive) { - @localizer["HoldPlacedBy"]: @hold.PlacedByUserId · @hold.PlacedOn.ToString("u") + @localizer["HoldPlacedBy"]: @hold.PlacedByUserId · @departmentTime.Format(hold.PlacedOn) } else { - @localizer["HoldReleasedBy"]: @hold.ReleasedByUserId · @hold.ReleasedOn?.ToString("u") + @localizer["HoldReleasedBy"]: @hold.ReleasedByUserId · @departmentTime.Format(hold.ReleasedOn) } diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Crosswalk.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Crosswalk.cshtml index d9e3e96b8..1a7786bb6 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Crosswalk.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Crosswalk.cshtml @@ -3,6 +3,7 @@ @model RecordOccupancyCrosswalkView @inject IStringLocalizer L @{ + var departmentTime = DepartmentTime.From(ViewData); ViewBag.Title = "Resgrid | " + L["PreplanCrosswalk"]; var s = Model.Status; } @@ -26,13 +27,13 @@

@L["CrosswalkIntroText"]

@L["DeploymentStatus"]
@(RmsEnumDisplay.Label((int)s.State))
-
@L["InventoriedOn"]
@RmsEnumDisplay.Utc(s.InventoriedOn)
+
@L["InventoriedOn"]
@departmentTime.Format(s.InventoriedOn)
@L["Occupancies"]
@s.Occupancies
@L["Candidates"]
@s.Candidates
@L["LinkedCount"]
@s.Linked
@L["Rejected"]
@s.Rejected
@L["UnreconciledPreplans"]
@s.UnreconciledPreplans
-
@L["SwitchedOn"]
@RmsEnumDisplay.Utc(s.SwitchedOn)
+
@L["SwitchedOn"]
@departmentTime.Format(s.SwitchedOn)
@if (s.State != RmsOccupancyOwnershipState.RecordsOwned) { @@ -105,7 +106,7 @@ @foreach (var c in Model.Decided) { - @Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(c.SourceDisplayName)@(RmsEnumDisplay.Label(c.State))@(c.RmsOccupancyId != null && Model.OccupancyNames.TryGetValue(c.RmsOccupancyId, out var n) ? n : "")@RmsEnumDisplay.Utc(c.DecidedOn) + @Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(c.SourceDisplayName)@(RmsEnumDisplay.Label(c.State))@(c.RmsOccupancyId != null && Model.OccupancyNames.TryGetValue(c.RmsOccupancyId, out var n) ? n : "")@departmentTime.Format(c.DecidedOn) } diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Details.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Details.cshtml index b48abb151..92dec267b 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Details.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Details.cshtml @@ -3,6 +3,7 @@ @model RecordOccupancyDetailsView @inject IStringLocalizer L @{ + var departmentTime = DepartmentTime.From(ViewData); var o = Model.Occupancy; var name = Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(o.Name); ViewBag.Title = "Resgrid | " + L["Occupancies"] + " | " + o.OccupancyNumber; @@ -48,8 +49,8 @@
@L["Stories"]
@o.Stories · @L["YearBuilt"] @o.YearBuilt · @o.SquareFeet ft² · @L["OccupantLoad"] @o.OccupantLoad
@L["OccupancyHours"]
@Text(o.OccupancyHours)
@L["OccupantsNeedingAssistance"]
@(o.HasOccupantsNeedingAssistance ? L["Yes"].Value : L["No"].Value) @Text(o.OccupantsNeedingAssistanceNotes)
-
@L["ReviewDue"]
@RmsEnumDisplay.Day(o.NextReviewDue) · @L["LastReviewedOn"] @RmsEnumDisplay.Day(o.LastReviewedOn)
-
@L["LastInspectedOn"]
@RmsEnumDisplay.Day(o.LastInspectedOn)
+
@L["ReviewDue"]
@RmsEnumDisplay.Day(o.NextReviewDue) · @L["LastReviewedOn"] @departmentTime.Date(o.LastReviewedOn)
+
@L["LastInspectedOn"]
@departmentTime.Date(o.LastInspectedOn)
@if (Model.CanAdminister && o.Status != (int)RmsOccupancyStatus.Merged) { @@ -168,7 +169,7 @@ @foreach (var i in Model.Inspections.OrderByDescending(i => i.ScheduledOn)) { - @i.InspectionNumber@(RmsEnumDisplay.Label(i.State))@RmsEnumDisplay.Day(i.ScheduledOn)@(RmsEnumDisplay.Label(i.Result)) + @i.InspectionNumber@(RmsEnumDisplay.Label(i.State))@departmentTime.Date(i.ScheduledOn)@(RmsEnumDisplay.Label(i.Result)) } @@ -178,7 +179,7 @@ @Html.AntiForgeryToken() - + } @@ -203,12 +204,12 @@
@L["Provenance"]
@foreach (var c in Model.Aggregate.Crosswalks) { -

@(RmsEnumDisplay.Label(c.SourceKind)) @c.SourceId · @(RmsEnumDisplay.Label(c.State)) · @RmsEnumDisplay.Utc(c.DecidedOn)

+

@(RmsEnumDisplay.Label(c.SourceKind)) @c.SourceId · @(RmsEnumDisplay.Label(c.State)) · @departmentTime.Format(c.DecidedOn)

} @if (Model.Aggregate.Provenance.Count > 0) { - @foreach (var p in Model.Aggregate.Provenance.OrderBy(p => p.FieldKey)) { } + @foreach (var p in Model.Aggregate.Provenance.OrderBy(p => p.FieldKey)) { }
@L["FieldKey"]@L["Source"]@L["CapturedOn"]
@p.FieldKey@(RmsEnumDisplay.Label(p.SourceKind)) @p.SourceId@RmsEnumDisplay.Utc(p.CapturedOn)
@p.FieldKey@(RmsEnumDisplay.Label(p.SourceKind)) @p.SourceId@departmentTime.Format(p.CapturedOn)
}
diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Index.cshtml index 9e529ba36..4c5ae65bf 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Index.cshtml @@ -3,6 +3,7 @@ @model RecordOccupanciesIndexView @inject IStringLocalizer L @{ + var departmentTime = DepartmentTime.From(ViewData); ViewBag.Title = "Resgrid | " + L["Occupancies"]; var now = DateTime.UtcNow; } @@ -60,7 +61,7 @@ @Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(o.AddressText)@(string.IsNullOrWhiteSpace(o.City) ? "" : ", " + o.City) @(RmsEnumDisplay.Label(o.Status)) @RmsEnumDisplay.Day(o.NextReviewDue) - @RmsEnumDisplay.Day(o.LastInspectedOn) + @departmentTime.Date(o.LastInspectedOn) @L["ViewRecordHeader"] } diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordPermits/Details.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordPermits/Details.cshtml index 0d617999d..cbff12699 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordPermits/Details.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordPermits/Details.cshtml @@ -3,6 +3,7 @@ @model RecordPermitDetailsView @inject IStringLocalizer L @{ + var departmentTime = DepartmentTime.From(ViewData); var p = Model.Permit; ViewBag.Title = "Resgrid | " + L["Permits"] + " | " + p.PermitNumber; var returnUrl = Url.Action("Details", "RecordPermits", new { area = "User", id = p.RmsPermitId }); @@ -40,14 +41,14 @@
@L["Occupancies"]
@if (Model.Aggregate.Occupancy != null) { @Model.Aggregate.Occupancy.OccupancyNumber @Text(Model.Aggregate.Occupancy.Name) }
@L["Applicant"]
@Text(p.ApplicantName)
@Text(p.ApplicantPhone) @Text(p.ApplicantEmail)
@L["DefinitionDescription"]
@Text(p.Description)
-
@L["AppliedOn"]
@RmsEnumDisplay.Utc(p.AppliedOn)
-
@L["ReviewedOn"]
@RmsEnumDisplay.Utc(p.ReviewedOn) @p.ReviewedByUserId
-
@L["IssuedOn"]
@RmsEnumDisplay.Utc(p.IssuedOn) @p.IssuedByUserId
+
@L["AppliedOn"]
@departmentTime.Format(p.AppliedOn)
+
@L["ReviewedOn"]
@departmentTime.Format(p.ReviewedOn) @p.ReviewedByUserId
+
@L["IssuedOn"]
@departmentTime.Format(p.IssuedOn) @p.IssuedByUserId
@L["EffectiveOn"]
@RmsEnumDisplay.Day(p.EffectiveOn) · @L["ExpiresOn"] @RmsEnumDisplay.Day(p.ExpiresOn)
@L["Conditions"]
@Text(p.Conditions)
@L["ReviewNotes"]
@Text(p.ReviewNotes)
@L["DecisionReason"]
@Text(p.DecisionReason)
-
@L["FeeAmount"]
@p.FeeAmount @(p.FeePaidOn.HasValue ? "· " + L["FeePaidOn"] + " " + RmsEnumDisplay.Day(p.FeePaidOn) + " " + p.InvoiceReference : "")
+
@L["FeeAmount"]
@p.FeeAmount @(p.FeePaidOn.HasValue ? "· " + L["FeePaidOn"] + " " + departmentTime.Date(p.FeePaidOn) + " " + p.InvoiceReference : "")
@@ -62,7 +63,7 @@ @foreach (var r in Model.Aggregate.PlanReviews.OrderBy(r => r.CycleNumber)) { - @r.CycleNumber@RmsEnumDisplay.Day(r.SubmittedOn)@RmsEnumDisplay.Day(r.ReviewedOn) @r.ReviewerUserId@(RmsEnumDisplay.Label(r.Outcome))@Text(r.Comments) + @r.CycleNumber@departmentTime.Date(r.SubmittedOn)@departmentTime.Date(r.ReviewedOn) @r.ReviewerUserId@(RmsEnumDisplay.Label(r.Outcome))@Text(r.Comments) } diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordPermits/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordPermits/Index.cshtml index fa996ba09..29b1a3b41 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordPermits/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordPermits/Index.cshtml @@ -3,6 +3,7 @@ @model RecordPermitsIndexView @inject IStringLocalizer L @{ + var departmentTime = DepartmentTime.From(ViewData); ViewBag.Title = "Resgrid | " + L["Permits"]; string Occ(string id) => string.IsNullOrWhiteSpace(id) ? "" : Model.OccupancyNames.TryGetValue(id, out var n) ? n : id; } @@ -50,7 +51,7 @@ @Occ(p.RmsOccupancyId) @Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(p.ApplicantName) @(RmsEnumDisplay.Label(p.State)) - @RmsEnumDisplay.Day(p.AppliedOn) + @departmentTime.Date(p.AppliedOn) @RmsEnumDisplay.Day(p.ExpiresOn) @L["ViewRecordHeader"] diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordSubmissions/Details.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordSubmissions/Details.cshtml index 9659ea516..ef52c3ce9 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordSubmissions/Details.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordSubmissions/Details.cshtml @@ -14,6 +14,7 @@ }; } @{ + var departmentTime = DepartmentTime.From(ViewData); var submission = Model.Submission; var canBind = submission.DestinationIdentity == null && submission.SentOn == null && submission.Attempts == 0 && submission.ExternalId == null && !submission.RequiresReconciliation && !submission.CreatePendingReceipt; @@ -96,7 +97,7 @@ @foreach (var exchange in Model.Exchanges.OrderBy(e => e.OccurredOn).ThenBy(e => e.RmsSubmissionExchangeId)) { - @exchange.OccurredOn.ToString("u") + @departmentTime.Format(exchange.OccurredOn) @exchange.Operation / @exchange.Stage @exchange.ExchangeId diff --git a/Web/Resgrid.Web/Areas/User/Views/Records/FieldRollout.cshtml b/Web/Resgrid.Web/Areas/User/Views/Records/FieldRollout.cshtml index 67f11e332..07c363c4e 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Records/FieldRollout.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Records/FieldRollout.cshtml @@ -2,6 +2,7 @@ @model Resgrid.Web.Areas.User.Models.Records.RecordsFieldRolloutView @inject IStringLocalizer localizer @{ + var departmentTime = DepartmentTime.From(ViewData); ViewBag.Title = "Resgrid | " + localizer["FieldRollout"]; } @functions { @@ -180,6 +181,6 @@ }
-

@string.Format(localizer["RolloutGeneratedOn"].Value, Model.Rollout.GeneratedOn.ToString("u"))

+

@string.Format(localizer["RolloutGeneratedOn"].Value, departmentTime.Format(Model.Rollout.GeneratedOn))

}
diff --git a/Web/Resgrid.Web/Areas/User/Views/Records/_DefinitionFields.cshtml b/Web/Resgrid.Web/Areas/User/Views/Records/_DefinitionFields.cshtml index 2a162bb5b..be716321b 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Records/_DefinitionFields.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Records/_DefinitionFields.cshtml @@ -132,8 +132,10 @@ break; case RmsFieldType.DateTime: { - var local = DateTime.TryParse(Value, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var utc) ? Local(utc.TimeConverter(Model.Department)) : string.Empty; - sb.Append(""); + var time = new DepartmentTime(Model.Department); + var posted = Model.PostedValues?.Contains(Model.Input(section.Key, rowKey, field.Key)) == true; + var local = posted ? Value : time.Input(time.Parse(Value)); + sb.Append(""); sb.Append(""); break; } diff --git a/Web/Resgrid.Web/Areas/User/Views/Records/_DefinitionValues.cshtml b/Web/Resgrid.Web/Areas/User/Views/Records/_DefinitionValues.cshtml index c5c9b5840..472ace3d2 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Records/_DefinitionValues.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Records/_DefinitionValues.cshtml @@ -2,13 +2,14 @@ @model Resgrid.Web.Areas.User.Models.Records.RecordDetailView @inject IStringLocalizer localizer @{ + var time = new DepartmentTime(Model.Department); // Definition-driven values on the detail page (RMS-1B): rendered against the pinned version's labels; restricted // cells are withheld here for viewers without RecordRestricted_View. var values = Model.Aggregate.Values; var layout = Model.DefinitionLayout; var sections = values == null ? new List() : (layout == null ? values.Sections : layout.OrderedSectionKeys(values.Sections.Select(s => s.SectionKey)).Select(k => values.Section(k)).Where(s => s != null).ToList()); bool Visible(RecordValueCell c) => layout == null || layout.IsFieldVisible(c.FieldKey); - string Cell(RecordValueCell c) => c == null || c.Display == null ? "-" : (c.Withheld || (c.Classification == RmsFieldClassification.Restricted && !Model.CanViewRestricted)) ? localizer["WithheldField"].Value : c.Display; + string Cell(RecordValueCell c) => c == null || c.Display == null ? "-" : (c.Withheld || (c.Classification == RmsFieldClassification.Restricted && !Model.CanViewRestricted)) ? localizer["WithheldField"].Value : c.Type == RmsFieldType.DateTime ? time.Format(time.Parse(c.Value)) : c.Display; } @if (values != null) { diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/Index.cshtml index 692c36ac6..397b9c714 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/Index.cshtml @@ -3,6 +3,7 @@ @model RecordsExecutiveView @inject IStringLocalizer L @{ + var departmentTime = DepartmentTime.From(ViewData); ViewBag.Title = "Resgrid | " + L["AnalyticsHeader"] + " - " + L["AnalyticsExecutive"]; var s = Model.Summary; string KpiLabel(string key) @@ -26,7 +27,7 @@
@if (s != null) { -
@L["AnalyticsHeadline"]
@L["AnalyticsPriorPeriod"]: @s.PriorStart.ToString("yyyy-MM-dd") - @s.PriorEnd.AddDays(-1).ToString("yyyy-MM-dd")
+
@L["AnalyticsHeadline"]
@L["AnalyticsPriorPeriod"]: @departmentTime.Date(s.PriorStart) - @departmentTime.Date(s.PriorEnd.AddDays(-1))
@foreach (var k in s.Kpis) { diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/Readiness.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/Readiness.cshtml index 66d3fc8fe..3c286099a 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/Readiness.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/Readiness.cshtml @@ -3,6 +3,7 @@ @model RecordsReadinessView @inject IStringLocalizer L @{ + var departmentTime = DepartmentTime.From(ViewData); ViewBag.Title = "Resgrid | " + L["AnalyticsHeader"] + " - " + L["ReadinessHeader"]; var r = Model.Readiness; string StateLabel(int? state) => !state.HasValue ? "-" : System.Enum.IsDefined(typeof(UnitStateTypes), state.Value) ? RmsEnumDisplay.Label(state.Value) : state.Value.ToString(); @@ -31,7 +32,7 @@ @if (u.NeedsAttention) { } @u.Label @(u.StationGroupLabel ?? "-") - @StateLabel(u.State) @if (u.StateOn.HasValue) { (@RmsEnumDisplay.Utc(u.StateOn)) } + @StateLabel(u.State) @if (u.StateOn.HasValue) { (@departmentTime.Format(u.StateOn)) } @(r.Checklists == null ? "-" : u.ChecklistCompleted + "/" + u.ChecklistExpected) @(r.Checklists == null ? "-" : u.ChecklistMissed.ToString()) @(r.Checklists == null ? "-" : RmsAnalyticsDisplay.Percent(u.ChecklistCompletionRatePercent)) @@ -57,7 +58,7 @@ else { var ck = r.Checklists; -

@L["ReadinessCohort"]: @ck.CohortStart.ToString("yyyy-MM-dd") - @ck.CohortEnd.AddDays(-1).ToString("yyyy-MM-dd")

+

@L["ReadinessCohort"]: @departmentTime.Date(ck.CohortStart) - @departmentTime.Date(ck.CohortEnd.AddDays(-1))

@if (ck.IsRedacted) {
@L["ReadinessRedacted"]
} @foreach (var s in ck.UnavailableSources) {
@L["ReadinessUnavailableSources"]: @s
}
@@ -96,7 +97,7 @@ else { var wo = r.WorkOrders; -

@L["ReadinessCohort"]: @wo.CohortStart.ToString("yyyy-MM-dd") - @wo.CohortEnd.AddDays(-1).ToString("yyyy-MM-dd")

+

@L["ReadinessCohort"]: @departmentTime.Date(wo.CohortStart) - @departmentTime.Date(wo.CohortEnd.AddDays(-1))

@wo.Total
@L["AnalyticsTotal"]
@wo.Open
@L["Open"]
diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsShell.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsShell.cshtml index e5c3fc530..9231f4244 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsShell.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsShell.cshtml @@ -3,6 +3,7 @@ @model RecordsAnalyticsBaseView @inject IStringLocalizer L @{ + var departmentTime = DepartmentTime.From(ViewData); // Action, icon, caption, and which of the filters that action accepts, so switching tabs carries the filter // state across instead of silently resetting it to the defaults. var tabs = new[] @@ -50,7 +51,7 @@ } - @if (result != null) { @L["GeneratedOn"] @RmsEnumDisplay.Utc(result.GeneratedOn) · @L["AnalyticsRecordsRead"]: @result.RecordsRead } + @if (result != null) { @L["GeneratedOn"] @departmentTime.Format(result.GeneratedOn) · @L["AnalyticsRecordsRead"]: @result.RecordsRead }

@L["AnalyticsIntro"]

diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordsHealth/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordsHealth/Index.cshtml index c5c40126f..bac1716bc 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordsHealth/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordsHealth/Index.cshtml @@ -3,6 +3,7 @@ @model RecordsHealthView @inject IStringLocalizer L @{ + var departmentTime = DepartmentTime.From(ViewData); ViewBag.Title = "Resgrid | " + L["RecordsHealth"]; var t = Model.Telemetry; var p = Model.Prevention; @@ -23,7 +24,7 @@ - @L["GeneratedOn"] @RmsEnumDisplay.Utc(t.GeneratedOn) + @L["GeneratedOn"] @departmentTime.Format(t.GeneratedOn) @foreach (var a in t.Alerts) {
@a
} @foreach (var w in t.Warnings) {
@w
} @@ -32,7 +33,7 @@
@L["ActivateHeader"]
-
@L["RecordsModuleActive"]
@(t.RecordsActive ? L["Yes"].Value : L["No"].Value) @RmsEnumDisplay.Utc(t.ActivatedOn)
+
@L["RecordsModuleActive"]
@(t.RecordsActive ? L["Yes"].Value : L["No"].Value) @departmentTime.Format(t.ActivatedOn)
@L["LegacyWriteAttempts"]
@t.LegacyWriteAttempts
@L["AuthorizationDenials"]
@t.AuthorizationDenials
@L["SearchIndexStatus"]
@(t.SearchEnabled ? L["SearchOnline"].Value : L["SearchDisabled"].Value)
diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordsInventory/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordsInventory/Edit.cshtml index 0f8da7ad7..0f544d4e6 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordsInventory/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordsInventory/Edit.cshtml @@ -6,6 +6,7 @@ @model Resgrid.Web.Areas.User.Models.Records.RecordInventoryView @inject IStringLocalizer inventoryLocalizer @{ + var departmentTime = DepartmentTime.From(ViewData); ViewBag.Title = "Resgrid | " + inventoryLocalizer["RecordUsageTitle"]; var controller = Model.Kind == RmsRecordKind.IncidentReport ? "IncidentReports" : "Records"; var protectedForms = new System.Collections.Generic.List { "#record-inventory-reopen", "#record-inventory-consume", "#record-inventory-evidence" }; @@ -47,7 +48,7 @@ @if (!string.IsNullOrEmpty(usage.ReversesUsageId)) { @inventoryLocalizer["RecordUsageReverses"] @usage.ReversesUsageId } @if (usage.IsReversed) { @inventoryLocalizer["RecordUsageReversed"] } @if (!string.IsNullOrEmpty(usage.PendingReversalTransactionId)) {

@inventoryLocalizer["RecordUsageReconciliationRequired"] @usage.PendingReversalTransactionId

} - @usage.CapturedOn.ToString("u") + @departmentTime.Format(usage.CapturedOn) @if (Model.ModernInventory && !string.IsNullOrEmpty(usage.UsageId) && string.IsNullOrEmpty(usage.ReversesUsageId) && !usage.IsReversed) { var formId = "record-inventory-reverse-" + correctionIndex++; var retryingCorrection = Model.UsageId == usage.UsageId; diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordsQuality/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordsQuality/Index.cshtml index 7e569a551..66225149d 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordsQuality/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordsQuality/Index.cshtml @@ -3,6 +3,7 @@ @model RecordsQualityIndexView @inject IStringLocalizer L @{ + var departmentTime = DepartmentTime.From(ViewData); ViewBag.Title = "Resgrid | " + L["QualityReview"]; }
@@ -40,7 +41,7 @@ {
@Html.AntiForgeryToken() - +
} @@ -59,7 +60,7 @@ @foreach (var p in Model.Pending) { - @p.RecordNumber@p.DefinitionKey@p.AuthorUserId@RmsEnumDisplay.Day(p.SampledOn)@L["ScoreReview"] + @p.RecordNumber@p.DefinitionKey@p.AuthorUserId@departmentTime.Date(p.SampledOn)@L["ScoreReview"] } diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordsQuality/Review.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordsQuality/Review.cshtml index 98b00bc70..175d652aa 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordsQuality/Review.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordsQuality/Review.cshtml @@ -3,6 +3,7 @@ @model RecordsQualityReviewView @inject IStringLocalizer L @{ + var departmentTime = DepartmentTime.From(ViewData); var r = Model.Review; ViewBag.Title = "Resgrid | " + L["ScoreReview"] + " | " + r.RecordNumber; var scored = r.ScoredOn.HasValue; @@ -25,8 +26,8 @@
@L["RecordNumber"]
@r.RecordNumber · @r.DefinitionKey · @L["Revision"] @r.RevisionId
@L["Author"]
@r.AuthorUserId
-
@L["SampledOn"]
@RmsEnumDisplay.Utc(r.SampledOn)
- @if (scored) {
@L["ScoredOn"]
@RmsEnumDisplay.Utc(r.ScoredOn) · @r.ReviewerUserId · @r.Score%
} +
@L["SampledOn"]
@departmentTime.Format(r.SampledOn)
+ @if (scored) {
@L["ScoredOn"]
@departmentTime.Format(r.ScoredOn) · @r.ReviewerUserId · @r.Score%
}

@L["ScoreHelp"]

diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordsQuality/Trends.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordsQuality/Trends.cshtml index 1ac467e3c..a0c84ef2d 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordsQuality/Trends.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordsQuality/Trends.cshtml @@ -3,6 +3,7 @@ @model RecordsQualityTrendsView @inject IStringLocalizer L @{ + var departmentTime = DepartmentTime.From(ViewData); ViewBag.Title = "Resgrid | " + L["Trends"]; var t = Model.Trends; } @@ -20,7 +21,7 @@
- +

@L["Sampled"]: @t.Sampled · @L["Scored"]: @t.Scored · @L["AverageScore"]: @t.AverageScore.ToString("0.0")%

diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_CalOesMarsShell.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_CalOesMarsShell.cshtml index 0792b4e47..3de42d27c 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Shared/_CalOesMarsShell.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_CalOesMarsShell.cshtml @@ -10,8 +10,8 @@ var isManager = ViewData["MarsIsManager"] as bool? ?? false; var canReconcile = ViewData["MarsCanReconcile"] as bool? ?? false; } -
-
+ diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_ContractorShell.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_ContractorShell.cshtml index 180e76ee8..4c4dd0694 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Shared/_ContractorShell.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_ContractorShell.cshtml @@ -11,8 +11,8 @@ var rootTitle = (string)ViewData["RootTitle"]; var isRoot = string.IsNullOrWhiteSpace(rootController) || string.Equals(title, rootTitle, StringComparison.Ordinal); } -
-
+
+

@title

+ @await Html.PartialAsync("_WorkspaceHeaderActions")
diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml index 20b9c3ef7..687390633 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml @@ -18,18 +18,38 @@ // Records.System flag: Records takes the Logs sidebar position and the Logs link is absent. The // existing LogsDisabled module setting still controls whether that position is visible at all. var recordsEnabled = await featureToggleService.IsEnabledAsync(FeatureFlagKeys.RecordsSystem, ClaimsAuthorizationHelper.GetDepartmentId()); + // Keep legacy modules flat; only new feature areas get collapsible groups. + var showCertifications = ClaimsAuthorizationHelper.IsUserDepartmentAdmin() || ClaimsAuthorizationHelper.CanViewCertifications(); + var showInvoicing = ClaimsAuthorizationHelper.CanViewInvoicing() && SettingsHelper.IsBusinessOperationsEnabled() && await featureToggleService.IsEnabledAsync(FeatureFlagKeys.CustomerInvoicing, ClaimsAuthorizationHelper.GetDepartmentId()); + var showDeployments = await featureToggleService.IsEnabledAsync(FeatureFlagKeys.Deployments, ClaimsAuthorizationHelper.GetDepartmentId()); + var showContractorBilling = (ClaimsAuthorizationHelper.IsUserDepartmentAdmin() || ClaimsAuthorizationHelper.CanViewBids() || ClaimsAuthorizationHelper.CanViewContracts()) && SettingsHelper.IsBusinessOperationsEnabled() && await businessOperationsAccess.CanUseContractorBillingAsync(ClaimsAuthorizationHelper.GetDepartmentId()); + var showCostRecovery = SettingsHelper.IsBusinessOperationsEnabled() && await businessOperationsAccess.CanUseCostRecoveryAsync(ClaimsAuthorizationHelper.GetDepartmentId()); + var showWorkOrders = await readinessAccess.CanUseMaintenanceAsync(ClaimsAuthorizationHelper.GetDepartmentId()); + var showChecklists = await readinessAccess.CanUseChecklistsAsync(ClaimsAuthorizationHelper.GetDepartmentId()); + var workforceEnabled = SettingsHelper.IsBusinessOperationsEnabled() && await businessOperationsAccess.CanUseWorkforceAsync(ClaimsAuthorizationHelper.GetDepartmentId()); + var payDataEnabled = SettingsHelper.IsBusinessOperationsEnabled() && await businessOperationsAccess.CanUsePayDataReportingAsync(ClaimsAuthorizationHelper.GetDepartmentId()); + var workforceManager = ClaimsAuthorizationHelper.IsUserDepartmentAdmin() || ClaimsAuthorizationHelper.CanViewWorkforce() || ClaimsAuthorizationHelper.CanViewInternalCosts() || ClaimsAuthorizationHelper.CanViewPayDataReporting(); + var showWorkforce = (workforceEnabled || payDataEnabled) && workforceManager; + var showMyDemographics = payDataEnabled && !showWorkforce; + var onWorkforceBusinessPage = Html.IsController("Certifications", "Workforce", "Invoicing", "Deployments", "Bids", "Contracts", "CalOesMars"); + var onReadinessPage = Html.IsController("WorkOrders", "WorkOrderMaintenance", "WorkOrderOperations", "WorkOrderReporting", + "Checklists", "ChecklistsScheduling", "ChecklistReports", "ChecklistReporting", "ChecklistReminders"); } -
diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_WorkspaceHeaderActions.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_WorkspaceHeaderActions.cshtml new file mode 100644 index 000000000..06862f30b --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_WorkspaceHeaderActions.cshtml @@ -0,0 +1,11 @@ +@using Microsoft.AspNetCore.Html +@* Pages supply their existing, permission-gated actions as a Razor template. Keeping the + template in the page preserves its model, routes, antiforgery tokens and modal targets. *@ +@if (ViewData["HeaderActions"] is Func actions) +{ +
+
+ @actions(null) +
+
+} diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Bulk.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Bulk.cshtml index f7e21b2e0..b4c02fd6b 100644 --- a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Bulk.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Bulk.cshtml @@ -11,11 +11,11 @@ @await Html.PartialAsync("_Shell") -
+
@localizer["BulkOperations"]
- @await Html.PartialAsync("_Tabs") + @await Html.PartialAsync("_Protection")
@localizer["BulkHelp"]
@@ -161,5 +161,6 @@ @section Styles { + } @section Scripts { @await Html.PartialAsync("_Scripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Detail.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Detail.cshtml index 8d59fc3c8..d66979fd6 100644 --- a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Detail.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Detail.cshtml @@ -19,6 +19,7 @@ }; } @{ + var departmentTime = DepartmentTime.From(ViewData); var d = Model.Detail; var o = d.Order; var c = d.Input.Content; @@ -32,62 +33,65 @@ string UserName(string userId) => Model.Choices.Users.FirstOrDefault(x => x.Id == userId)?.Name ?? userId; string RoleName(int? roleId) => Model.Choices.Roles.FirstOrDefault(x => x.Id == roleId?.ToString())?.Name ?? roleId?.ToString(); - var assignedTo = Model.Choices.Users.FirstOrDefault(x => x.Id == o.AssignedToUserId)?.Name - ?? Model.Choices.Roles.FirstOrDefault(x => x.Id == o.AssignedToRoleId?.ToString())?.Name - ?? localizer["None"].Value; + var assignees = o.AssignedToUserIds.Select(UserName).Concat(o.AssignedToRoleIds.Select(id => RoleName(id))).ToList(); + var assignedTo = assignees.Count == 0 ? localizer["None"].Value : string.Join(", ", assignees); var canComment = d.CanWrite && o.Status is not (WorkOrderStatus.Closed or WorkOrderStatus.Cancelled or WorkOrderStatus.Rejected or WorkOrderStatus.Duplicate) && (d.CanContribute || d.ReportedBy == User.Identity.Name || d.ReportedBy == Resgrid.Web.Helpers.ClaimsAuthorizationHelper.GetUserId()); var canUpload = d.CanContribute || (d.CanWrite && o.Status == WorkOrderStatus.Requested && d.ReportedBy == Resgrid.Web.Helpers.ClaimsAuthorizationHelper.GetUserId()); + + Func headerActions = @ +
+ @localizer["WorkOrders"] + @if (d.CanAccept) + { +
+ @Html.AntiForgeryToken() + + + +
+ } + @if (d.Transitions.Length > 0) + { + + } + @if (d.CanManage && o.Status is WorkOrderStatus.Accepted or WorkOrderStatus.Assigned or WorkOrderStatus.InProgress or WorkOrderStatus.OnHold) + { + + } + @if (d.CanEdit) + { + @localizer["EditWorkOrder"] + } + @localizer["Operations"] +
+ @Html.AntiForgeryToken() + + +
+ +
+
; + ViewData["HeaderActions"] = headerActions; } @await Html.PartialAsync("_Shell") -
+
@o.Number — @o.Title
- @await Html.PartialAsync("_Tabs") + @await Html.PartialAsync("_Protection")
-
- @localizer["WorkOrders"] -
- @if (d.CanAccept) - { -
- @Html.AntiForgeryToken() - - - -
- } - @if (d.Transitions.Length > 0) - { - - } - @if (d.CanManage && o.Status is WorkOrderStatus.Accepted or WorkOrderStatus.Assigned or WorkOrderStatus.InProgress or WorkOrderStatus.OnHold) - { - - } - @if (d.CanEdit) - { - @localizer["EditWorkOrder"] - } - @localizer["Operations"] -
- @Html.AntiForgeryToken() - - -
-

@localizer["Status" + o.Status] @localizer["Priority" + o.Priority] - @localizer["DueOn"]: @o.DueOn?.ToString("u") + @localizer["DueOn"]: @departmentTime.Format(o.DueOn)

@if (!d.CanWrite) @@ -146,7 +150,7 @@ @if (d.ClosedOn.HasValue) {

@localizer["VerificationEvidence"]: @c.VerificationEvidence

-

@localizer["VerifiedBy"]: @UserName(d.VerifiedBy) · @d.ClosedOn?.ToString("u")

+

@localizer["VerifiedBy"]: @UserName(d.VerifiedBy) · @departmentTime.Format(d.ClosedOn)

}
@@ -312,7 +316,7 @@ @foreach (var a in d.Activities) { - @a.CreatedOn.ToString("u") + @departmentTime.Format(a.CreatedOn) @localizer["Activity" + a.Type] @if (a.NewStatus.HasValue) @@ -332,7 +336,7 @@ } @if (a.RevisedDueOn.HasValue) { - @localizer["OriginalDue"]: @a.OriginalDueOn?.ToString("u") → @localizer["RevisedDue"]: @a.RevisedDueOn.Value.ToString("u") + @localizer["OriginalDue"]: @departmentTime.Format(a.OriginalDueOn) → @localizer["RevisedDue"]: @departmentTime.Format(a.RevisedDueOn) } @if (a.Snapshot != null) { @@ -379,7 +383,7 @@
- +
@@ -523,7 +527,7 @@ }
- +
@@ -726,5 +730,6 @@ @section Styles { + } @section Scripts { @await Html.PartialAsync("_Scripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Edit.cshtml index 5071c1b0c..a50163602 100644 --- a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Edit.cshtml @@ -2,7 +2,7 @@ @using Resgrid.Model.WorkOrders @inject IStringLocalizer localizer @{ - var isNew = Model.Id == 0; + var isNew = Model.Id == null; ViewBag.Title = "Resgrid | " + localizer[isNew ? "NewWorkOrder" : "EditWorkOrder"]; ViewData["Tab"] = "Index"; ViewData["Title"] = localizer[isNew ? "NewWorkOrder" : "EditWorkOrder"].Value; @@ -11,7 +11,7 @@ @await Html.PartialAsync("_Shell") -
+
@localizer[isNew ? "NewWorkOrder" : "EditWorkOrder"]
@@ -47,6 +47,7 @@ @section Styles { + } @section Scripts { diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/EditRecurrence.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/EditRecurrence.cshtml index 80b93df74..26c8258ce 100644 --- a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/EditRecurrence.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/EditRecurrence.cshtml @@ -18,7 +18,7 @@ @await Html.PartialAsync("_Shell") -
+
@localizer["PreventiveMaintenance"]
@@ -62,9 +62,9 @@
@@ -134,7 +134,7 @@
- +
@@ -159,11 +159,11 @@

@localizer["AssignmentAndEscalation"]

@localizer["StepRecurrenceAssignmentHint"]

+

@localizer["MultipleAssigneesHint"]

- - @foreach (var x in Model.Choices.Users) { @@ -171,9 +171,8 @@
- - @foreach (var x in Model.Choices.Roles) { @@ -196,7 +195,7 @@
- +
@@ -221,6 +220,7 @@ @section Styles { + } @section Scripts { diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Index.cshtml index dda5b18db..bd9d00380 100644 --- a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Index.cshtml @@ -20,20 +20,29 @@ }; } @{ + var departmentTime = DepartmentTime.From(ViewData); ViewBag.Title = "Resgrid | " + localizer["WorkOrders"]; ViewData["Tab"] = "Index"; ViewData["Title"] = localizer["WorkOrders"].Value; ViewData["Subtitle"] = localizer["WorkOrdersIntro"].Value; ViewData["CanWrite"] = Model.Orders.CanWrite; + + Func headerActions = @ + @if (Model.Orders.CanWrite) + { + @localizer["NewWorkOrder"] + } + ; + ViewData["HeaderActions"] = headerActions; } @await Html.PartialAsync("_Shell") -
+
@localizer["WorkOrders"]
- @await Html.PartialAsync("_Tabs") + @await Html.PartialAsync("_Protection")
@localizer["HelpWorkOrders"]
@@ -115,14 +124,6 @@
- @if (Model.Orders.CanWrite) - { - - } -
@if (Model.Orders.Items.Count == 0) { @@ -159,7 +160,7 @@ @if (item.DueOn.HasValue) { - @item.DueOn.Value.ToString("yyyy-MM-dd HH:mm") + @departmentTime.Format(item.DueOn) } @@ -203,5 +204,6 @@ @section Styles { + } @section Scripts { @await Html.PartialAsync("_Scripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Locked.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Locked.cshtml index 66ef4fe7b..c11d632e3 100644 --- a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Locked.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Locked.cshtml @@ -8,7 +8,7 @@ @await Html.PartialAsync("_Shell") -
+
@localizer["WorkOrders"]
@@ -39,5 +39,6 @@ @section Styles { + } @section Scripts { @await Html.PartialAsync("_Scripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Operations.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Operations.cshtml index 574701a48..2cd76b03b 100644 --- a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Operations.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Operations.cshtml @@ -7,6 +7,7 @@ opens in a dialog rather than sitting inline in a table cell. *@ @{ + var departmentTime = DepartmentTime.From(ViewData); var d = Model.Detail; var id = d.Order.Id; var c = d.Input.Content; @@ -18,32 +19,36 @@ ViewData["ParentId"] = id.ToString(); ViewData["CanWrite"] = d.CanWrite; var staged = d.Parts.Where(p => p.Staged).ToList(); + + Func headerActions = @ + + ; + ViewData["HeaderActions"] = headerActions; } @await Html.PartialAsync("_Shell") -
+
@localizer["Operations"] · @d.Order.Title
- @await Html.PartialAsync("_Tabs") + @await Html.PartialAsync("_Protection")
- -
@localizer["ResponseDue"]
-

@d.ResponseDueOn?.ToString("u")

@localizer["RespondedOn"]: @d.ResponseOn?.ToString("u")
+

@departmentTime.Format(d.ResponseDueOn)

@localizer["RespondedOn"]: @departmentTime.Format(d.ResponseOn)
@localizer["RepairDue"]
-

@d.RepairDueOn?.ToString("u")

+

@departmentTime.Format(d.RepairDueOn)

@@ -183,7 +188,7 @@ @movement.PartId @localizer["PartMovement" + (WorkOrderPartMovementKind)movement.Kind] @movement.Quantity - @movement.CreatedOn.ToString("u") + @departmentTime.Format(movement.CreatedOn) @localizer[movement.Cancelled ? "StatusCancelled" : pending ? "InventoryWitnessPending" : "Done"] @@ -313,8 +318,8 @@
- - + +

@c.Currency

@@ -511,5 +516,6 @@ @section Styles { + } @section Scripts { @await Html.PartialAsync("_Scripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Recurrence.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Recurrence.cshtml index f2446a506..dbefe6f8c 100644 --- a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Recurrence.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Recurrence.cshtml @@ -2,6 +2,7 @@ @using Newtonsoft.Json.Linq @inject IStringLocalizer localizer @{ + var departmentTime = DepartmentTime.From(ViewData); var row = Model.Detail.Schedule; var settings = Model.Detail.Settings; ViewBag.Title = "Resgrid | " + localizer["PreventiveMaintenance"]; @@ -11,38 +12,42 @@ ViewData["ParentAction"] = "Recurrences"; ViewData["CanWrite"] = Model.CanWrite; var canRecord = Model.CanWrite && (row.MeterUnit != 0 || row.Condition != 0); + + Func headerActions = @ +
+ @localizer["PreventiveMaintenance"] + @if (canRecord) + { + + } + @if (Model.CanWrite) + { + @localizer["Edit"] + } + @if (row.PendingWorkOrderId != null) + { + @localizer["WorkOrders"] + } + +
+
; + ViewData["HeaderActions"] = headerActions; } @await Html.PartialAsync("_Shell") -
+
@settings.Template.Content.Title
- @await Html.PartialAsync("_Tabs") + @await Html.PartialAsync("_Protection")
-
- @localizer["PreventiveMaintenance"] -
- @if (canRecord) - { - - } - @if (Model.CanWrite) - { - @localizer["Edit"] - } - @if (row.PendingWorkOrderId.HasValue) - { - @localizer["WorkOrders"] - } -

@localizer[row.IsActive ? "Active" : "Paused"] - @localizer["NextDue"]: @row.NextDueOn?.ToString("u") + @localizer["NextDue"]: @departmentTime.Format(row.NextDueOn)

@if (!string.IsNullOrEmpty(settings.Template.Content.Description)) { @@ -77,7 +82,7 @@ { var content = JObject.Parse(reading.Content ?? "{}"); - @reading.ObservedOn.ToString("u") + @departmentTime.Format(reading.ObservedOn) @content["MeterValue"] @content["ConditionValue"] @content["Source"] @@ -104,10 +109,10 @@ { var content = JObject.Parse(change.Content ?? "{}"); - @change.CreatedOn.ToString("u") + @departmentTime.Format(change.CreatedOn) @localizer["MaintenanceChange" + change.ChangeType] @content["Note"] - @change.OriginalDueOn?.ToString("u") → @change.RevisedDueOn?.ToString("u") + @departmentTime.Format(change.OriginalDueOn) → @departmentTime.Format(change.RevisedDueOn) } @@ -147,8 +152,8 @@
- - + +
@if (row.MeterUnit != 0) { @@ -194,5 +199,6 @@ @section Styles { + } @section Scripts { @await Html.PartialAsync("_Scripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Recurrences.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Recurrences.cshtml index 9534279f5..ebfa0b2a2 100644 --- a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Recurrences.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Recurrences.cshtml @@ -2,32 +2,36 @@ @using Resgrid.Model.WorkOrders @inject IStringLocalizer localizer @{ + var departmentTime = DepartmentTime.From(ViewData); ViewBag.Title = "Resgrid | " + localizer["PreventiveMaintenance"]; ViewData["Tab"] = "Recurrences"; ViewData["Title"] = localizer["PreventiveMaintenance"].Value; ViewData["Subtitle"] = localizer["RecurrenceHelp"].Value; ViewData["CanWrite"] = Model.CanWrite; + + Func headerActions = @ +
+ @if (Model.CanWrite) + { + @localizer["NewRecurrence"] + } +
+
; + ViewData["HeaderActions"] = headerActions; } @await Html.PartialAsync("_Shell") -
+
@localizer["PreventiveMaintenance"]
- @await Html.PartialAsync("_Tabs") + @await Html.PartialAsync("_Protection")
@localizer["RecurrenceHelp"]
- @if (Model.CanWrite) - { - - } @if (Model.Rows.Count == 0) { @@ -47,10 +51,10 @@ { @entry.Settings.Template.Content.Title - @entry.Schedule.NextDueOn?.ToString("u") + @departmentTime.Format(entry.Schedule.NextDueOn) @localizer[entry.Schedule.IsActive ? "Active" : "Paused"] - @if (entry.Schedule.PendingWorkOrderId.HasValue) + @if (entry.Schedule.PendingWorkOrderId != null) { @localizer["WorkOrders"] } @@ -83,5 +87,6 @@ @section Styles { + } @section Scripts { @await Html.PartialAsync("_Scripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Reports.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Reports.cshtml index 190ff7228..3518b25f7 100644 --- a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Reports.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Reports.cshtml @@ -2,6 +2,7 @@ @using Resgrid.Model.WorkOrders @inject IStringLocalizer localizer @{ + var departmentTime = DepartmentTime.From(ViewData); var isHistory = Model.Stats == null; var destination = isHistory ? "History" : "Reports"; var heading = localizer[isHistory ? "ServiceHistory" : "MaintenanceReports"].Value; @@ -9,15 +10,16 @@ ViewData["Tab"] = destination; ViewData["Title"] = heading; ViewData["Subtitle"] = localizer[isHistory ? "HistoryMethod" : "ReportMethod"].Value; + ViewData["CanWrite"] = Model.CanWrite; } @await Html.PartialAsync("_Shell") -
+
@heading
- @await Html.PartialAsync("_Tabs") + @await Html.PartialAsync("_Protection")
@localizer[isHistory ? "HistoryMethod" : "ReportMethod"]
@@ -29,11 +31,11 @@
- +
- +
@@ -99,7 +101,7 @@
@localizer["TotalOrders"]
-

@stats.Total

@localizer["AsOfUtc"]: @stats.AsOfUtc.ToString("u")
+

@stats.Total

@localizer["AsOfUtc"]: @departmentTime.Format(stats.AsOfUtc)
@localizer["OpenOrders"]
@@ -206,7 +208,7 @@ @entry.Order.Title @localizer["Status" + entry.Order.Status] @localizer["Priority" + entry.Order.Priority] - @entry.Order.CreatedOn.ToString("u") + @departmentTime.Format(entry.Order.CreatedOn) @(entry.RepairHours?.ToString("0.##") ?? "—") @(entry.ActiveRepairHours?.ToString("0.##") ?? "—") @(entry.WaitingHours?.ToString("0.##") ?? "—") @@ -219,7 +221,7 @@
} - @if (Model.History.NextAfterId.HasValue) + @if (Model.History.NextAfterId != null) {
@localizer["Next"] @@ -232,5 +234,6 @@ @section Styles { + } @section Scripts { @await Html.PartialAsync("_Scripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Policy.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Settings.cshtml similarity index 73% rename from Web/Resgrid.Web/Areas/User/Views/WorkOrders/Policy.cshtml rename to Web/Resgrid.Web/Areas/User/Views/WorkOrders/Settings.cshtml index e0d382242..a173f40b0 100644 --- a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Policy.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Settings.cshtml @@ -2,49 +2,50 @@ @using Resgrid.Model.WorkOrders @inject IStringLocalizer localizer @{ - ViewBag.Title = "Resgrid | " + localizer["OperationsPolicy"]; + ViewBag.Title = "Resgrid | " + localizer["WorkOrderSettings"]; var p = Model.Input; - ViewData["Tab"] = "Policy"; - ViewData["Title"] = localizer["OperationsPolicy"].Value; - ViewData["Subtitle"] = localizer["PolicyHelp"].Value; + ViewData["Tab"] = "Settings"; + ViewData["Title"] = localizer["WorkOrderSettings"].Value; + ViewData["Subtitle"] = localizer["WorkOrderSettingsHelp"].Value; ViewData["CanWrite"] = Model.CanWrite; } @await Html.PartialAsync("_Shell") -
+
-
@localizer["OperationsPolicy"]
+
@localizer["WorkOrderSettings"]
- @await Html.PartialAsync("_Tabs") + @await Html.PartialAsync("_Protection") -
@localizer["PolicyHelp"]
+
@localizer["WorkOrderSettingsHelp"]
-
+ @Html.AntiForgeryToken()
+
+ + +

@localizer["DepartmentCurrencySettingsHelp"]

+
+
@localizer["SpendingApprovals"]
-
- - - - @for (var i = 0; i < Math.Max(4, p.SpendingRules.Count + 1); i++) - { - var r = p.SpendingRules.ElementAtOrDefault(i); - - - - - } - -
@localizer["Currency"]@localizer["SpendingThreshold"]
+
+ + +

@localizer["DepartmentThresholdHelp"]

@@ -56,7 +57,12 @@
- +
@@ -124,5 +130,6 @@ @section Styles { + } @section Scripts { @await Html.PartialAsync("_Scripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_Maintenance.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_Maintenance.cshtml index b8f3a76b5..bba69a625 100644 --- a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_Maintenance.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_Maintenance.cshtml @@ -6,6 +6,7 @@ hold and deferring the due date. Both ask for a reason, so both open in a dialog. *@ @{ + var departmentTime = DepartmentTime.From(ViewData); var d = Model.Detail; var id = d.Order.Id; var isOpen = d.Order.Status is not (WorkOrderStatus.Closed or WorkOrderStatus.Cancelled or WorkOrderStatus.Rejected or WorkOrderStatus.Duplicate); @@ -26,7 +27,7 @@ } @if (d.OriginalDueOn.HasValue) { - @localizer["OriginalDue"]: @d.OriginalDueOn.Value.ToString("u") + @localizer["OriginalDue"]: @departmentTime.Format(d.OriginalDueOn) }

} @@ -189,7 +190,7 @@ else
- +
diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_OrderFields.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_OrderFields.cshtml index 3529b0bd9..dbcc33311 100644 --- a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_OrderFields.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_OrderFields.cshtml @@ -7,6 +7,7 @@ its own steps and the review after them. Field names are unchanged. *@ @{ + var departmentTime = DepartmentTime.From(ViewData); var choices = (WorkOrderChoices)ViewData["Choices"]; var canManage = (bool)ViewData["CanManage"]; var isRecurrence = ViewData["Recurrence"] as bool? == true; @@ -47,19 +48,19 @@ @if (!isRecurrence) {
- + @if (Model.Revision > 0) { @* A saved order changes its due date through a deferral, and the service rejects an edit whose DueOn differs from the stored value by even a tick. datetime-local cannot carry that precision (the browser blanks anything past milliseconds), so the exact value posts from a hidden field and the visible one is display only. *@ - - + + } else { - + } @localizer["HelpFieldDueOn"]
@@ -120,8 +121,9 @@

@localizer["StepOrderCostHint"]

- - + +

@Model.Content.Currency

+

@localizer["DepartmentCurrencyHelp"]

diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_Shell.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_Shell.cshtml index a26a3fc2a..60a078b1d 100644 --- a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_Shell.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_Shell.cshtml @@ -1,7 +1,7 @@ @inject IStringLocalizer localizer @* Page heading shared by every work order screen, laid out the same way as the rest of the - application: title and breadcrumb on the left. + application: title and breadcrumb on the left, page actions on the right and module navigation below. ViewData["Title"] heading and last breadcrumb crumb (defaults to the module name) ViewData["Subtitle"] optional one line description under the breadcrumb @@ -15,8 +15,8 @@ var parentId = (string)ViewData["ParentId"]; var isRoot = string.Equals(title, localizer["WorkOrders"].Value, StringComparison.Ordinal); } -
-
+
+

@title

+ @await Html.PartialAsync("_WorkspaceHeaderActions") + @if (ViewData["Tab"] != null) + { +
+ @await Html.PartialAsync("_Tabs") +
+ }
diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_Tabs.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_Tabs.cshtml index fc95d0e4d..00f51bba6 100644 --- a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_Tabs.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_Tabs.cshtml @@ -18,10 +18,10 @@ if (canWrite) { tabs.Add(("Bulk", "fa-list-ol", localizer["BulkOperations"].Value)); - tabs.Add(("Policy", "fa-sliders", localizer["OperationsPolicy"].Value)); + tabs.Add(("Settings", "fa-cog", localizer["WorkOrderSettings"].Value)); } } -