Optimise floor plan editor and add reciprocal stairs

This commit is contained in:
Nick Beckley 2026-06-21 21:32:05 +01:00
parent f57913014c
commit 89c381e6bc
5 changed files with 326 additions and 4 deletions

View File

@ -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;

View File

@ -220,6 +220,8 @@ public interface IFloorPlanRepository
Task<FloorPlan?> GetAsync(int floorPlanId);
Task<(FloorPlan? FloorPlan, IReadOnlyList<FloorPlanFloor> Floors, IReadOnlyList<FloorPlanBlock> Blocks, IReadOnlyList<FloorPlanTransition> Transitions)> GetEditorAsync(int floorPlanId);
Task<FloorPlanFloor?> GetFloorAsync(int floorPlanFloorId);
Task<IReadOnlyList<FloorPlanBlock>> ListBlocksByFloorAsync(int floorPlanFloorId);
Task<FloorPlanBlock?> GetBlockAsync(int floorPlanBlockId);
Task<int> SavePlanAsync(FloorPlan floorPlan);
Task DeletePlanAsync(int floorPlanId);
Task<int> SaveFloorAsync(FloorPlanFloor floor);
@ -571,6 +573,19 @@ public sealed class FloorPlanRepository(ISqlConnectionFactory connectionFactory)
return await connection.QuerySingleOrDefaultAsync<FloorPlanFloor>("dbo.FloorPlanFloor_Get", new { FloorPlanFloorID = floorPlanFloorId }, commandType: CommandType.StoredProcedure);
}
public async Task<IReadOnlyList<FloorPlanBlock>> ListBlocksByFloorAsync(int floorPlanFloorId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<FloorPlanBlock>("dbo.FloorPlanBlock_ListByFloor", new { FloorPlanFloorID = floorPlanFloorId }, commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<FloorPlanBlock?> GetBlockAsync(int floorPlanBlockId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<FloorPlanBlock>("dbo.FloorPlanBlock_Get", new { FloorPlanBlockID = floorPlanBlockId }, commandType: CommandType.StoredProcedure);
}
public async Task<int> SavePlanAsync(FloorPlan floorPlan)
{
using var connection = connectionFactory.CreateConnection();

View File

@ -186,6 +186,7 @@ public interface IFloorPlanService
Task<int> SaveFloorAsync(FloorPlanFloorEditViewModel model);
Task DeleteFloorAsync(int floorPlanFloorId);
Task<int> SaveBlockAsync(FloorPlanBlockEditViewModel model);
Task<FloorPlanBlockEditViewModel?> GetBlockAsync(int floorPlanBlockId);
Task SaveLayoutAsync(FloorPlanEditorViewModel model);
Task<FloorPlanBackgroundImageUploadResult> 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<FloorPlanBlockEditViewModel?> 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<FloorPlanFloor> floors,
IReadOnlyList<FloorPlanBlock> blocks,
IReadOnlyList<FloorPlanTransition> 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);

View File

@ -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

View File

@ -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 = `<span class="floor-plan-block-label"></span><span class="floor-plan-resize-handle" data-resize-handle aria-hidden="true"></span>`;
button.querySelector(".floor-plan-block-label").textContent = block.displayLabel || block.locationName || "New block";