Implemented the Floor Plans UX/data-model refinement pass.
This commit is contained in:
parent
bd8067b472
commit
030a4a10da
@ -572,7 +572,7 @@ public sealed class FloorPlanRepository(ISqlConnectionFactory connectionFactory)
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleAsync<int>(
|
||||
"dbo.FloorPlan_Save",
|
||||
new { floorPlan.FloorPlanID, floorPlan.ProjectID, floorPlan.Name, floorPlan.Description },
|
||||
new { floorPlan.FloorPlanID, floorPlan.ProjectID, floorPlan.LocationID, floorPlan.Name, floorPlan.Description },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
@ -587,7 +587,7 @@ public sealed class FloorPlanRepository(ISqlConnectionFactory connectionFactory)
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleAsync<int>(
|
||||
"dbo.FloorPlanFloor_Save",
|
||||
new { floor.FloorPlanFloorID, floor.FloorPlanID, floor.Name, floor.SortOrder, floor.GridWidth, floor.GridHeight },
|
||||
new { floor.FloorPlanFloorID, floor.FloorPlanID, floor.LocationID, floor.Name, floor.SortOrder, floor.GridWidth, floor.GridHeight },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
|
||||
@ -1142,6 +1142,9 @@ public sealed class FloorPlan
|
||||
{
|
||||
public int FloorPlanID { get; set; }
|
||||
public int ProjectID { get; set; }
|
||||
public int? LocationID { get; set; }
|
||||
public string? LocationName { get; set; }
|
||||
public string? LocationPath { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public DateTime CreatedDate { get; set; }
|
||||
@ -1154,6 +1157,9 @@ public sealed class FloorPlanFloor
|
||||
{
|
||||
public int FloorPlanFloorID { get; set; }
|
||||
public int FloorPlanID { get; set; }
|
||||
public int? LocationID { get; set; }
|
||||
public string? LocationName { get; set; }
|
||||
public string? LocationPath { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public int SortOrder { get; set; }
|
||||
public int GridWidth { get; set; } = 24;
|
||||
|
||||
@ -7531,11 +7531,13 @@ public sealed class FloorPlanService(
|
||||
public async Task<FloorPlanListViewModel?> ListAsync(int projectId)
|
||||
{
|
||||
var project = await projects.GetAsync(projectId);
|
||||
var projectLocations = project is null ? [] : await locations.ListByProjectAsync(projectId);
|
||||
return project is null ? null : new FloorPlanListViewModel
|
||||
{
|
||||
Project = project,
|
||||
FloorPlans = await floorPlans.ListByProjectAsync(projectId),
|
||||
NewFloorPlan = new FloorPlanEditViewModel { ProjectID = projectId, Name = "New floor plan" }
|
||||
NewFloorPlan = new FloorPlanEditViewModel { ProjectID = projectId, Name = "New floor plan" },
|
||||
LocationOptions = ToOptionalLocationOptions(projectLocations, "Create a matching Location")
|
||||
};
|
||||
}
|
||||
|
||||
@ -7561,6 +7563,9 @@ public sealed class FloorPlanService(
|
||||
{
|
||||
FloorPlanID = data.FloorPlan.FloorPlanID,
|
||||
ProjectID = data.FloorPlan.ProjectID,
|
||||
LocationID = data.FloorPlan.LocationID,
|
||||
LocationName = data.FloorPlan.LocationName,
|
||||
LocationPath = data.FloorPlan.LocationPath,
|
||||
Name = data.FloorPlan.Name,
|
||||
Description = data.FloorPlan.Description
|
||||
},
|
||||
@ -7581,9 +7586,8 @@ public sealed class FloorPlanService(
|
||||
WidthCells = 2,
|
||||
HeightCells = 1
|
||||
},
|
||||
LocationOptions = projectLocations
|
||||
.Select(x => new SelectListItem(string.IsNullOrWhiteSpace(x.LocationPath) ? x.LocationName : x.LocationPath, x.LocationID.ToString()))
|
||||
.ToList(),
|
||||
LocationOptions = ToOptionalLocationOptions(projectLocations, "Create/link automatically"),
|
||||
Locations = projectLocations,
|
||||
BlockTypeOptions = FloorPlanBlockTypes.All.Select(x => new SelectListItem(BlockTypeLabel(x), x)).ToList()
|
||||
};
|
||||
}
|
||||
@ -7591,19 +7595,23 @@ public sealed class FloorPlanService(
|
||||
public async Task<int> SavePlanAsync(FloorPlanEditViewModel model)
|
||||
{
|
||||
var isNew = model.FloorPlanID == 0;
|
||||
var locationId = await ResolveFloorPlanLocationAsync(model);
|
||||
var floorPlanId = await floorPlans.SavePlanAsync(new FloorPlan
|
||||
{
|
||||
FloorPlanID = model.FloorPlanID,
|
||||
ProjectID = model.ProjectID,
|
||||
LocationID = locationId,
|
||||
Name = model.Name.Trim(),
|
||||
Description = model.Description
|
||||
});
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
var floorLocationId = await ResolveFloorLocationAsync(floorPlanId, "Ground Floor", null);
|
||||
await floorPlans.SaveFloorAsync(new FloorPlanFloor
|
||||
{
|
||||
FloorPlanID = floorPlanId,
|
||||
LocationID = floorLocationId,
|
||||
Name = "Ground Floor",
|
||||
SortOrder = 10,
|
||||
GridWidth = 24,
|
||||
@ -7628,10 +7636,12 @@ public sealed class FloorPlanService(
|
||||
public async Task<int> SaveFloorAsync(FloorPlanFloorEditViewModel model)
|
||||
{
|
||||
ValidateFloor(model);
|
||||
var locationId = await ResolveFloorLocationAsync(model.FloorPlanID, model.Name, model.LocationID);
|
||||
return await floorPlans.SaveFloorAsync(new FloorPlanFloor
|
||||
{
|
||||
FloorPlanFloorID = model.FloorPlanFloorID,
|
||||
FloorPlanID = model.FloorPlanID,
|
||||
LocationID = locationId,
|
||||
Name = model.Name.Trim(),
|
||||
SortOrder = model.SortOrder,
|
||||
GridWidth = model.GridWidth,
|
||||
@ -7650,6 +7660,7 @@ public sealed class FloorPlanService(
|
||||
var projectId = plan.ProjectID;
|
||||
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();
|
||||
(model.X, model.Y) = FindFirstAvailablePosition(floor, existingBlocks, model.WidthCells, model.HeightCells);
|
||||
@ -7712,13 +7723,111 @@ public sealed class FloorPlanService(
|
||||
throw new InvalidOperationException("Block is outside the floor grid.");
|
||||
}
|
||||
|
||||
var location = await locations.GetAsync(model.LocationID);
|
||||
if (!model.LocationID.HasValue || model.LocationID.Value <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Choose or create a location for this block.");
|
||||
}
|
||||
|
||||
var location = await locations.GetAsync(model.LocationID.Value);
|
||||
if (location is null || location.ProjectID != projectId)
|
||||
{
|
||||
throw new InvalidOperationException("Choose a location from this project.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int?> ResolveFloorPlanLocationAsync(FloorPlanEditViewModel model)
|
||||
{
|
||||
if (model.LocationID.HasValue && model.LocationID.Value > 0)
|
||||
{
|
||||
var existing = await locations.GetAsync(model.LocationID.Value);
|
||||
if (existing is null || existing.ProjectID != model.ProjectID)
|
||||
{
|
||||
throw new InvalidOperationException("Floor plan location must belong to this project.");
|
||||
}
|
||||
|
||||
return model.LocationID.Value;
|
||||
}
|
||||
|
||||
return await FindOrCreateLocationAsync(model.ProjectID, null, model.Name.Trim());
|
||||
}
|
||||
|
||||
private async Task<int?> ResolveFloorLocationAsync(int floorPlanId, string floorName, int? locationId)
|
||||
{
|
||||
var plan = await floorPlans.GetAsync(floorPlanId)
|
||||
?? throw new InvalidOperationException("Floor plan not found.");
|
||||
|
||||
if (locationId.HasValue && locationId.Value > 0)
|
||||
{
|
||||
var existing = await locations.GetAsync(locationId.Value);
|
||||
if (existing is null || existing.ProjectID != plan.ProjectID)
|
||||
{
|
||||
throw new InvalidOperationException("Floor location must belong to this project.");
|
||||
}
|
||||
|
||||
return locationId.Value;
|
||||
}
|
||||
|
||||
var planLocationId = plan.LocationID ?? await FindOrCreateLocationAsync(plan.ProjectID, null, plan.Name);
|
||||
if (plan.LocationID != planLocationId)
|
||||
{
|
||||
await floorPlans.SavePlanAsync(new FloorPlan
|
||||
{
|
||||
FloorPlanID = plan.FloorPlanID,
|
||||
ProjectID = plan.ProjectID,
|
||||
LocationID = planLocationId,
|
||||
Name = plan.Name,
|
||||
Description = plan.Description
|
||||
});
|
||||
}
|
||||
|
||||
return await FindOrCreateLocationAsync(plan.ProjectID, planLocationId, floorName.Trim());
|
||||
}
|
||||
|
||||
private async Task<int> ResolveBlockLocationAsync(FloorPlanBlockEditViewModel model, FloorPlanFloor floor, FloorPlan plan)
|
||||
{
|
||||
if (string.Equals(model.AddBlockLocationMode, "Create", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var locationName = string.IsNullOrWhiteSpace(model.NewLocationName) ? model.LabelOverride : model.NewLocationName;
|
||||
locationName = string.IsNullOrWhiteSpace(locationName) ? model.LabelOverride : locationName;
|
||||
if (string.IsNullOrWhiteSpace(locationName))
|
||||
{
|
||||
throw new InvalidOperationException("Enter a room/location name for the new block.");
|
||||
}
|
||||
|
||||
var floorLocationId = floor.LocationID ?? await ResolveFloorLocationAsync(floor.FloorPlanID, floor.Name, null);
|
||||
return await FindOrCreateLocationAsync(plan.ProjectID, floorLocationId, locationName.Trim());
|
||||
}
|
||||
|
||||
if (!model.LocationID.HasValue || model.LocationID.Value <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Choose a location for this block.");
|
||||
}
|
||||
|
||||
return model.LocationID.Value;
|
||||
}
|
||||
|
||||
private async Task<int> FindOrCreateLocationAsync(int projectId, int? parentLocationId, string locationName)
|
||||
{
|
||||
var name = locationName.Trim();
|
||||
var existing = (await locations.ListByProjectAsync(projectId))
|
||||
.FirstOrDefault(x => x.ParentLocationID == parentLocationId
|
||||
&& string.Equals(x.LocationName.Trim(), name, StringComparison.OrdinalIgnoreCase)
|
||||
&& !x.IsArchived);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
return existing.LocationID;
|
||||
}
|
||||
|
||||
return await locations.SaveAsync(new LocationItem
|
||||
{
|
||||
ProjectID = projectId,
|
||||
ParentLocationID = parentLocationId,
|
||||
LocationName = name,
|
||||
ShowInQuickAddBar = false
|
||||
});
|
||||
}
|
||||
|
||||
private static void ValidateFloor(FloorPlanFloorEditViewModel model)
|
||||
{
|
||||
if (model.GridWidth is < 8 or > 80 || model.GridHeight is < 8 or > 80)
|
||||
@ -7762,6 +7871,9 @@ public sealed class FloorPlanService(
|
||||
{
|
||||
FloorPlanFloorID = floor.FloorPlanFloorID,
|
||||
FloorPlanID = floor.FloorPlanID,
|
||||
LocationID = floor.LocationID,
|
||||
LocationName = floor.LocationName,
|
||||
LocationPath = floor.LocationPath,
|
||||
Name = floor.Name,
|
||||
SortOrder = floor.SortOrder,
|
||||
GridWidth = floor.GridWidth,
|
||||
@ -7788,7 +7900,7 @@ public sealed class FloorPlanService(
|
||||
{
|
||||
FloorPlanBlockID = model.FloorPlanBlockID,
|
||||
FloorPlanFloorID = model.FloorPlanFloorID,
|
||||
LocationID = model.LocationID,
|
||||
LocationID = model.LocationID!.Value,
|
||||
X = model.X,
|
||||
Y = model.Y,
|
||||
WidthCells = model.WidthCells,
|
||||
@ -7803,6 +7915,13 @@ public sealed class FloorPlanService(
|
||||
FloorPlanBlockTypes.OpenSpace => "Open space",
|
||||
_ => blockType
|
||||
};
|
||||
|
||||
private static IReadOnlyList<SelectListItem> ToOptionalLocationOptions(IEnumerable<LocationItem> projectLocations, string emptyLabel)
|
||||
{
|
||||
var items = new List<SelectListItem> { new(emptyLabel, string.Empty) };
|
||||
items.AddRange(projectLocations.Select(x => new SelectListItem(string.IsNullOrWhiteSpace(x.LocationPath) ? x.LocationName : x.LocationPath, x.LocationID.ToString())));
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class CharacterService(
|
||||
|
||||
290
PlotLine/Sql/072_FloorPlanLocationLinks.sql
Normal file
290
PlotLine/Sql/072_FloorPlanLocationLinks.sql
Normal file
@ -0,0 +1,290 @@
|
||||
SET ANSI_NULLS ON;
|
||||
GO
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
GO
|
||||
|
||||
IF COL_LENGTH(N'dbo.FloorPlans', N'LocationID') IS NULL
|
||||
BEGIN
|
||||
ALTER TABLE dbo.FloorPlans ADD LocationID int NULL;
|
||||
END;
|
||||
GO
|
||||
|
||||
IF COL_LENGTH(N'dbo.FloorPlanFloors', N'LocationID') IS NULL
|
||||
BEGIN
|
||||
ALTER TABLE dbo.FloorPlanFloors ADD LocationID int NULL;
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_FloorPlans_Locations')
|
||||
BEGIN
|
||||
ALTER TABLE dbo.FloorPlans
|
||||
ADD CONSTRAINT FK_FloorPlans_Locations FOREIGN KEY (LocationID) REFERENCES dbo.Locations(LocationID);
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_FloorPlanFloors_Locations')
|
||||
BEGIN
|
||||
ALTER TABLE dbo.FloorPlanFloors
|
||||
ADD CONSTRAINT FK_FloorPlanFloors_Locations FOREIGN KEY (LocationID) REFERENCES dbo.Locations(LocationID);
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_FloorPlans_LocationID' AND object_id = OBJECT_ID(N'dbo.FloorPlans'))
|
||||
BEGIN
|
||||
CREATE INDEX IX_FloorPlans_LocationID ON dbo.FloorPlans(LocationID) WHERE LocationID IS NOT NULL;
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_FloorPlanFloors_LocationID' AND object_id = OBJECT_ID(N'dbo.FloorPlanFloors'))
|
||||
BEGIN
|
||||
CREATE INDEX IX_FloorPlanFloors_LocationID ON dbo.FloorPlanFloors(LocationID) WHERE LocationID IS NOT NULL;
|
||||
END;
|
||||
GO
|
||||
|
||||
UPDATE fp
|
||||
SET LocationID = matched.LocationID
|
||||
FROM dbo.FloorPlans fp
|
||||
CROSS APPLY
|
||||
(
|
||||
SELECT TOP (1) l.LocationID
|
||||
FROM dbo.Locations l
|
||||
WHERE l.ProjectID = fp.ProjectID
|
||||
AND l.ParentLocationID IS NULL
|
||||
AND l.IsArchived = 0
|
||||
AND LTRIM(RTRIM(l.LocationName)) = LTRIM(RTRIM(fp.Name))
|
||||
ORDER BY l.LocationID
|
||||
) matched
|
||||
WHERE fp.LocationID IS NULL;
|
||||
GO
|
||||
|
||||
UPDATE fpf
|
||||
SET LocationID = matched.LocationID
|
||||
FROM dbo.FloorPlanFloors fpf
|
||||
INNER JOIN dbo.FloorPlans fp ON fp.FloorPlanID = fpf.FloorPlanID
|
||||
CROSS APPLY
|
||||
(
|
||||
SELECT TOP (1) l.LocationID
|
||||
FROM dbo.Locations l
|
||||
WHERE l.ProjectID = fp.ProjectID
|
||||
AND l.ParentLocationID = fp.LocationID
|
||||
AND l.IsArchived = 0
|
||||
AND LTRIM(RTRIM(l.LocationName)) = LTRIM(RTRIM(fpf.Name))
|
||||
ORDER BY l.LocationID
|
||||
) matched
|
||||
WHERE fpf.LocationID IS NULL
|
||||
AND fp.LocationID IS NOT NULL;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.FloorPlan_ListByProject
|
||||
@ProjectID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT
|
||||
fp.FloorPlanID,
|
||||
fp.ProjectID,
|
||||
fp.LocationID,
|
||||
lp.LocationName,
|
||||
lp.LocationPath,
|
||||
fp.Name,
|
||||
fp.Description,
|
||||
fp.CreatedDate,
|
||||
fp.ModifiedDate,
|
||||
COUNT(DISTINCT fpf.FloorPlanFloorID) AS FloorCount,
|
||||
COUNT(fpb.FloorPlanBlockID) AS BlockCount
|
||||
FROM dbo.FloorPlans fp
|
||||
LEFT JOIN dbo.LocationPaths lp ON lp.LocationID = fp.LocationID
|
||||
LEFT JOIN dbo.FloorPlanFloors fpf ON fpf.FloorPlanID = fp.FloorPlanID
|
||||
LEFT JOIN dbo.FloorPlanBlocks fpb ON fpb.FloorPlanFloorID = fpf.FloorPlanFloorID
|
||||
WHERE fp.ProjectID = @ProjectID
|
||||
GROUP BY fp.FloorPlanID, fp.ProjectID, fp.LocationID, lp.LocationName, lp.LocationPath, fp.Name, fp.Description, fp.CreatedDate, fp.ModifiedDate
|
||||
ORDER BY fp.Name;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.FloorPlan_Get
|
||||
@FloorPlanID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT
|
||||
fp.FloorPlanID,
|
||||
fp.ProjectID,
|
||||
fp.LocationID,
|
||||
lp.LocationName,
|
||||
lp.LocationPath,
|
||||
fp.Name,
|
||||
fp.Description,
|
||||
fp.CreatedDate,
|
||||
fp.ModifiedDate
|
||||
FROM dbo.FloorPlans fp
|
||||
LEFT JOIN dbo.LocationPaths lp ON lp.LocationID = fp.LocationID
|
||||
WHERE fp.FloorPlanID = @FloorPlanID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.FloorPlan_GetEditor
|
||||
@FloorPlanID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
EXEC dbo.FloorPlan_Get @FloorPlanID;
|
||||
|
||||
SELECT
|
||||
fpf.FloorPlanFloorID,
|
||||
fpf.FloorPlanID,
|
||||
fpf.LocationID,
|
||||
lp.LocationName,
|
||||
lp.LocationPath,
|
||||
fpf.Name,
|
||||
fpf.SortOrder,
|
||||
fpf.GridWidth,
|
||||
fpf.GridHeight,
|
||||
fpf.CreatedDate,
|
||||
fpf.ModifiedDate
|
||||
FROM dbo.FloorPlanFloors fpf
|
||||
LEFT JOIN dbo.LocationPaths 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 dbo.LocationPaths lp ON lp.LocationID = fpb.LocationID
|
||||
WHERE fpf.FloorPlanID = @FloorPlanID
|
||||
ORDER BY fpf.SortOrder, fpb.Y, fpb.X, lp.LocationName;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.FloorPlan_Save
|
||||
@FloorPlanID int,
|
||||
@ProjectID int,
|
||||
@LocationID int = NULL,
|
||||
@Name nvarchar(200),
|
||||
@Description nvarchar(max) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SET @Name = NULLIF(LTRIM(RTRIM(@Name)), N'');
|
||||
IF @Name IS NULL
|
||||
THROW 51000, 'Floor plan name is required.', 1;
|
||||
|
||||
IF @LocationID IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM dbo.Locations WHERE LocationID = @LocationID AND ProjectID = @ProjectID AND IsArchived = 0)
|
||||
THROW 51007, 'Floor plan location must belong to this project.', 1;
|
||||
|
||||
IF @FloorPlanID = 0
|
||||
BEGIN
|
||||
INSERT dbo.FloorPlans (ProjectID, LocationID, Name, Description)
|
||||
VALUES (@ProjectID, @LocationID, @Name, @Description);
|
||||
|
||||
SELECT CONVERT(int, SCOPE_IDENTITY());
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
UPDATE dbo.FloorPlans
|
||||
SET LocationID = @LocationID,
|
||||
Name = @Name,
|
||||
Description = @Description,
|
||||
ModifiedDate = SYSUTCDATETIME()
|
||||
WHERE FloorPlanID = @FloorPlanID
|
||||
AND ProjectID = @ProjectID;
|
||||
|
||||
SELECT @FloorPlanID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.FloorPlanFloor_Get
|
||||
@FloorPlanFloorID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT
|
||||
fpf.FloorPlanFloorID,
|
||||
fpf.FloorPlanID,
|
||||
fpf.LocationID,
|
||||
lp.LocationName,
|
||||
lp.LocationPath,
|
||||
fpf.Name,
|
||||
fpf.SortOrder,
|
||||
fpf.GridWidth,
|
||||
fpf.GridHeight,
|
||||
fpf.CreatedDate,
|
||||
fpf.ModifiedDate
|
||||
FROM dbo.FloorPlanFloors fpf
|
||||
LEFT JOIN dbo.LocationPaths lp ON lp.LocationID = fpf.LocationID
|
||||
WHERE fpf.FloorPlanFloorID = @FloorPlanFloorID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.FloorPlanFloor_Save
|
||||
@FloorPlanFloorID int,
|
||||
@FloorPlanID int,
|
||||
@LocationID int = NULL,
|
||||
@Name nvarchar(100),
|
||||
@SortOrder int,
|
||||
@GridWidth int,
|
||||
@GridHeight int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @ProjectID int;
|
||||
SELECT @ProjectID = ProjectID FROM dbo.FloorPlans WHERE FloorPlanID = @FloorPlanID;
|
||||
|
||||
IF @ProjectID IS NULL
|
||||
THROW 51003, 'Floor plan not found.', 1;
|
||||
|
||||
SET @Name = NULLIF(LTRIM(RTRIM(@Name)), N'');
|
||||
IF @Name IS NULL
|
||||
THROW 51001, 'Floor name is required.', 1;
|
||||
|
||||
IF @GridWidth NOT BETWEEN 8 AND 80 OR @GridHeight NOT BETWEEN 8 AND 80
|
||||
THROW 51002, 'Grid width and height must be between 8 and 80.', 1;
|
||||
|
||||
IF @LocationID IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM dbo.Locations WHERE LocationID = @LocationID AND ProjectID = @ProjectID AND IsArchived = 0)
|
||||
THROW 51008, 'Floor location must belong to this project.', 1;
|
||||
|
||||
IF @FloorPlanFloorID = 0
|
||||
BEGIN
|
||||
INSERT dbo.FloorPlanFloors (FloorPlanID, LocationID, Name, SortOrder, GridWidth, GridHeight)
|
||||
VALUES (@FloorPlanID, @LocationID, @Name, @SortOrder, @GridWidth, @GridHeight);
|
||||
|
||||
SELECT CONVERT(int, SCOPE_IDENTITY());
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
UPDATE dbo.FloorPlanFloors
|
||||
SET LocationID = @LocationID,
|
||||
Name = @Name,
|
||||
SortOrder = @SortOrder,
|
||||
GridWidth = @GridWidth,
|
||||
GridHeight = @GridHeight,
|
||||
ModifiedDate = SYSUTCDATETIME()
|
||||
WHERE FloorPlanFloorID = @FloorPlanFloorID
|
||||
AND FloorPlanID = @FloorPlanID;
|
||||
|
||||
SELECT @FloorPlanFloorID;
|
||||
END;
|
||||
GO
|
||||
@ -1821,12 +1821,16 @@ public sealed class FloorPlanListViewModel
|
||||
public Project Project { get; set; } = new();
|
||||
public IReadOnlyList<FloorPlan> FloorPlans { get; set; } = [];
|
||||
public FloorPlanEditViewModel NewFloorPlan { get; set; } = new();
|
||||
public IReadOnlyList<SelectListItem> LocationOptions { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class FloorPlanEditViewModel
|
||||
{
|
||||
public int FloorPlanID { get; set; }
|
||||
public int ProjectID { get; set; }
|
||||
public int? LocationID { get; set; }
|
||||
public string? LocationName { get; set; }
|
||||
public string? LocationPath { get; set; }
|
||||
|
||||
[Required, StringLength(200)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
@ -1843,6 +1847,7 @@ public sealed class FloorPlanEditorViewModel
|
||||
public FloorPlanFloorEditViewModel NewFloor { get; set; } = new();
|
||||
public FloorPlanBlockEditViewModel NewBlock { get; set; } = new();
|
||||
public IReadOnlyList<SelectListItem> LocationOptions { get; set; } = [];
|
||||
public IReadOnlyList<LocationItem> Locations { get; set; } = [];
|
||||
public IReadOnlyList<SelectListItem> BlockTypeOptions { get; set; } = [];
|
||||
}
|
||||
|
||||
@ -1850,6 +1855,9 @@ public sealed class FloorPlanFloorEditViewModel
|
||||
{
|
||||
public int FloorPlanFloorID { get; set; }
|
||||
public int FloorPlanID { get; set; }
|
||||
public int? LocationID { get; set; }
|
||||
public string? LocationName { get; set; }
|
||||
public string? LocationPath { get; set; }
|
||||
|
||||
[Required, StringLength(100)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
@ -1868,8 +1876,9 @@ public sealed class FloorPlanBlockEditViewModel
|
||||
public int FloorPlanBlockID { get; set; }
|
||||
public int FloorPlanFloorID { get; set; }
|
||||
|
||||
[Range(1, int.MaxValue, ErrorMessage = "Choose a location.")]
|
||||
public int LocationID { get; set; }
|
||||
public int? LocationID { get; set; }
|
||||
public string AddBlockLocationMode { get; set; } = "Existing";
|
||||
public string? NewLocationName { get; set; }
|
||||
|
||||
public string LocationName { get; set; } = string.Empty;
|
||||
public string LocationPath { get; set; } = string.Empty;
|
||||
|
||||
@ -38,39 +38,24 @@
|
||||
<form asp-action="SavePlan" method="post" class="row g-3">
|
||||
<input asp-for="FloorPlan.FloorPlanID" type="hidden" name="FloorPlanID" />
|
||||
<input asp-for="FloorPlan.ProjectID" type="hidden" name="ProjectID" />
|
||||
<div class="col-md-5">
|
||||
<div class="col-md-4">
|
||||
<label asp-for="FloorPlan.Name" class="form-label"></label>
|
||||
<input asp-for="FloorPlan.Name" class="form-control" name="Name" />
|
||||
<span asp-validation-for="FloorPlan.Name" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<div class="col-md-4">
|
||||
<label asp-for="FloorPlan.Description" class="form-label"></label>
|
||||
<input asp-for="FloorPlan.Description" class="form-control" name="Description" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Root Location</label>
|
||||
<select asp-for="FloorPlan.LocationID" asp-items="Model.LocationOptions" class="form-select" name="LocationID"></select>
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-end">
|
||||
<button class="btn btn-outline-primary w-100" type="submit">Save details</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="edit-panel floor-plan-tools">
|
||||
<form asp-action="AddFloor" method="post" class="row g-2 align-items-end">
|
||||
<input asp-for="NewFloor.FloorPlanID" type="hidden" name="FloorPlanID" />
|
||||
<input asp-for="NewFloor.SortOrder" type="hidden" name="SortOrder" />
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">New floor</label>
|
||||
<input asp-for="NewFloor.Name" class="form-control form-control-sm" name="Name" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Width</label>
|
||||
<input asp-for="NewFloor.GridWidth" class="form-control form-control-sm" name="GridWidth" min="8" max="80" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Height</label>
|
||||
<input asp-for="NewFloor.GridHeight" class="form-control form-control-sm" name="GridHeight" min="8" max="80" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button class="btn btn-outline-primary btn-sm w-100" type="submit">Add floor</button>
|
||||
<div class="col-12">
|
||||
<p class="muted mb-0">Linked Location: @(string.IsNullOrWhiteSpace(Model.FloorPlan.LocationPath) ? "A matching Location will be created or linked on save." : Model.FloorPlan.LocationPath)</p>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@ -80,7 +65,31 @@
|
||||
<section class="empty-panel">
|
||||
<h2>No floors yet</h2>
|
||||
<p>Add a floor to start arranging linked locations.</p>
|
||||
<div class="floor-plan-tool-card mt-3">
|
||||
<h3>Add floor</h3>
|
||||
<input asp-for="NewFloor.FloorPlanID" type="hidden" name="FloorPlanID" form="floor-plan-add-floor-form" />
|
||||
<input asp-for="NewFloor.SortOrder" type="hidden" name="SortOrder" form="floor-plan-add-floor-form" />
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">New floor</label>
|
||||
<input asp-for="NewFloor.Name" class="form-control form-control-sm" name="Name" form="floor-plan-add-floor-form" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Width</label>
|
||||
<input asp-for="NewFloor.GridWidth" class="form-control form-control-sm" name="GridWidth" min="8" max="80" form="floor-plan-add-floor-form" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Height</label>
|
||||
<input asp-for="NewFloor.GridHeight" class="form-control form-control-sm" name="GridHeight" min="8" max="80" form="floor-plan-add-floor-form" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button class="btn btn-outline-primary btn-sm w-100" type="submit" form="floor-plan-add-floor-form">Add floor</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form id="floor-plan-add-floor-form" asp-action="AddFloor" method="post"></form>
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -88,15 +97,26 @@ else
|
||||
<input asp-for="FloorPlan.FloorPlanID" type="hidden" />
|
||||
<input type="hidden" name="floorPlanId" value="@Model.FloorPlan.FloorPlanID" />
|
||||
<input asp-for="FloorPlan.ProjectID" type="hidden" />
|
||||
<input asp-for="FloorPlan.LocationID" type="hidden" />
|
||||
<input asp-for="FloorPlan.Name" type="hidden" />
|
||||
<input asp-for="FloorPlan.Description" type="hidden" />
|
||||
|
||||
<section class="floor-plan-tabs" role="tablist" aria-label="Floors">
|
||||
@foreach (var floor in Model.Floors.OrderBy(x => x.SortOrder).ThenBy(x => x.Name))
|
||||
{
|
||||
var isActive = floor.FloorPlanFloorID == activeFloor?.FloorPlanFloorID;
|
||||
<button type="button" class="floor-plan-tab @(isActive ? "active" : string.Empty)" data-floor-tab="@floor.FloorPlanFloorID" data-floor-name="@floor.Name">@floor.Name</button>
|
||||
}
|
||||
<section class="floor-plan-editor-toolbar">
|
||||
<div>
|
||||
<span class="muted">Active floor</span>
|
||||
<strong data-current-floor-name>@activeFloor?.Name</strong>
|
||||
</div>
|
||||
<div class="floor-plan-tabs" role="tablist" aria-label="Floors">
|
||||
@foreach (var floor in Model.Floors.OrderBy(x => x.SortOrder).ThenBy(x => x.Name))
|
||||
{
|
||||
var isActive = floor.FloorPlanFloorID == activeFloor?.FloorPlanFloorID;
|
||||
<button type="button" class="floor-plan-tab @(isActive ? "active" : string.Empty)" data-floor-tab="@floor.FloorPlanFloorID" data-floor-name="@floor.Name" data-floor-location-id="@floor.LocationID">@floor.Name</button>
|
||||
}
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<a class="btn btn-outline-secondary btn-sm" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Back to floor plans</a>
|
||||
<button class="btn btn-primary btn-sm" type="submit">Save layout</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="floor-plan-layout">
|
||||
@ -107,28 +127,6 @@ else
|
||||
var isActive = floor.FloorPlanFloorID == activeFloor?.FloorPlanFloorID;
|
||||
var floorBlocks = blocksByFloor.GetValueOrDefault(floor.FloorPlanFloorID) ?? [];
|
||||
<div class="floor-plan-floor @(isActive ? "active" : string.Empty)" data-floor-panel="@floor.FloorPlanFloorID">
|
||||
<div class="floor-plan-floor-header">
|
||||
<div>
|
||||
<input asp-for="Floors[i].FloorPlanFloorID" type="hidden" />
|
||||
<input asp-for="Floors[i].FloorPlanID" type="hidden" />
|
||||
<input asp-for="Floors[i].SortOrder" type="hidden" />
|
||||
<label class="form-label">Floor name</label>
|
||||
<input asp-for="Floors[i].Name" class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Grid width</label>
|
||||
<input asp-for="Floors[i].GridWidth" class="form-control form-control-sm" min="8" max="80" data-floor-width="@floor.FloorPlanFloorID" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Grid height</label>
|
||||
<input asp-for="Floors[i].GridHeight" class="form-control form-control-sm" min="8" max="80" data-floor-height="@floor.FloorPlanFloorID" />
|
||||
</div>
|
||||
<button class="btn btn-outline-danger btn-sm"
|
||||
type="button"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#delete-floor-@floor.FloorPlanFloorID">Delete floor</button>
|
||||
</div>
|
||||
|
||||
<div class="floor-plan-grid-wrap">
|
||||
<div class="floor-plan-grid"
|
||||
style="--grid-width:@floor.GridWidth;--grid-height:@floor.GridHeight;"
|
||||
@ -143,8 +141,9 @@ else
|
||||
style="--x:@block.X;--y:@block.Y;--w:@block.WidthCells;--h:@block.HeightCells;"
|
||||
data-block="@block.FloorPlanBlockID"
|
||||
data-block-index="@blockIndex"
|
||||
data-floor-id="@block.FloorPlanFloorID">
|
||||
<span>@block.DisplayLabel</span>
|
||||
data-floor-id="@block.FloorPlanFloorID"
|
||||
title="@block.LocationPath">
|
||||
<span class="floor-plan-block-label">@block.DisplayLabel</span>
|
||||
<span class="floor-plan-resize-handle" data-resize-handle aria-hidden="true"></span>
|
||||
</button>
|
||||
}
|
||||
@ -155,96 +154,166 @@ else
|
||||
</section>
|
||||
|
||||
<aside class="floor-plan-side-panel">
|
||||
@if (Model.LocationOptions.Any())
|
||||
{
|
||||
<section class="floor-plan-add-block-panel">
|
||||
<h2>Add block</h2>
|
||||
<p class="muted">Adds a block to <span data-current-floor-name>@activeFloor?.Name</span>.</p>
|
||||
<input type="hidden" name="FloorPlanBlockID" value="0" form="floor-plan-add-block-form" />
|
||||
<input type="hidden" name="FloorPlanFloorID" value="@activeFloor?.FloorPlanFloorID" data-add-block-floor-id form="floor-plan-add-block-form" />
|
||||
<input type="hidden" name="X" value="0" form="floor-plan-add-block-form" />
|
||||
<input type="hidden" name="Y" value="0" form="floor-plan-add-block-form" />
|
||||
<input type="hidden" name="WidthCells" value="1" data-add-block-width form="floor-plan-add-block-form" />
|
||||
<input type="hidden" name="HeightCells" value="1" data-add-block-height form="floor-plan-add-block-form" />
|
||||
<div class="mb-2">
|
||||
<label class="form-label" for="add-block-location">Location</label>
|
||||
<select id="add-block-location" class="form-select form-select-sm" name="LocationID" asp-items="Model.LocationOptions" form="floor-plan-add-block-form"></select>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label" for="add-block-type">Block type</label>
|
||||
<select id="add-block-type" class="form-select form-select-sm" name="BlockType" asp-items="Model.BlockTypeOptions" form="floor-plan-add-block-form"></select>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label" for="add-block-size-preset">Size preset</label>
|
||||
<select id="add-block-size-preset" class="form-select form-select-sm" data-size-preset>
|
||||
<option value="1x1">Small Room 1 x 1</option>
|
||||
<option value="2x1" selected>Medium Room 2 x 1</option>
|
||||
<option value="2x2">Large Room 2 x 2</option>
|
||||
<option value="1x3">Corridor 1 x 3</option>
|
||||
<option value="3x2">Wide Hall 3 x 2</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="add-block-label">Label override</label>
|
||||
<input id="add-block-label" class="form-control form-control-sm" name="LabelOverride" form="floor-plan-add-block-form" />
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm w-100" type="submit" form="floor-plan-add-block-form">Add block</button>
|
||||
</section>
|
||||
}
|
||||
<section class="floor-plan-tool-card">
|
||||
<h2>Add block</h2>
|
||||
<p class="muted">Adds a block to <span data-current-floor-name>@activeFloor?.Name</span>. Use a child Location or create a new room under this floor.</p>
|
||||
<input type="hidden" name="FloorPlanBlockID" value="0" form="floor-plan-add-block-form" />
|
||||
<input type="hidden" name="FloorPlanFloorID" value="@activeFloor?.FloorPlanFloorID" data-add-block-floor-id form="floor-plan-add-block-form" />
|
||||
<input type="hidden" name="X" value="0" form="floor-plan-add-block-form" />
|
||||
<input type="hidden" name="Y" value="0" form="floor-plan-add-block-form" />
|
||||
<input type="hidden" name="WidthCells" value="1" data-add-block-width form="floor-plan-add-block-form" />
|
||||
<input type="hidden" name="HeightCells" value="1" data-add-block-height form="floor-plan-add-block-form" />
|
||||
<div class="floor-plan-choice-row" role="group" aria-label="Add block location mode">
|
||||
<label><input type="radio" name="AddBlockLocationMode" value="Existing" checked form="floor-plan-add-block-form" data-add-block-mode /> Use existing location</label>
|
||||
<label><input type="radio" name="AddBlockLocationMode" value="Create" form="floor-plan-add-block-form" data-add-block-mode /> Create new room/location</label>
|
||||
</div>
|
||||
<div class="mb-2" data-existing-location-panel>
|
||||
<label class="form-label" for="add-block-location">Existing child Location</label>
|
||||
<select id="add-block-location" class="form-select form-select-sm" name="LocationID" form="floor-plan-add-block-form" data-add-block-location>
|
||||
<option value="">Choose a child Location</option>
|
||||
@foreach (var location in Model.Locations)
|
||||
{
|
||||
<option value="@location.LocationID" data-parent-location-id="@location.ParentLocationID">@location.LocationPath</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-2 d-none" data-new-location-panel>
|
||||
<label class="form-label" for="add-block-new-location">New room/location name</label>
|
||||
<input id="add-block-new-location" class="form-control form-control-sm" name="NewLocationName" form="floor-plan-add-block-form" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label" for="add-block-type">Block type</label>
|
||||
<select id="add-block-type" class="form-select form-select-sm" name="BlockType" asp-items="Model.BlockTypeOptions" form="floor-plan-add-block-form"></select>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label" for="add-block-size-preset">Size preset</label>
|
||||
<select id="add-block-size-preset" class="form-select form-select-sm" data-size-preset>
|
||||
<option value="1x1">Small Room 1 x 1</option>
|
||||
<option value="2x1" selected>Medium Room 2 x 1</option>
|
||||
<option value="2x2">Large Room 2 x 2</option>
|
||||
<option value="1x3">Corridor 1 x 3</option>
|
||||
<option value="3x2">Wide Hall 3 x 2</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="add-block-label">Optional label override</label>
|
||||
<input id="add-block-label" class="form-control form-control-sm" name="LabelOverride" form="floor-plan-add-block-form" />
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm w-100" type="submit" form="floor-plan-add-block-form">Add block</button>
|
||||
</section>
|
||||
|
||||
<h2>Selected block</h2>
|
||||
<p class="muted" data-no-selection>Select a block on the grid to edit its details.</p>
|
||||
<div class="floor-plan-overlap-warning d-none" data-overlap-warning>Some blocks overlap on this floor.</div>
|
||||
<section class="floor-plan-tool-card">
|
||||
<h2>Selected block</h2>
|
||||
<p class="muted" data-no-selection>Select a block on the grid to edit its details.</p>
|
||||
<div class="floor-plan-overlap-warning d-none" data-overlap-warning>Some blocks overlap on this floor.</div>
|
||||
|
||||
@for (var i = 0; i < Model.Blocks.Count; i++)
|
||||
{
|
||||
<div class="floor-plan-block-editor d-none" data-block-editor="@Model.Blocks[i].FloorPlanBlockID">
|
||||
<input asp-for="Blocks[i].FloorPlanBlockID" type="hidden" />
|
||||
<input asp-for="Blocks[i].FloorPlanFloorID" type="hidden" data-block-field="floor" />
|
||||
<input asp-for="Blocks[i].LocationName" type="hidden" />
|
||||
<input asp-for="Blocks[i].LocationPath" type="hidden" />
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Location</label>
|
||||
<select asp-for="Blocks[i].LocationID" asp-items="Model.LocationOptions" class="form-select form-select-sm"></select>
|
||||
@for (var i = 0; i < Model.Blocks.Count; i++)
|
||||
{
|
||||
<div class="floor-plan-block-editor d-none" data-block-editor="@Model.Blocks[i].FloorPlanBlockID">
|
||||
<input asp-for="Blocks[i].FloorPlanBlockID" type="hidden" />
|
||||
<input asp-for="Blocks[i].FloorPlanFloorID" type="hidden" data-block-field="floor" />
|
||||
<input asp-for="Blocks[i].LocationName" type="hidden" />
|
||||
<input asp-for="Blocks[i].LocationPath" type="hidden" />
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Location</label>
|
||||
<select asp-for="Blocks[i].LocationID" asp-items="Model.LocationOptions" class="form-select form-select-sm"></select>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Type</label>
|
||||
<select asp-for="Blocks[i].BlockType" asp-items="Model.BlockTypeOptions" class="form-select form-select-sm" data-block-field="type"></select>
|
||||
</div>
|
||||
<div class="row g-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label">X</label>
|
||||
<input asp-for="Blocks[i].X" class="form-control form-control-sm" min="0" max="79" data-block-field="x" />
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Y</label>
|
||||
<input asp-for="Blocks[i].Y" class="form-control form-control-sm" min="0" max="79" data-block-field="y" />
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Width</label>
|
||||
<input asp-for="Blocks[i].WidthCells" class="form-control form-control-sm" min="1" max="80" data-block-field="w" />
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Height</label>
|
||||
<input asp-for="Blocks[i].HeightCells" class="form-control form-control-sm" min="1" max="80" data-block-field="h" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<label class="form-label">Label override</label>
|
||||
<input asp-for="Blocks[i].LabelOverride" class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<label class="form-label">Notes</label>
|
||||
<textarea asp-for="Blocks[i].Notes" class="form-control form-control-sm" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="button-row mt-3">
|
||||
<button class="btn btn-outline-danger btn-sm"
|
||||
type="button"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#delete-block-@Model.Blocks[i].FloorPlanBlockID">Delete block</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="floor-plan-tool-card">
|
||||
<h2>Floors</h2>
|
||||
<div class="floor-plan-tool-subsection">
|
||||
<h3>Add floor</h3>
|
||||
<input asp-for="NewFloor.FloorPlanID" type="hidden" name="FloorPlanID" form="floor-plan-add-floor-form" />
|
||||
<input asp-for="NewFloor.SortOrder" type="hidden" name="SortOrder" form="floor-plan-add-floor-form" />
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Type</label>
|
||||
<select asp-for="Blocks[i].BlockType" asp-items="Model.BlockTypeOptions" class="form-select form-select-sm" data-block-field="type"></select>
|
||||
<label class="form-label">New floor</label>
|
||||
<input asp-for="NewFloor.Name" class="form-control form-control-sm" name="Name" form="floor-plan-add-floor-form" />
|
||||
</div>
|
||||
<div class="row g-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label">X</label>
|
||||
<input asp-for="Blocks[i].X" class="form-control form-control-sm" min="0" max="79" data-block-field="x" />
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Y</label>
|
||||
<input asp-for="Blocks[i].Y" class="form-control form-control-sm" min="0" max="79" data-block-field="y" />
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Width</label>
|
||||
<input asp-for="Blocks[i].WidthCells" class="form-control form-control-sm" min="1" max="80" data-block-field="w" />
|
||||
<input asp-for="NewFloor.GridWidth" class="form-control form-control-sm" name="GridWidth" min="8" max="80" form="floor-plan-add-floor-form" />
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Height</label>
|
||||
<input asp-for="Blocks[i].HeightCells" class="form-control form-control-sm" min="1" max="80" data-block-field="h" />
|
||||
<input asp-for="NewFloor.GridHeight" class="form-control form-control-sm" name="GridHeight" min="8" max="80" form="floor-plan-add-floor-form" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<label class="form-label">Label override</label>
|
||||
<input asp-for="Blocks[i].LabelOverride" class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<label class="form-label">Notes</label>
|
||||
<textarea asp-for="Blocks[i].Notes" class="form-control form-control-sm" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="button-row mt-3">
|
||||
<button class="btn btn-outline-danger btn-sm"
|
||||
type="button"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#delete-block-@Model.Blocks[i].FloorPlanBlockID">Delete block</button>
|
||||
</div>
|
||||
<button class="btn btn-outline-primary btn-sm w-100 mt-2" type="submit" form="floor-plan-add-floor-form">Add floor</button>
|
||||
</div>
|
||||
}
|
||||
<div class="floor-plan-tool-subsection">
|
||||
<h3>Current floor</h3>
|
||||
@for (var i = 0; i < Model.Floors.Count; i++)
|
||||
{
|
||||
var floor = Model.Floors[i];
|
||||
var isActive = floor.FloorPlanFloorID == activeFloor?.FloorPlanFloorID;
|
||||
<div class="floor-plan-floor-editor @(isActive ? string.Empty : "d-none")" data-floor-editor="@floor.FloorPlanFloorID">
|
||||
<input asp-for="Floors[i].FloorPlanFloorID" type="hidden" />
|
||||
<input asp-for="Floors[i].FloorPlanID" type="hidden" />
|
||||
<input asp-for="Floors[i].SortOrder" type="hidden" />
|
||||
<input asp-for="Floors[i].LocationID" type="hidden" />
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Floor name</label>
|
||||
<input asp-for="Floors[i].Name" class="form-control form-control-sm" />
|
||||
</div>
|
||||
<p class="muted">Location: @(string.IsNullOrWhiteSpace(floor.LocationPath) ? "A child Location will be created or linked on save." : floor.LocationPath)</p>
|
||||
<div class="row g-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label">Grid width</label>
|
||||
<input asp-for="Floors[i].GridWidth" class="form-control form-control-sm" min="8" max="80" data-floor-width="@floor.FloorPlanFloorID" />
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Grid height</label>
|
||||
<input asp-for="Floors[i].GridHeight" class="form-control form-control-sm" min="8" max="80" data-floor-height="@floor.FloorPlanFloorID" />
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-outline-danger btn-sm w-100 mt-2"
|
||||
type="button"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#delete-floor-@floor.FloorPlanFloorID">Delete floor</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@ -257,6 +326,8 @@ else
|
||||
<input type="hidden" name="floorPlanId" value="@Model.FloorPlan.FloorPlanID" />
|
||||
</form>
|
||||
|
||||
<form id="floor-plan-add-floor-form" asp-action="AddFloor" method="post"></form>
|
||||
|
||||
@foreach (var floor in Model.Floors)
|
||||
{
|
||||
var placedBlockCount = Model.Blocks.Count(x => x.FloorPlanFloorID == floor.FloorPlanFloorID);
|
||||
@ -320,11 +391,16 @@ else
|
||||
|
||||
const tabs = [...editor.querySelectorAll("[data-floor-tab]")];
|
||||
const panels = [...editor.querySelectorAll("[data-floor-panel]")];
|
||||
const floorEditors = [...editor.querySelectorAll("[data-floor-editor]")];
|
||||
const blocks = [...editor.querySelectorAll("[data-block]")];
|
||||
const noSelection = editor.querySelector("[data-no-selection]");
|
||||
const warning = editor.querySelector("[data-overlap-warning]");
|
||||
const addBlockFloor = document.querySelector("[data-add-block-floor-id]");
|
||||
const addBlockFloorName = document.querySelector("[data-current-floor-name]");
|
||||
const addBlockFloorNames = [...document.querySelectorAll("[data-current-floor-name]")];
|
||||
const addBlockLocation = document.querySelector("[data-add-block-location]");
|
||||
const addBlockModes = [...document.querySelectorAll("[data-add-block-mode]")];
|
||||
const existingLocationPanel = document.querySelector("[data-existing-location-panel]");
|
||||
const newLocationPanel = document.querySelector("[data-new-location-panel]");
|
||||
const sizePreset = document.querySelector("[data-size-preset]");
|
||||
const addBlockWidth = document.querySelector("[data-add-block-width]");
|
||||
const addBlockHeight = document.querySelector("[data-add-block-height]");
|
||||
@ -335,12 +411,42 @@ else
|
||||
function setActiveFloor(floorId) {
|
||||
tabs.forEach(tab => tab.classList.toggle("active", tab.dataset.floorTab === floorId));
|
||||
panels.forEach(panel => panel.classList.toggle("active", panel.dataset.floorPanel === floorId));
|
||||
floorEditors.forEach(panel => panel.classList.toggle("d-none", panel.dataset.floorEditor !== floorId));
|
||||
const activeTab = tabs.find(tab => tab.dataset.floorTab === floorId);
|
||||
if (addBlockFloor) addBlockFloor.value = floorId;
|
||||
if (addBlockFloorName && activeTab) addBlockFloorName.textContent = activeTab.dataset.floorName || activeTab.textContent || "the current floor";
|
||||
if (activeTab) {
|
||||
addBlockFloorNames.forEach(item => item.textContent = activeTab.dataset.floorName || activeTab.textContent || "the current floor");
|
||||
}
|
||||
filterAddBlockLocations(activeTab?.dataset.floorLocationId || "");
|
||||
checkOverlaps();
|
||||
}
|
||||
|
||||
function filterAddBlockLocations(floorLocationId) {
|
||||
if (!addBlockLocation) return;
|
||||
let firstValue = "";
|
||||
[...addBlockLocation.options].forEach(option => {
|
||||
if (!option.value) {
|
||||
option.hidden = false;
|
||||
option.disabled = false;
|
||||
return;
|
||||
}
|
||||
const matches = option.dataset.parentLocationId === floorLocationId;
|
||||
option.hidden = !matches;
|
||||
option.disabled = !matches;
|
||||
if (matches && !firstValue) firstValue = option.value;
|
||||
});
|
||||
if (!addBlockLocation.value || addBlockLocation.selectedOptions[0]?.disabled) {
|
||||
addBlockLocation.value = firstValue;
|
||||
}
|
||||
}
|
||||
|
||||
function updateAddBlockMode() {
|
||||
const selectedMode = addBlockModes.find(input => input.checked)?.value || "Existing";
|
||||
const isCreate = selectedMode === "Create";
|
||||
existingLocationPanel?.classList.toggle("d-none", isCreate);
|
||||
newLocationPanel?.classList.toggle("d-none", !isCreate);
|
||||
}
|
||||
|
||||
function selectBlock(block) {
|
||||
selectedBlock = block;
|
||||
blocks.forEach(item => item.classList.toggle("selected", item === block));
|
||||
@ -495,6 +601,8 @@ else
|
||||
if (addBlockHeight) addBlockHeight.value = height || "1";
|
||||
});
|
||||
sizePreset?.dispatchEvent(new Event("change"));
|
||||
addBlockModes.forEach(input => input.addEventListener("change", updateAddBlockMode));
|
||||
updateAddBlockMode();
|
||||
|
||||
const initialBlock = blocks.find(block => block.dataset.block === initialSelectedBlockId) || blocks[0];
|
||||
if (initialBlock) {
|
||||
|
||||
@ -28,14 +28,18 @@
|
||||
<h2>Create floor plan</h2>
|
||||
<form asp-action="Create" method="post" class="row g-3">
|
||||
<input asp-for="NewFloorPlan.ProjectID" type="hidden" name="ProjectID" />
|
||||
<div class="col-md-5">
|
||||
<div class="col-md-4">
|
||||
<label asp-for="NewFloorPlan.Name" class="form-label"></label>
|
||||
<input asp-for="NewFloorPlan.Name" class="form-control" name="Name" />
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<div class="col-md-4">
|
||||
<label asp-for="NewFloorPlan.Description" class="form-label"></label>
|
||||
<input asp-for="NewFloorPlan.Description" class="form-control" name="Description" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Root Location</label>
|
||||
<select asp-for="NewFloorPlan.LocationID" asp-items="Model.LocationOptions" class="form-select" name="LocationID"></select>
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-end">
|
||||
<button class="btn btn-primary w-100" type="submit">Create</button>
|
||||
</div>
|
||||
@ -63,6 +67,7 @@ else
|
||||
<p>@floorPlan.Description</p>
|
||||
}
|
||||
<p class="muted">@floorPlan.FloorCount floor(s), @floorPlan.BlockCount placed block(s)</p>
|
||||
<p class="muted">Location: @(string.IsNullOrWhiteSpace(floorPlan.LocationPath) ? "Not linked yet" : floorPlan.LocationPath)</p>
|
||||
</div>
|
||||
<div class="button-row compact-buttons">
|
||||
<a class="btn btn-outline-secondary btn-sm" asp-action="Edit" asp-route-id="@floorPlan.FloorPlanID">Open editor</a>
|
||||
|
||||
@ -4503,7 +4503,28 @@ body.dragging-location [data-drag-type="location"] {
|
||||
.floor-plan-layout {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(320px, 360px);
|
||||
}
|
||||
|
||||
.floor-plan-editor-toolbar {
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, .92);
|
||||
border: 1px solid var(--border-color, #d8ded9);
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 12px;
|
||||
position: sticky;
|
||||
top: 10px;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.floor-plan-editor-toolbar .muted {
|
||||
display: block;
|
||||
font-size: .76rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.floor-plan-floor {
|
||||
@ -4514,15 +4535,14 @@ body.dragging-location [data-drag-type="location"] {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.floor-plan-floor-header {
|
||||
align-items: end;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: minmax(180px, 1fr) 120px 120px auto;
|
||||
margin-bottom: 10px;
|
||||
.floor-plan-stage {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.floor-plan-grid-wrap {
|
||||
max-width: 100%;
|
||||
max-height: calc(100vh - 250px);
|
||||
min-height: min(58vh, 520px);
|
||||
overflow: auto;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
@ -4549,13 +4569,14 @@ body.dragging-location [data-drag-type="location"] {
|
||||
color: #12332c;
|
||||
cursor: grab;
|
||||
display: flex;
|
||||
font-weight: 650;
|
||||
font-size: .72rem;
|
||||
font-weight: 700;
|
||||
height: calc(var(--h) * var(--cell-size));
|
||||
justify-content: center;
|
||||
left: calc(var(--x) * var(--cell-size));
|
||||
line-height: 1.2;
|
||||
line-height: 1.08;
|
||||
overflow: hidden;
|
||||
padding: 6px;
|
||||
padding: 4px;
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
top: calc(var(--y) * var(--cell-size));
|
||||
@ -4563,8 +4584,24 @@ body.dragging-location [data-drag-type="location"] {
|
||||
width: calc(var(--w) * var(--cell-size));
|
||||
}
|
||||
|
||||
.floor-plan-block span:first-child {
|
||||
overflow-wrap: anywhere;
|
||||
.floor-plan-block-label {
|
||||
display: -webkit-box;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
overflow-wrap: normal;
|
||||
text-overflow: ellipsis;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
|
||||
.floor-plan-block[style*="--h:2"] .floor-plan-block-label,
|
||||
.floor-plan-block[style*="--h:3"] .floor-plan-block-label,
|
||||
.floor-plan-block[style*="--h:4"] .floor-plan-block-label,
|
||||
.floor-plan-block[style*="--h:5"] .floor-plan-block-label,
|
||||
.floor-plan-block[style*="--h:6"] .floor-plan-block-label,
|
||||
.floor-plan-block[style*="--h:7"] .floor-plan-block-label,
|
||||
.floor-plan-block[style*="--h:8"] .floor-plan-block-label {
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.floor-plan-block.selected {
|
||||
@ -4610,12 +4647,10 @@ body.dragging-location [data-drag-type="location"] {
|
||||
|
||||
.floor-plan-side-panel {
|
||||
align-self: start;
|
||||
background: var(--panel-bg, #fff);
|
||||
border: 1px solid var(--border-color, #d8ded9);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
position: sticky;
|
||||
top: 12px;
|
||||
top: 82px;
|
||||
}
|
||||
|
||||
.floor-plan-side-panel h2 {
|
||||
@ -4623,16 +4658,47 @@ body.dragging-location [data-drag-type="location"] {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.floor-plan-add-block-panel {
|
||||
border-bottom: 1px solid var(--border-color, #d8ded9);
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 14px;
|
||||
.floor-plan-side-panel h3 {
|
||||
font-size: .92rem;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.floor-plan-add-block-panel p {
|
||||
.floor-plan-tool-card {
|
||||
background: var(--panel-bg, #fff);
|
||||
border: 1px solid var(--border-color, #d8ded9);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.floor-plan-tool-card p {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.floor-plan-tool-subsection {
|
||||
border-top: 1px solid var(--border-color, #d8ded9);
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.floor-plan-tool-subsection:first-of-type {
|
||||
border-top: 0;
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.floor-plan-choice-row {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.floor-plan-choice-row label {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
font-size: .88rem;
|
||||
}
|
||||
|
||||
.floor-plan-overlap-warning {
|
||||
background: rgba(180, 85, 68, .12);
|
||||
border: 1px solid rgba(180, 85, 68, .35);
|
||||
@ -4650,6 +4716,13 @@ body.dragging-location [data-drag-type="location"] {
|
||||
linear-gradient(to bottom, rgba(214, 224, 218, .12) 1px, transparent 1px);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .floor-plan-editor-toolbar,
|
||||
.dark .floor-plan-editor-toolbar,
|
||||
[data-bs-theme="dark"] .floor-plan-tool-card,
|
||||
.dark .floor-plan-tool-card {
|
||||
background: var(--panel-bg, #1f2522);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .floor-plan-block,
|
||||
.dark .floor-plan-block {
|
||||
color: #f1f6f3;
|
||||
@ -4682,10 +4755,14 @@ body.dragging-location [data-drag-type="location"] {
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.floor-plan-layout,
|
||||
.floor-plan-floor-header {
|
||||
.floor-plan-editor-toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.floor-plan-grid-wrap {
|
||||
max-height: 62vh;
|
||||
}
|
||||
|
||||
.floor-plan-side-panel {
|
||||
position: static;
|
||||
}
|
||||
|
||||
2
PlotLine/wwwroot/css/site.min.css
vendored
2
PlotLine/wwwroot/css/site.min.css
vendored
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user