diff --git a/PlotLine/Controllers/FloorPlansController.cs b/PlotLine/Controllers/FloorPlansController.cs index e6ebad6..1b0183f 100644 --- a/PlotLine/Controllers/FloorPlansController.cs +++ b/PlotLine/Controllers/FloorPlansController.cs @@ -227,8 +227,7 @@ public sealed class FloorPlansController(IFloorPlanService floorPlans) : Control var floorPlanBlockId = await floorPlans.SaveBlockAsync(model); if (isAsyncSave) { - var editor = await floorPlans.GetEditorAsync(floorPlanId); - var block = editor?.Blocks.FirstOrDefault(x => x.FloorPlanBlockID == floorPlanBlockId); + var block = await floorPlans.GetBlockAsync(floorPlanBlockId); if (block is null) { Response.StatusCode = StatusCodes.Status400BadRequest; diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index 36cd1ca..d18ae8a 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -220,6 +220,8 @@ public interface IFloorPlanRepository Task GetAsync(int floorPlanId); Task<(FloorPlan? FloorPlan, IReadOnlyList Floors, IReadOnlyList Blocks, IReadOnlyList Transitions)> GetEditorAsync(int floorPlanId); Task GetFloorAsync(int floorPlanFloorId); + Task> ListBlocksByFloorAsync(int floorPlanFloorId); + Task GetBlockAsync(int floorPlanBlockId); Task SavePlanAsync(FloorPlan floorPlan); Task DeletePlanAsync(int floorPlanId); Task SaveFloorAsync(FloorPlanFloor floor); @@ -571,6 +573,19 @@ public sealed class FloorPlanRepository(ISqlConnectionFactory connectionFactory) return await connection.QuerySingleOrDefaultAsync("dbo.FloorPlanFloor_Get", new { FloorPlanFloorID = floorPlanFloorId }, commandType: CommandType.StoredProcedure); } + public async Task> ListBlocksByFloorAsync(int floorPlanFloorId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync("dbo.FloorPlanBlock_ListByFloor", new { FloorPlanFloorID = floorPlanFloorId }, commandType: CommandType.StoredProcedure); + return rows.ToList(); + } + + public async Task GetBlockAsync(int floorPlanBlockId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync("dbo.FloorPlanBlock_Get", new { FloorPlanBlockID = floorPlanBlockId }, commandType: CommandType.StoredProcedure); + } + public async Task SavePlanAsync(FloorPlan floorPlan) { using var connection = connectionFactory.CreateConnection(); diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index ed68c2c..8d7b7f1 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -186,6 +186,7 @@ public interface IFloorPlanService Task SaveFloorAsync(FloorPlanFloorEditViewModel model); Task DeleteFloorAsync(int floorPlanFloorId); Task SaveBlockAsync(FloorPlanBlockEditViewModel model); + Task GetBlockAsync(int floorPlanBlockId); Task SaveLayoutAsync(FloorPlanEditorViewModel model); Task UploadBackgroundAsync(int floorPlanId, int floorPlanFloorId, IFormFile upload); Task SaveBackgroundSettingsAsync(FloorPlanFloorBackgroundSettingsViewModel model); @@ -7677,8 +7678,7 @@ public sealed class FloorPlanService( if (model.FloorPlanBlockID == 0) { model.LocationID = await ResolveBlockLocationAsync(model, floor, plan); - var editor = await floorPlans.GetEditorAsync(plan.FloorPlanID); - var existingBlocks = editor.Blocks.Where(x => x.FloorPlanFloorID == floor.FloorPlanFloorID).ToList(); + var existingBlocks = await floorPlans.ListBlocksByFloorAsync(floor.FloorPlanFloorID); (model.X, model.Y) = FindFirstAvailablePosition(floor, existingBlocks, model.WidthCells, model.HeightCells); } @@ -7686,6 +7686,12 @@ public sealed class FloorPlanService( return await floorPlans.SaveBlockAsync(ToBlock(model)); } + public async Task GetBlockAsync(int floorPlanBlockId) + { + var block = await floorPlans.GetBlockAsync(floorPlanBlockId); + return block is null ? null : ToBlockEdit(block); + } + public async Task SaveLayoutAsync(FloorPlanEditorViewModel model) { var data = await floorPlans.GetEditorAsync(model.FloorPlan.FloorPlanID); @@ -7738,11 +7744,80 @@ public sealed class FloorPlanService( { model.PositionWithinLocation = FloorPlanTransitionPositions.Centre; } + else if (model.FloorPlanTransitionID == 0) + { + var existingPrimary = data.Transitions.FirstOrDefault(x => + x.FloorPlanFloorID == model.FloorPlanFloorID + && x.FromLocationID == model.FromLocationID!.Value + && x.ToLocationID == model.ToLocationID!.Value + && string.Equals(x.TransitionType, model.TransitionType, StringComparison.OrdinalIgnoreCase)); + if (existingPrimary is not null) + { + model.FloorPlanTransitionID = existingPrimary.FloorPlanTransitionID; + } + } var transitionId = await floorPlans.SaveTransitionAsync(ToTransition(model)); + if (IsStairTransition(model.TransitionType)) + { + await SaveReciprocalStairTransitionAsync(model, floor, data.Floors, data.Blocks, data.Transitions); + } await activity.RecordAsync(data.FloorPlan.ProjectID, model.FloorPlanTransitionID == 0 ? "Created" : "Updated", "Floor Plan Transition", transitionId, model.TransitionType); return transitionId; } + private async Task SaveReciprocalStairTransitionAsync( + FloorPlanTransitionEditViewModel model, + FloorPlanFloor floor, + IReadOnlyList floors, + IReadOnlyList blocks, + IReadOnlyList transitions) + { + var transitionKind = NormalizeTransitionType(model.TransitionType); + var targetLevel = floor.SortOrder + (transitionKind == "stairsup" ? 1 : -1); + var targetFloor = floors + .Where(x => x.SortOrder == targetLevel) + .OrderBy(x => x.Name) + .FirstOrDefault(); + + if (targetFloor is null) + { + return; + } + + var targetLocationIds = blocks + .Where(x => x.FloorPlanFloorID == targetFloor.FloorPlanFloorID) + .Select(x => x.LocationID) + .ToHashSet(); + if (!targetLocationIds.Contains(model.ToLocationID!.Value)) + { + return; + } + + var reciprocalType = transitionKind == "stairsup" + ? FloorPlanTransitionTypes.StairsDown + : FloorPlanTransitionTypes.StairsUp; + var existing = transitions.FirstOrDefault(x => + x.FloorPlanFloorID == targetFloor.FloorPlanFloorID + && x.FromLocationID == model.ToLocationID.Value + && x.ToLocationID == model.FromLocationID!.Value + && string.Equals(x.TransitionType, reciprocalType, StringComparison.OrdinalIgnoreCase)); + + await floorPlans.SaveTransitionAsync(new FloorPlanTransition + { + FloorPlanTransitionID = existing?.FloorPlanTransitionID ?? 0, + FloorPlanFloorID = targetFloor.FloorPlanFloorID, + FromLocationID = model.ToLocationID.Value, + ToLocationID = model.FromLocationID!.Value, + TransitionType = reciprocalType, + Notes = model.Notes, + IsLocked = model.IsLocked, + DisplayLabel = model.DisplayLabel, + PositionWithinLocation = string.IsNullOrWhiteSpace(model.PositionWithinLocation) + ? FloorPlanTransitionPositions.Centre + : model.PositionWithinLocation + }); + } + public async Task DeleteTransitionAsync(int floorPlanId, int floorPlanTransitionId) { var data = await floorPlans.GetEditorAsync(floorPlanId); diff --git a/PlotLine/Sql/078_FloorPlanEditorPerformanceAndReciprocalStairs.sql b/PlotLine/Sql/078_FloorPlanEditorPerformanceAndReciprocalStairs.sql new file mode 100644 index 0000000..ade6de1 --- /dev/null +++ b/PlotLine/Sql/078_FloorPlanEditorPerformanceAndReciprocalStairs.sql @@ -0,0 +1,231 @@ +CREATE OR ALTER PROCEDURE dbo.FloorPlan_GetEditor + @FloorPlanID int +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @ProjectID int; + SELECT @ProjectID = ProjectID + FROM dbo.FloorPlans + WHERE FloorPlanID = @FloorPlanID; + + EXEC dbo.FloorPlan_Get @FloorPlanID; + + CREATE TABLE #ProjectLocationPaths + ( + LocationID int NOT NULL PRIMARY KEY, + LocationName nvarchar(200) NOT NULL, + LocationPath nvarchar(max) NOT NULL + ); + + ;WITH ProjectLocationTree AS + ( + SELECT + LocationID, + LocationName, + CAST(LocationName AS nvarchar(max)) AS LocationPath + FROM dbo.Locations + WHERE ProjectID = @ProjectID + AND ParentLocationID IS NULL + AND IsArchived = 0 + + UNION ALL + + SELECT + child.LocationID, + child.LocationName, + CAST(parent.LocationPath + N' > ' + child.LocationName AS nvarchar(max)) AS LocationPath + FROM dbo.Locations child + INNER JOIN ProjectLocationTree parent ON parent.LocationID = child.ParentLocationID + WHERE child.ProjectID = @ProjectID + AND child.IsArchived = 0 + ) + INSERT #ProjectLocationPaths (LocationID, LocationName, LocationPath) + SELECT LocationID, LocationName, LocationPath + FROM ProjectLocationTree; + + SELECT + fpf.FloorPlanFloorID, + fpf.FloorPlanID, + fpf.LocationID, + lp.LocationName, + lp.LocationPath, + fpf.Name, + fpf.SortOrder, + fpf.GridWidth, + fpf.GridHeight, + fpf.BackgroundImagePath, + fpf.BackgroundImageOriginalFileName, + COALESCE(fpf.BackgroundOpacity, CONVERT(decimal(5,2), 0.35)) AS BackgroundOpacity, + COALESCE(fpf.BackgroundScale, CONVERT(decimal(8,3), 1.000)) AS BackgroundScale, + COALESCE(fpf.BackgroundOffsetX, CONVERT(decimal(10,2), 0.00)) AS BackgroundOffsetX, + COALESCE(fpf.BackgroundOffsetY, CONVERT(decimal(10,2), 0.00)) AS BackgroundOffsetY, + fpf.BackgroundLocked, + fpf.CreatedDate, + fpf.ModifiedDate + FROM dbo.FloorPlanFloors fpf + LEFT JOIN #ProjectLocationPaths lp ON lp.LocationID = fpf.LocationID + WHERE fpf.FloorPlanID = @FloorPlanID + ORDER BY fpf.SortOrder, fpf.Name; + + SELECT + fpb.FloorPlanBlockID, + fpb.FloorPlanFloorID, + fpb.LocationID, + lp.LocationName, + COALESCE(NULLIF(lp.LocationPath, N''), lp.LocationName) AS LocationPath, + fpb.X, + fpb.Y, + fpb.WidthCells, + fpb.HeightCells, + fpb.BlockType, + fpb.LabelOverride, + fpb.Notes, + fpb.CreatedDate, + fpb.ModifiedDate + FROM dbo.FloorPlanBlocks fpb + INNER JOIN dbo.FloorPlanFloors fpf ON fpf.FloorPlanFloorID = fpb.FloorPlanFloorID + INNER JOIN #ProjectLocationPaths lp ON lp.LocationID = fpb.LocationID + WHERE fpf.FloorPlanID = @FloorPlanID + ORDER BY fpf.SortOrder, fpb.Y, fpb.X, lp.LocationName; + + SELECT + fpt.FloorPlanTransitionID, + fpt.FloorPlanFloorID, + fpt.FromLocationID, + fromPath.LocationName AS FromLocationName, + COALESCE(NULLIF(fromPath.LocationPath, N''), fromPath.LocationName) AS FromLocationPath, + fpt.ToLocationID, + toPath.LocationName AS ToLocationName, + COALESCE(NULLIF(toPath.LocationPath, N''), toPath.LocationName) AS ToLocationPath, + fpt.TransitionType, + fpt.Notes, + fpt.IsLocked, + fpt.DisplayLabel, + fpt.PositionWithinLocation, + fpt.CreatedDate, + fpt.ModifiedDate + FROM dbo.FloorPlanTransitions fpt + INNER JOIN dbo.FloorPlanFloors fpf ON fpf.FloorPlanFloorID = fpt.FloorPlanFloorID + INNER JOIN #ProjectLocationPaths fromPath ON fromPath.LocationID = fpt.FromLocationID + INNER JOIN #ProjectLocationPaths toPath ON toPath.LocationID = fpt.ToLocationID + WHERE fpf.FloorPlanID = @FloorPlanID + ORDER BY fpf.SortOrder, fromPath.LocationName, toPath.LocationName, fpt.TransitionType; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.FloorPlanBlock_ListByFloor + @FloorPlanFloorID int +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @ProjectID int; + SELECT @ProjectID = fp.ProjectID + FROM dbo.FloorPlanFloors fpf + INNER JOIN dbo.FloorPlans fp ON fp.FloorPlanID = fpf.FloorPlanID + WHERE fpf.FloorPlanFloorID = @FloorPlanFloorID; + + CREATE TABLE #ProjectLocationPaths + ( + LocationID int NOT NULL PRIMARY KEY, + LocationName nvarchar(200) NOT NULL, + LocationPath nvarchar(max) NOT NULL + ); + + ;WITH ProjectLocationTree AS + ( + SELECT LocationID, LocationName, CAST(LocationName AS nvarchar(max)) AS LocationPath + FROM dbo.Locations + WHERE ProjectID = @ProjectID AND ParentLocationID IS NULL AND IsArchived = 0 + + UNION ALL + + SELECT child.LocationID, child.LocationName, CAST(parent.LocationPath + N' > ' + child.LocationName AS nvarchar(max)) AS LocationPath + FROM dbo.Locations child + INNER JOIN ProjectLocationTree parent ON parent.LocationID = child.ParentLocationID + WHERE child.ProjectID = @ProjectID AND child.IsArchived = 0 + ) + INSERT #ProjectLocationPaths (LocationID, LocationName, LocationPath) + SELECT LocationID, LocationName, LocationPath + FROM ProjectLocationTree; + + SELECT + fpb.FloorPlanBlockID, + fpb.FloorPlanFloorID, + fpb.LocationID, + lp.LocationName, + COALESCE(NULLIF(lp.LocationPath, N''), lp.LocationName) AS LocationPath, + fpb.X, + fpb.Y, + fpb.WidthCells, + fpb.HeightCells, + fpb.BlockType, + fpb.LabelOverride, + fpb.Notes, + fpb.CreatedDate, + fpb.ModifiedDate + FROM dbo.FloorPlanBlocks fpb + INNER JOIN #ProjectLocationPaths lp ON lp.LocationID = fpb.LocationID + WHERE fpb.FloorPlanFloorID = @FloorPlanFloorID + ORDER BY fpb.Y, fpb.X, lp.LocationName; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.FloorPlanBlock_Get + @FloorPlanBlockID int +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @ProjectID int; + SELECT @ProjectID = fp.ProjectID + FROM dbo.FloorPlanBlocks fpb + INNER JOIN dbo.FloorPlanFloors fpf ON fpf.FloorPlanFloorID = fpb.FloorPlanFloorID + INNER JOIN dbo.FloorPlans fp ON fp.FloorPlanID = fpf.FloorPlanID + WHERE fpb.FloorPlanBlockID = @FloorPlanBlockID; + + CREATE TABLE #ProjectLocationPaths + ( + LocationID int NOT NULL PRIMARY KEY, + LocationName nvarchar(200) NOT NULL, + LocationPath nvarchar(max) NOT NULL + ); + + ;WITH ProjectLocationTree AS + ( + SELECT LocationID, LocationName, CAST(LocationName AS nvarchar(max)) AS LocationPath + FROM dbo.Locations + WHERE ProjectID = @ProjectID AND ParentLocationID IS NULL AND IsArchived = 0 + + UNION ALL + + SELECT child.LocationID, child.LocationName, CAST(parent.LocationPath + N' > ' + child.LocationName AS nvarchar(max)) AS LocationPath + FROM dbo.Locations child + INNER JOIN ProjectLocationTree parent ON parent.LocationID = child.ParentLocationID + WHERE child.ProjectID = @ProjectID AND child.IsArchived = 0 + ) + INSERT #ProjectLocationPaths (LocationID, LocationName, LocationPath) + SELECT LocationID, LocationName, LocationPath + FROM ProjectLocationTree; + + SELECT + fpb.FloorPlanBlockID, + fpb.FloorPlanFloorID, + fpb.LocationID, + lp.LocationName, + COALESCE(NULLIF(lp.LocationPath, N''), lp.LocationName) AS LocationPath, + fpb.X, + fpb.Y, + fpb.WidthCells, + fpb.HeightCells, + fpb.BlockType, + fpb.LabelOverride, + fpb.Notes, + fpb.CreatedDate, + fpb.ModifiedDate + FROM dbo.FloorPlanBlocks fpb + INNER JOIN #ProjectLocationPaths lp ON lp.LocationID = fpb.LocationID + WHERE fpb.FloorPlanBlockID = @FloorPlanBlockID; +END; +GO diff --git a/PlotLine/Views/FloorPlans/Edit.cshtml b/PlotLine/Views/FloorPlans/Edit.cshtml index 7507841..a7f9d5c 100644 --- a/PlotLine/Views/FloorPlans/Edit.cshtml +++ b/PlotLine/Views/FloorPlans/Edit.cshtml @@ -1393,6 +1393,8 @@ button.dataset.blockIndex = blocks.length; button.dataset.floorId = block.floorPlanFloorID; button.dataset.locationId = block.locationID || ""; + button.dataset.locationName = block.locationName || ""; + button.dataset.locationPath = block.locationPath || ""; button.title = block.locationPath || ""; button.innerHTML = ``; button.querySelector(".floor-plan-block-label").textContent = block.displayLabel || block.locationName || "New block";