From 39d4a1f499a5efb3e561d859743a04f832be42dd Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Thu, 21 May 2026 09:13:24 -0700 Subject: [PATCH 01/14] AB#32881 submissions and description --- .../Configuration/FieldPathTypeDto.cs | 7 + .../Configuration/MapMetadataDto.cs | 5 + .../Configuration/ReportColumnsMapDto.cs | 12 + .../Configuration/UpsertColumnMappingDto.cs | 5 + .../ConsolidatedFormVersionFieldsProvider.cs | 253 + .../ConsolidatedWorksheetFieldsProvider.cs | 316 ++ .../Configuration/ReportMappingUtils.cs | 22 +- .../Domain/Configuration/ReportColumnsMap.cs | 13 + .../ReportColumnsMapRepository.cs | 2 + .../ReportingApplicationMapperlyProfile.cs | 8 + .../Configuration/Providers.cs | 14 + .../ReportingConfiguration/Default.cshtml | 88 +- .../ReportingConfiguration/Default.js | 203 +- .../ReportingConfigurationViewComponent.cs | 20 +- ...lidatedWorksheetViewGeneration.Designer.cs | 4947 ++++++++++++++++ ..._AddConsolidatedWorksheetViewGeneration.cs | 36 + ...datedFormVersionViewGeneration.Designer.cs | 4948 +++++++++++++++++ ...ddConsolidatedFormVersionViewGeneration.cs | 36 + ...generate_consolidated_formversion_view.sql | 51 + .../generate_consolidated_worksheet_view.sql | 51 + .../get_consolidated_formversion_data.sql | 336 ++ .../get_consolidated_worksheet_data.sql | 278 + ...ty.GrantManager.EntityFrameworkCore.csproj | 16 + 23 files changed, 11612 insertions(+), 55 deletions(-) create mode 100644 applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/ConsolidatedFormVersionFieldsProvider.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/ConsolidatedWorksheetFieldsProvider.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260519000001_AddConsolidatedWorksheetViewGeneration.Designer.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260519000001_AddConsolidatedWorksheetViewGeneration.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260519000002_AddConsolidatedFormVersionViewGeneration.Designer.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260519000002_AddConsolidatedFormVersionViewGeneration.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Scripts/generate_consolidated_formversion_view.sql create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Scripts/generate_consolidated_worksheet_view.sql create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Scripts/get_consolidated_formversion_data.sql create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Scripts/get_consolidated_worksheet_data.sql diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/FieldPathTypeDto.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/FieldPathTypeDto.cs index 6d74905038..8178d8866a 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/FieldPathTypeDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/FieldPathTypeDto.cs @@ -39,5 +39,12 @@ public class FieldPathTypeDto /// The path to reach the data, this is a datacentric version of the Path, and could be the same /// public string DataPath { get; set; } = string.Empty; + + /// + /// Optional version label used only by the consolidated worksheet provider. + /// Null means the field is merged across all versions; non-null (e.g., "v1") means the field + /// is specific to that form version (conflict or version-exclusive field). + /// + public string? VersionLabel { get; set; } = null; } } diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/MapMetadataDto.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/MapMetadataDto.cs index c6b57d93a7..2b948f6597 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/MapMetadataDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/MapMetadataDto.cs @@ -16,5 +16,10 @@ public class MapMetadataDto /// used for display purposes, change detection analysis, and mapping management operations. /// public Dictionary Info { get; set; } = new Dictionary(); + + /// + /// Gets or sets the optional free-text description for this mapping configuration (max 500 characters). + /// + public string? Description { get; set; } } } diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/ReportColumnsMapDto.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/ReportColumnsMapDto.cs index f7116f3223..6583e3cf69 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/ReportColumnsMapDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/ReportColumnsMapDto.cs @@ -72,6 +72,11 @@ public class MappingDto /// Each row defines how a source field maps to a database column with type, path, and label information. /// public MapRowDto[] Rows { get; set; } = []; + + /// + /// Gets or sets optional metadata for this mapping configuration including description and info context. + /// + public MapMetadataDto? Metadata { get; set; } } /// @@ -128,5 +133,12 @@ public class MapRowDto /// Represents the component type path (e.g., "form->panel->textfield") in the source schema structure. /// public string TypePath { get; set; } = string.Empty; + + /// + /// Gets or sets an optional version label indicating which form version this column belongs to. + /// Used exclusively for consolidated worksheet views: null means the column is merged across all versions; + /// a non-null value (e.g., "v1", "v2") means the column is specific to that form version. + /// + public string? VersionLabel { get; set; } = null; } } diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/UpsertColumnMappingDto.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/UpsertColumnMappingDto.cs index 01042b9ebc..9c3a227ebb 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/UpsertColumnMappingDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/UpsertColumnMappingDto.cs @@ -15,6 +15,11 @@ public class UpsertColumnMappingDto /// of the auto-generated mapping configuration while preserving automatic naming for unmapped fields. /// public UpsertMapRowDto[] Rows { get; set; } = []; + + /// + /// Gets or sets the optional free-text description for this mapping configuration (max 500 characters). + /// + public string? Description { get; set; } } /// diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/ConsolidatedFormVersionFieldsProvider.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/ConsolidatedFormVersionFieldsProvider.cs new file mode 100644 index 0000000000..0f0d1e3bbb --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/ConsolidatedFormVersionFieldsProvider.cs @@ -0,0 +1,253 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Unity.GrantManager.ApplicationForms; +using Unity.GrantManager.Reporting.Configuration; +using Unity.Reporting.Domain.Configuration; +using Volo.Abp.DependencyInjection; + +namespace Unity.Reporting.Configuration.FieldsProviders +{ + /// + /// Fields provider for consolidated form version submission views that span all form versions. + /// Reads live field metadata directly from the form metadata service across all form versions, + /// merges fields by (Label, Path, Type), and detects version changes for break notification. + /// The CorrelationId for this provider is the FormId (not a specific form version ID). + /// + public class ConsolidatedFormVersionFieldsProvider( + IApplicationFormAppService applicationFormAppService, + IFormMetadataService formMetadataService) + : IFieldsProvider, ITransientDependency + { + public string CorrelationProvider => Providers.FormVersionConsolidated; + + /// + /// Retrieves and merges submission field metadata across all form versions for consolidated view configuration. + /// Fields matching on (Label, Path, Type) are merged into a single column entry. + /// Fields with the same (Label, Path) but different Type produce per-version conflict entries. + /// Fields unique to one version are included with a VersionLabel marker. + /// + public async Task GetFieldsMetadataAsync(Guid formId) + { + var versions = await applicationFormAppService.GetVersionsAsync(formId); + var versionsWithFields = new List<(Guid VersionId, string VersionLabel, FieldPathTypeDto[] Fields)>(); + var metadataInfo = new Dictionary(); + + foreach (var version in versions.OrderBy(v => v.Version)) + { + var versionLabel = $"v{version.Version}"; + var fullMetadata = await formMetadataService.GetFormComponentMetaDataAsync(version.Id); + + var fields = fullMetadata.Components + .Select(ConvertToFieldPathType) + .Where(x => x != null) + .Select(x => x!) + .ToArray(); + + if (fields.Length == 0) + continue; + + versionsWithFields.Add((version.Id, versionLabel, fields)); + metadataInfo[$"formversion_{version.Id}"] = versionLabel; + } + + var mergedFields = MergeFields(versionsWithFields); + var mapMetadata = new MapMetadataDto { Info = metadataInfo }; + + return new FieldPathMetaMapDto { Fields = [.. mergedFields], Metadata = mapMetadata }; + } + + /// + /// Detects changes in form versions since the consolidated mapping was last saved. + /// Returns a semicolon-joined change description or null if nothing has changed. + /// Since form version fields are immutable, only added/removed versions are tracked. + /// + public async Task DetectChangesAsync(Guid formId, ReportColumnsMap reportColumnsMap) + { + var versions = await applicationFormAppService.GetVersionsAsync(formId); + var currentInfo = new Dictionary(); + + foreach (var version in versions) + { + var versionLabel = $"v{version.Version}"; + var fullMetadata = await formMetadataService.GetFormComponentMetaDataAsync(version.Id); + + if (fullMetadata.Components.Count == 0) + continue; + + currentInfo[$"formversion_{version.Id}"] = versionLabel; + } + + var storedInfo = GetStoredInfo(reportColumnsMap); + var changes = DetectInfoChanges(storedInfo, currentInfo); + + return changes.Count > 0 ? string.Join("; ", changes) : null; + } + + private static FieldPathTypeDto? ConvertToFieldPathType(FormComponentMetaDataItemDto? item) + { + if (item == null) + return null; + + return new FieldPathTypeDto + { + Id = item.Id, + Path = item.Path, + Type = item.Type, + Key = item.Key, + Label = item.Label, + TypePath = item.TypePath, + DataPath = item.DataPath + }; + } + + private static List MergeFields( + List<(Guid VersionId, string VersionLabel, FieldPathTypeDto[] Fields)> versionsWithFields) + { + var exactMatchGroups = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var pathGroups = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var (_, versionLabel, fields) in versionsWithFields) + { + foreach (var field in fields) + { + var exactKey = $"{field.Label?.ToLowerInvariant()}|{field.Path?.ToLowerInvariant()}|{field.Type?.ToLowerInvariant()}"; + var pathKey = $"{field.Label?.ToLowerInvariant()}|{field.Path?.ToLowerInvariant()}"; + + if (!exactMatchGroups.TryGetValue(exactKey, out var exactList)) + { + exactList = []; + exactMatchGroups[exactKey] = exactList; + } + if (!exactList.Any(e => e.VersionLabel == versionLabel)) + { + exactList.Add((versionLabel, field)); + } + + if (!pathGroups.TryGetValue(pathKey, out var typeSet)) + { + typeSet = new HashSet(StringComparer.OrdinalIgnoreCase); + pathGroups[pathKey] = typeSet; + } + typeSet.Add(field.Type?.ToLowerInvariant() ?? string.Empty); + } + } + + var result = new List(); + var processedExactKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var (_, versionLabel, fields) in versionsWithFields) + { + foreach (var field in fields) + { + var exactKey = $"{field.Label?.ToLowerInvariant()}|{field.Path?.ToLowerInvariant()}|{field.Type?.ToLowerInvariant()}"; + var pathKey = $"{field.Label?.ToLowerInvariant()}|{field.Path?.ToLowerInvariant()}"; + + if (processedExactKeys.Contains(exactKey)) + continue; + + processedExactKeys.Add(exactKey); + + var typesForPath = pathGroups[pathKey]; + var exactGroup = exactMatchGroups[exactKey]; + var versionsHavingThisExact = exactGroup.Select(e => e.VersionLabel).ToHashSet(StringComparer.OrdinalIgnoreCase); + + if (typesForPath.Count > 1) + { + // Conflict: same (label, path) but different types — emit per-version entry + result.Add(new FieldPathTypeDto + { + Id = field.Id, + Key = field.Key, + Label = field.Label, + Path = field.Path, + Type = field.Type, + TypePath = field.TypePath, + DataPath = field.DataPath, + VersionLabel = versionLabel + }); + } + else if (versionsWithFields.Count > 1 && versionsHavingThisExact.Count == versionsWithFields.Count) + { + // Merged: exact match across all versions — no version label + result.Add(new FieldPathTypeDto + { + Id = field.Id, + Key = field.Key, + Label = field.Label, + Path = field.Path, + Type = field.Type, + TypePath = field.TypePath, + DataPath = field.DataPath, + VersionLabel = null + }); + } + else + { + // Version-exclusive field: present in some but not all versions + result.Add(new FieldPathTypeDto + { + Id = field.Id, + Key = field.Key, + Label = field.Label, + Path = field.Path, + Type = field.Type, + TypePath = field.TypePath, + DataPath = field.DataPath, + VersionLabel = versionLabel + }); + } + } + } + + return result; + } + + private static Dictionary GetStoredInfo(ReportColumnsMap reportColumnsMap) + { + if (string.IsNullOrEmpty(reportColumnsMap.Mapping)) + return []; + + try + { + var mapping = JsonSerializer.Deserialize(reportColumnsMap.Mapping); + return mapping?.Metadata?.Info ?? []; + } + catch + { + return []; + } + } + + private static List DetectInfoChanges( + Dictionary storedInfo, + Dictionary currentInfo) + { + var changes = new List(); + + var addedVersionKeys = currentInfo.Keys + .Where(k => k.StartsWith("formversion_", StringComparison.OrdinalIgnoreCase)) + .Except(storedInfo.Keys, StringComparer.OrdinalIgnoreCase); + + foreach (var key in addedVersionKeys) + { + var label = currentInfo[key]; + changes.Add($"Version added: {label}"); + } + + var removedVersionKeys = storedInfo.Keys + .Where(k => k.StartsWith("formversion_", StringComparison.OrdinalIgnoreCase)) + .Except(currentInfo.Keys, StringComparer.OrdinalIgnoreCase); + + foreach (var key in removedVersionKeys) + { + var label = storedInfo[key]; + changes.Add($"Version removed: {label}"); + } + + return changes; + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/ConsolidatedWorksheetFieldsProvider.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/ConsolidatedWorksheetFieldsProvider.cs new file mode 100644 index 0000000000..89d78b9238 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/ConsolidatedWorksheetFieldsProvider.cs @@ -0,0 +1,316 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Unity.Flex.Reporting.Configuration; +using Unity.Flex.WorksheetLinks; +using Unity.GrantManager.ApplicationForms; +using Unity.Reporting.Domain.Configuration; +using Volo.Abp.DependencyInjection; + +namespace Unity.Reporting.Configuration.FieldsProviders +{ + /// + /// Fields provider for consolidated worksheet views that span all form versions. + /// Reads live worksheet field metadata directly from the Flex module across all form versions, + /// merges fields by (Label, Path, Type), and detects version/worksheet changes for break notification. + /// The CorrelationId for this provider is the FormId (not a specific form version ID). + /// + public class ConsolidatedWorksheetFieldsProvider( + IApplicationFormAppService applicationFormAppService, + IWorksheetsMetadataService worksheetsMetadataService, + IWorksheetLinkAppService worksheetLinkAppService) + : IFieldsProvider, ITransientDependency + { + public string CorrelationProvider => Providers.WorksheetConsolidated; + + /// + /// Retrieves and merges worksheet field metadata across all form versions for consolidated view configuration. + /// Fields matching on (Label, Path, Type) are merged into a single column entry. + /// Fields with the same (Label, Path) but different Type produce per-version conflict entries. + /// Fields unique to one version are included with a VersionLabel marker. + /// + public async Task GetFieldsMetadataAsync(Guid formId) + { + var versions = await applicationFormAppService.GetVersionsAsync(formId); + var versionsWithFields = new List<(Guid VersionId, string VersionLabel, FieldPathTypeDto[] Fields)>(); + var metadataInfo = new Dictionary(); + + foreach (var version in versions.OrderBy(v => v.Version)) + { + var versionLabel = $"v{version.Version}"; + var links = await worksheetLinkAppService.GetListByCorrelationAsync(version.Id, "FormVersion"); + + if (links.Count == 0) + continue; + + var allComponents = new List(); + + foreach (var link in links) + { + var metadata = await worksheetsMetadataService.GetWorksheetSchemaMetaDataAsync(link.WorksheetId, version.Id); + var components = metadata.Components + .Select(ConvertToFieldPathType) + .Where(x => x != null) + .Select(x => x!); + allComponents.AddRange(components); + + var worksheetTitle = link.Worksheet?.Title ?? "Unknown Worksheet"; + var worksheetName = link.Worksheet?.Name ?? "Unknown"; + metadataInfo[$"ws_{version.Id}_{link.WorksheetId}"] = $"{worksheetTitle} ({worksheetName})"; + } + + versionsWithFields.Add((version.Id, versionLabel, [.. allComponents])); + metadataInfo[$"formversion_{version.Id}"] = versionLabel; + } + + var mergedFields = MergeFields(versionsWithFields); + var mapMetadata = new MapMetadataDto { Info = metadataInfo }; + + return new FieldPathMetaMapDto { Fields = [.. mergedFields], Metadata = mapMetadata }; + } + + /// + /// Detects changes in form versions and worksheet links since the consolidated mapping was last saved. + /// Returns a semicolon-joined change description or null if nothing has changed. + /// + public async Task DetectChangesAsync(Guid formId, ReportColumnsMap reportColumnsMap) + { + var versions = await applicationFormAppService.GetVersionsAsync(formId); + var currentInfo = new Dictionary(); + + foreach (var version in versions) + { + var versionLabel = $"v{version.Version}"; + var links = await worksheetLinkAppService.GetListByCorrelationAsync(version.Id, "FormVersion"); + + if (links.Count == 0) + continue; + + currentInfo[$"formversion_{version.Id}"] = versionLabel; + + foreach (var link in links) + { + var worksheetTitle = link.Worksheet?.Title ?? "Unknown Worksheet"; + var worksheetName = link.Worksheet?.Name ?? "Unknown"; + currentInfo[$"ws_{version.Id}_{link.WorksheetId}"] = $"{worksheetTitle} ({worksheetName})"; + } + } + + var storedInfo = GetStoredInfo(reportColumnsMap); + var changes = DetectInfoChanges(storedInfo, currentInfo); + + return changes.Count > 0 ? string.Join("; ", changes) : null; + } + + private static FieldPathTypeDto? ConvertToFieldPathType(WorksheetComponentMetaDataItemDto? item) + { + if (item == null) + return null; + + return new FieldPathTypeDto + { + Id = item.Id, + Path = item.Path, + Type = item.Type, + Key = item.Key, + Label = item.Label, + TypePath = item.TypePath, + DataPath = item.DataPath + }; + } + + private static List MergeFields( + List<(Guid VersionId, string VersionLabel, FieldPathTypeDto[] Fields)> versionsWithFields) + { + // Track: (label.lower, path.lower, type.lower) → list of (versionLabel, field) + var exactMatchGroups = new Dictionary>(StringComparer.OrdinalIgnoreCase); + // Track: (label.lower, path.lower) → set of types seen + var pathGroups = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var (_, versionLabel, fields) in versionsWithFields) + { + foreach (var field in fields) + { + var exactKey = $"{field.Label?.ToLowerInvariant()}|{field.Path?.ToLowerInvariant()}|{field.Type?.ToLowerInvariant()}"; + var pathKey = $"{field.Label?.ToLowerInvariant()}|{field.Path?.ToLowerInvariant()}"; + + if (!exactMatchGroups.TryGetValue(exactKey, out var exactList)) + { + exactList = []; + exactMatchGroups[exactKey] = exactList; + } + // Only add first occurrence per version (avoid duplicates within same version) + if (!exactList.Any(e => e.VersionLabel == versionLabel)) + { + exactList.Add((versionLabel, field)); + } + + if (!pathGroups.TryGetValue(pathKey, out var typeSet)) + { + typeSet = new HashSet(StringComparer.OrdinalIgnoreCase); + pathGroups[pathKey] = typeSet; + } + typeSet.Add(field.Type?.ToLowerInvariant() ?? string.Empty); + } + } + + var result = new List(); + var processedExactKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var (_, versionLabel, fields) in versionsWithFields) + { + foreach (var field in fields) + { + var exactKey = $"{field.Label?.ToLowerInvariant()}|{field.Path?.ToLowerInvariant()}|{field.Type?.ToLowerInvariant()}"; + var pathKey = $"{field.Label?.ToLowerInvariant()}|{field.Path?.ToLowerInvariant()}"; + + if (processedExactKeys.Contains(exactKey)) + continue; + + processedExactKeys.Add(exactKey); + + var typesForPath = pathGroups[pathKey]; + var exactGroup = exactMatchGroups[exactKey]; + var allVersionLabels = versionsWithFields.Select(v => v.VersionLabel).ToList(); + var versionsHavingThisExact = exactGroup.Select(e => e.VersionLabel).ToHashSet(StringComparer.OrdinalIgnoreCase); + + if (typesForPath.Count > 1) + { + // Conflict: same (label, path) but different types — emit per-version entry + result.Add(new FieldPathTypeDto + { + Id = field.Id, + Key = field.Key, + Label = field.Label, + Path = field.Path, + Type = field.Type, + TypePath = field.TypePath, + DataPath = field.DataPath, + VersionLabel = versionLabel + }); + } + else if (versionsWithFields.Count > 1 && versionsHavingThisExact.Count == versionsWithFields.Count) + { + // Merged: exact match across all versions — no version label + result.Add(new FieldPathTypeDto + { + Id = field.Id, + Key = field.Key, + Label = field.Label, + Path = field.Path, + Type = field.Type, + TypePath = field.TypePath, + DataPath = field.DataPath, + VersionLabel = null + }); + } + else + { + // Version-exclusive field: present in some but not all versions + result.Add(new FieldPathTypeDto + { + Id = field.Id, + Key = field.Key, + Label = field.Label, + Path = field.Path, + Type = field.Type, + TypePath = field.TypePath, + DataPath = field.DataPath, + VersionLabel = versionLabel + }); + } + } + } + + return result; + } + + private static Dictionary GetStoredInfo(ReportColumnsMap reportColumnsMap) + { + if (string.IsNullOrEmpty(reportColumnsMap.Mapping)) + return []; + + try + { + var mapping = JsonSerializer.Deserialize(reportColumnsMap.Mapping); + return mapping?.Metadata?.Info ?? []; + } + catch + { + return []; + } + } + + private static List DetectInfoChanges( + Dictionary storedInfo, + Dictionary currentInfo) + { + var changes = new List(); + + // Detect added/removed form versions + var addedVersionKeys = currentInfo.Keys + .Where(k => k.StartsWith("formversion_", StringComparison.OrdinalIgnoreCase)) + .Except(storedInfo.Keys, StringComparer.OrdinalIgnoreCase); + + foreach (var key in addedVersionKeys) + { + var label = currentInfo[key]; + changes.Add($"Version added: {label} (has worksheets)"); + } + + var removedVersionKeys = storedInfo.Keys + .Where(k => k.StartsWith("formversion_", StringComparison.OrdinalIgnoreCase)) + .Except(currentInfo.Keys, StringComparer.OrdinalIgnoreCase); + + foreach (var key in removedVersionKeys) + { + var label = storedInfo[key]; + changes.Add($"Version removed: {label}"); + } + + // Detect added/removed worksheets within versions + var addedWsKeys = currentInfo.Keys + .Where(k => k.StartsWith("ws_", StringComparison.OrdinalIgnoreCase)) + .Except(storedInfo.Keys, StringComparer.OrdinalIgnoreCase); + + foreach (var key in addedWsKeys) + { + var worksheetInfo = currentInfo[key]; + var versionLabel = GetVersionLabelFromWsKey(key, currentInfo); + changes.Add($"Worksheet added to {versionLabel}: {worksheetInfo}"); + } + + var removedWsKeys = storedInfo.Keys + .Where(k => k.StartsWith("ws_", StringComparison.OrdinalIgnoreCase)) + .Except(currentInfo.Keys, StringComparer.OrdinalIgnoreCase); + + foreach (var key in removedWsKeys) + { + var worksheetInfo = storedInfo[key]; + var versionLabel = GetVersionLabelFromWsKey(key, storedInfo); + changes.Add($"Worksheet removed from {versionLabel}: {worksheetInfo}"); + } + + return changes; + } + + // ws_{versionId}_{worksheetId} → look up formversion_{versionId} in info + // versionId is a GUID (36 chars) starting at position 3 (after "ws_") + private static string GetVersionLabelFromWsKey(string wsKey, Dictionary info) + { + const int guidLength = 36; + const int prefixLength = 3; // "ws_" + + if (wsKey.Length >= prefixLength + guidLength) + { + var versionIdStr = wsKey.Substring(prefixLength, guidLength); + var versionKey = $"formversion_{versionIdStr}"; + if (info.TryGetValue(versionKey, out var label)) + return label; + } + return "unknown version"; + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/ReportMappingUtils.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/ReportMappingUtils.cs index 6c36736706..956a7aff06 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/ReportMappingUtils.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/ReportMappingUtils.cs @@ -343,7 +343,8 @@ internal static ReportColumnsMap CreateNewMap(UpsertReportColumnsMapDto upsertRe Path = field.Path, DataPath = field.DataPath, TypePath = field.TypePath, - Id = field.Id + Id = field.Id, + VersionLabel = field.VersionLabel }; }).ToList(); @@ -351,7 +352,11 @@ internal static ReportColumnsMap CreateNewMap(UpsertReportColumnsMapDto upsertRe var mapping = new Mapping { Rows = [.. mapRows], - Metadata = new MapMetadata() { Info = fieldsMap.Metadata?.Info } + Metadata = new MapMetadata() + { + Info = fieldsMap.Metadata?.Info, + Description = upsertReportColmnsMapDto.Mapping?.Description + } }; // Create and return the map entity @@ -447,15 +452,20 @@ internal static ReportColumnsMap UpdateExistingMap(UpsertReportColumnsMapDto upd Path = field.Path, DataPath = field.DataPath, TypePath = field.TypePath, - Id = field.Id + Id = field.Id, + VersionLabel = field.VersionLabel }; }).ToList(); // Create new mapping object and serialize it - var updatedMapping = new Mapping - { + var updatedMapping = new Mapping + { Rows = [.. mapRows], - Metadata = new MapMetadata() { Info = fieldsMap.Metadata?.Info } + Metadata = new MapMetadata() + { + Info = fieldsMap.Metadata?.Info, + Description = updateReportColumnsMapDto.Mapping?.Description + } }; existing.Mapping = JsonSerializer.Serialize(updatedMapping); diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Domain/Configuration/ReportColumnsMap.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Domain/Configuration/ReportColumnsMap.cs index df20a20437..2a55f14830 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Domain/Configuration/ReportColumnsMap.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Domain/Configuration/ReportColumnsMap.cs @@ -133,6 +133,13 @@ public class MapRow /// Represents the component type path (e.g., "form->panel->textfield") in the source schema. /// public string TypePath { get; set; } = string.Empty; + + /// + /// Gets or sets an optional version label indicating which form version this column belongs to. + /// Used exclusively for consolidated worksheet views: null means the column is merged across all versions; + /// a non-null value (e.g., "v1", "v2") means the column is specific to that form version (conflict or version-exclusive field). + /// + public string? VersionLabel { get; set; } = null; } /// @@ -148,5 +155,11 @@ public class MapMetadata /// used for display purposes and change detection analysis. /// public Dictionary? Info { get; set; } = null; + + /// + /// Gets or sets an optional free-text description for this mapping configuration. + /// Maximum 500 characters. Used to document the purpose or context of this reporting configuration. + /// + public string? Description { get; set; } = null; } } diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/EntityFrameworkCore/Repositories/ReportColumnsMapRepository.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/EntityFrameworkCore/Repositories/ReportColumnsMapRepository.cs index 2e559ef4cf..782c0c3385 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/EntityFrameworkCore/Repositories/ReportColumnsMapRepository.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/EntityFrameworkCore/Repositories/ReportColumnsMapRepository.cs @@ -91,6 +91,8 @@ public async Task GenerateViewAsync(Guid correlationId, string correlationProvid "formversion" => $@"CALL ""Reporting"".generate_formversion_view({correlationId});", "worksheet" => $@"CALL ""Reporting"".generate_worksheet_view({correlationId});", "scoresheet" => $@"CALL ""Reporting"".generate_scoresheet_view({correlationId});", + "worksheetconsolidated" => $@"CALL ""Reporting"".generate_consolidated_worksheet_view({correlationId});", + "formversionconsolidated" => $@"CALL ""Reporting"".generate_consolidated_formversion_view({correlationId});", _ => throw new ArgumentException($"Unsupported correlation provider: {correlationProvider}"), }; await dbContext.Database.ExecuteSqlAsync(sql); diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/ReportingApplicationMapperlyProfile.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/ReportingApplicationMapperlyProfile.cs index 134acf454e..75ddf45272 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/ReportingApplicationMapperlyProfile.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/ReportingApplicationMapperlyProfile.cs @@ -49,3 +49,11 @@ public partial class MapRowToMapRowDtoMapper : MapperBase public override partial void Map(MapRow source, MapRowDto destination); } + +[Mapper] +public partial class MapMetadataToMapMetadataDtoMapper : MapperBase +{ + public override partial MapMetadataDto Map(MapMetadata source); + + public override partial void Map(MapMetadata source, MapMetadataDto destination); +} diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Domain.Shared/Configuration/Providers.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Domain.Shared/Configuration/Providers.cs index 8fe4e5cf08..9c58c4a50c 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Domain.Shared/Configuration/Providers.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Domain.Shared/Configuration/Providers.cs @@ -28,5 +28,19 @@ public static class Providers /// Scoresheets contain structured evaluation criteria and scoring mechanisms for application review. /// public static string Scoresheet => "scoresheet"; + + /// + /// Gets the correlation provider identifier for consolidated worksheet views spanning all form versions. + /// Used when creating a single unified report view that merges worksheet data across all versions of a form. + /// The CorrelationId for this provider is the FormId (not a specific version ID). + /// + public static string WorksheetConsolidated => "worksheetconsolidated"; + + /// + /// Gets the correlation provider identifier for consolidated form version submission views spanning all form versions. + /// Used when creating a single unified report view that merges submission data across all versions of a form. + /// The CorrelationId for this provider is the FormId (not a specific version ID). + /// + public static string FormVersionConsolidated => "formversionconsolidated"; } } diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.cshtml index c8ded88444..9ccec09378 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.cshtml @@ -19,12 +19,12 @@
- + @if (await FeatureChecker.IsEnabledAsync("Unity.Flex")) { - + @@ -43,34 +43,44 @@
- @if (Model.IsVersionSelectorVisible) - { -
- +
+
+
+ + + + +
- } - else - { - -
+ + @if (await FeatureChecker.IsEnabledAsync("Unity.Flex")) + { +
+
+ + + + +
+
+ } + +
- -
+ + - } +
@if (Model.CorrelationId.HasValue) @@ -90,6 +100,15 @@ data-bs-placement="top" data-bs-original-title="Generate view" class="btn unt-btn-primary btn-primary @(Model.HasSavedConfiguration ? "" : "generate-view-btn-hidden")"> + + }
+ + +