Implemented the Floor Plans UX/data-model refinement pass.

This commit is contained in:
Nick Beckley 2026-06-20 20:32:59 +01:00
parent 030a4a10da
commit b2ac7b5eeb
6 changed files with 324 additions and 16 deletions

View File

@ -58,13 +58,25 @@ public sealed class FloorPlansController(IFloorPlanService floorPlans) : Control
[ValidateAntiForgeryToken]
public async Task<IActionResult> SaveLayout(FloorPlanEditorViewModel model)
{
var isAsyncSave = string.Equals(Request.Headers["X-Requested-With"], "XMLHttpRequest", StringComparison.OrdinalIgnoreCase);
try
{
await floorPlans.SaveLayoutAsync(model);
if (isAsyncSave)
{
return Json(new { ok = true, message = "Saved" });
}
TempData["FloorPlanMessage"] = "Layout saved.";
}
catch (InvalidOperationException ex)
{
if (isAsyncSave)
{
Response.StatusCode = StatusCodes.Status400BadRequest;
return Json(new { ok = false, message = ex.Message });
}
TempData["FloorPlanError"] = ex.Message;
}

View File

@ -1170,6 +1170,7 @@ public sealed class FloorPlanFloor
public sealed class FloorPlanBlock
{
// Future L-shape support: ShapeType plus FootWidthCells, FootHeightCells, and CornerPosition can extend rectangles without a freeform polygon editor.
public int FloorPlanBlockID { get; set; }
public int FloorPlanFloorID { get; set; }
public int LocationID { get; set; }

View File

@ -0,0 +1,37 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
CREATE OR ALTER PROCEDURE dbo.FloorPlan_Delete
@FloorPlanID int
AS
BEGIN
SET NOCOUNT ON;
DELETE dbo.FloorPlans
WHERE FloorPlanID = @FloorPlanID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.FloorPlanFloor_Delete
@FloorPlanFloorID int
AS
BEGIN
SET NOCOUNT ON;
DELETE dbo.FloorPlanFloors
WHERE FloorPlanFloorID = @FloorPlanFloorID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.FloorPlanBlock_Delete
@FloorPlanBlockID int
AS
BEGIN
SET NOCOUNT ON;
DELETE dbo.FloorPlanBlocks
WHERE FloorPlanBlockID = @FloorPlanBlockID;
END;
GO

View File

@ -34,7 +34,7 @@
<div class="alert alert-danger">@error</div>
}
<section class="edit-panel floor-plan-meta">
<section class="edit-panel floor-plan-meta floor-plan-meta-wide">
<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" />
@ -114,8 +114,9 @@ else
}
</div>
<div class="button-row">
<span class="floor-plan-save-status" data-save-status>Saved</span>
<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>
<button class="btn btn-primary btn-sm" type="submit" data-save-layout-button>Save layout</button>
</div>
</section>
@ -154,7 +155,14 @@ else
</section>
<aside class="floor-plan-side-panel">
<section class="floor-plan-tool-card">
<div class="floor-plan-toolbox" data-toolbox>
<div class="floor-plan-toolbox-tabs" role="tablist" aria-label="Floor plan tools">
<button type="button" class="floor-plan-toolbox-tab active" id="floor-plan-tool-tab-add" role="tab" aria-selected="true" aria-controls="floor-plan-tool-add" data-toolbox-tab="add">Add block</button>
<button type="button" class="floor-plan-toolbox-tab" id="floor-plan-tool-tab-selected" role="tab" aria-selected="false" aria-controls="floor-plan-tool-selected" data-toolbox-tab="selected">Selected block</button>
<button type="button" class="floor-plan-toolbox-tab" id="floor-plan-tool-tab-floors" role="tab" aria-selected="false" aria-controls="floor-plan-tool-floors" data-toolbox-tab="floors">Floors</button>
</div>
<div class="floor-plan-toolbox-body">
<section class="floor-plan-tool-card active" id="floor-plan-tool-add" role="tabpanel" aria-labelledby="floor-plan-tool-tab-add" data-toolbox-panel="add">
<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" />
@ -202,7 +210,7 @@ else
<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">
<section class="floor-plan-tool-card" id="floor-plan-tool-selected" role="tabpanel" aria-labelledby="floor-plan-tool-tab-selected" data-toolbox-panel="selected" hidden>
<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>
@ -258,7 +266,7 @@ else
}
</section>
<section class="floor-plan-tool-card">
<section class="floor-plan-tool-card" id="floor-plan-tool-floors" role="tabpanel" aria-labelledby="floor-plan-tool-tab-floors" data-toolbox-panel="floors" hidden>
<h2>Floors</h2>
<div class="floor-plan-tool-subsection">
<h3>Add floor</h3>
@ -314,11 +322,13 @@ else
}
</div>
</section>
</div>
</div>
</aside>
</div>
<div class="button-row mt-3">
<button class="btn btn-primary" type="submit">Save layout</button>
<button class="btn btn-primary" type="submit" data-save-layout-button>Save layout</button>
</div>
</form>
@ -399,14 +409,125 @@ else
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 addBlockType = document.querySelector("#add-block-type");
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]");
const toolboxTabs = [...editor.querySelectorAll("[data-toolbox-tab]")];
const toolboxPanels = [...editor.querySelectorAll("[data-toolbox-panel]")];
const saveStatus = editor.querySelector("[data-save-status]");
const saveButtons = [...editor.querySelectorAll("[data-save-layout-button]")];
const initialSelectedBlockId = "@selectedBlockId";
const saveStatusText = {
saved: "Saved",
dirty: "Unsaved changes",
saving: "Saving...",
failed: "Save failed"
};
const sizePresets = {
Room: [
["1x1", "Small Room 1 x 1"],
["2x1", "Medium Room 2 x 1"],
["2x2", "Large Room 2 x 2"],
["3x1", "Long Room 3 x 1"],
["3x2", "Grand Room 3 x 2"]
],
Corridor: [
["1x2", "Short Corridor 1 x 2"],
["1x4", "Long Corridor 1 x 4"],
["2x3", "Wide Corridor 2 x 3"],
["4x1", "Horizontal Corridor 4 x 1"],
["5x1", "Gallery 5 x 1"]
],
OpenSpace: [
["4x4", "Courtyard 4 x 4"],
["6x5", "Large Courtyard 6 x 5"],
["6x4", "Garden Area 6 x 4"],
["2x2", "Lightwell 2 x 2"]
],
Exterior: [
["3x3", "Yard 3 x 3"],
["4x2", "Drive 4 x 2"],
["6x4", "Garden 6 x 4"],
["4x1", "Terrace 4 x 1"]
],
Other: [
["1x1", "Small 1 x 1"],
["2x1", "Medium 2 x 1"],
["2x2", "Large 2 x 2"],
["3x2", "Wide 3 x 2"]
]
};
let selectedBlock = null;
let pointerState = null;
let saveTimer = null;
let saveInFlight = false;
let saveAgain = false;
function setToolboxTab(tabName) {
toolboxTabs.forEach(tab => {
const active = tab.dataset.toolboxTab === tabName;
tab.classList.toggle("active", active);
tab.setAttribute("aria-selected", active ? "true" : "false");
});
toolboxPanels.forEach(panel => {
const active = panel.dataset.toolboxPanel === tabName;
panel.classList.toggle("active", active);
panel.hidden = !active;
});
}
function setSaveStatus(state, message) {
if (!saveStatus) return;
saveStatus.dataset.saveState = state;
saveStatus.textContent = message || saveStatusText[state] || state;
}
function markDirty() {
setSaveStatus("dirty");
window.clearTimeout(saveTimer);
saveTimer = window.setTimeout(() => saveLayoutAsync(), 1000);
}
async function saveLayoutAsync() {
window.clearTimeout(saveTimer);
if (saveInFlight) {
saveAgain = true;
return;
}
saveInFlight = true;
saveButtons.forEach(button => button.disabled = true);
setSaveStatus("saving");
try {
const response = await fetch(editor.action, {
method: "POST",
body: new FormData(editor),
headers: {
"Accept": "application/json",
"X-Requested-With": "XMLHttpRequest"
},
credentials: "same-origin"
});
const result = await response.json().catch(() => ({}));
if (!response.ok || result.ok === false) {
throw new Error(result.message || "Unable to save layout.");
}
setSaveStatus("saved");
} catch (error) {
setSaveStatus("failed", error.message || "Save failed");
} finally {
saveInFlight = false;
saveButtons.forEach(button => button.disabled = false);
if (saveAgain) {
saveAgain = false;
markDirty();
}
}
}
function setActiveFloor(floorId) {
tabs.forEach(tab => tab.classList.toggle("active", tab.dataset.floorTab === floorId));
@ -454,6 +575,7 @@ else
const panel = editor.querySelector(`[data-block-editor="${block.dataset.block}"]`);
if (panel) panel.classList.remove("d-none");
if (noSelection) noSelection.classList.add("d-none");
setToolboxTab("selected");
}
function fieldsFor(block) {
@ -538,7 +660,33 @@ else
warning?.classList.toggle("d-none", !overlaps);
}
function applySizePreset() {
const [width, height] = (sizePreset?.value || "2x1").split("x");
if (addBlockWidth) addBlockWidth.value = width || "2";
if (addBlockHeight) addBlockHeight.value = height || "1";
}
function updateSizePresets() {
if (!sizePreset) return;
const presets = sizePresets[addBlockType?.value || "Room"] || sizePresets.Room;
sizePreset.innerHTML = "";
presets.forEach(([value, label], index) => {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
option.selected = index === 0;
sizePreset.append(option);
});
applySizePreset();
}
tabs.forEach(tab => tab.addEventListener("click", () => setActiveFloor(tab.dataset.floorTab)));
toolboxTabs.forEach(tab => tab.addEventListener("click", () => setToolboxTab(tab.dataset.toolboxTab)));
editor.addEventListener("submit", event => {
event.preventDefault();
saveLayoutAsync();
});
blocks.forEach(block => {
block.addEventListener("pointerdown", event => {
@ -574,13 +722,20 @@ else
}
});
editor.addEventListener("pointerup", () => pointerState = null);
editor.addEventListener("pointercancel", () => pointerState = null);
editor.addEventListener("pointerup", () => {
if (pointerState) markDirty();
pointerState = null;
});
editor.addEventListener("pointercancel", () => {
if (pointerState) markDirty();
pointerState = null;
});
editor.querySelectorAll("[data-block-field='x'],[data-block-field='y'],[data-block-field='w'],[data-block-field='h']").forEach(input => {
input.addEventListener("change", () => {
if (!selectedBlock) return;
writeBlock(selectedBlock, readBlock(selectedBlock));
markDirty();
});
});
@ -592,15 +747,20 @@ else
.filter(name => !name.startsWith("floor-plan-block--"))
.concat(`floor-plan-block--${select.value.toLowerCase()}`)
.join(" ");
markDirty();
});
});
sizePreset?.addEventListener("change", () => {
const [width, height] = (sizePreset.value || "2x1").split("x");
if (addBlockWidth) addBlockWidth.value = width || "2";
if (addBlockHeight) addBlockHeight.value = height || "1";
editor.querySelectorAll("input, select, textarea").forEach(input => {
if (input.matches("[form='floor-plan-add-block-form'], [form='floor-plan-add-floor-form']")) return;
if (input.matches("[data-block-field='x'],[data-block-field='y'],[data-block-field='w'],[data-block-field='h'],[data-block-field='type']")) return;
const eventName = input.tagName === "TEXTAREA" || input.type === "text" ? "input" : "change";
input.addEventListener(eventName, markDirty);
});
sizePreset?.dispatchEvent(new Event("change"));
sizePreset?.addEventListener("change", applySizePreset);
addBlockType?.addEventListener("change", updateSizePresets);
updateSizePresets();
addBlockModes.forEach(input => input.addEventListener("change", updateAddBlockMode));
updateAddBlockMode();

View File

@ -4479,6 +4479,10 @@ body.dragging-location [data-drag-type="location"] {
gap: 14px;
}
.floor-plan-meta-wide {
max-width: none;
}
.floor-plan-tabs {
display: flex;
flex-wrap: wrap;
@ -4527,6 +4531,34 @@ body.dragging-location [data-drag-type="location"] {
line-height: 1.1;
}
.floor-plan-save-status {
border: 1px solid rgba(47, 111, 99, .2);
border-radius: 999px;
color: var(--plotline-accent-dark, #22534a);
background: rgba(47, 111, 99, .08);
font-size: .78rem;
font-weight: 800;
padding: 4px 9px;
}
.floor-plan-save-status[data-save-state="dirty"] {
border-color: rgba(184, 118, 43, .32);
color: #7b4b12;
background: rgba(251, 236, 211, .72);
}
.floor-plan-save-status[data-save-state="saving"] {
border-color: rgba(51, 117, 155, .3);
color: #245b78;
background: rgba(233, 243, 248, .82);
}
.floor-plan-save-status[data-save-state="failed"] {
border-color: rgba(163, 61, 61, .32);
color: #8a3434;
background: rgba(255, 232, 232, .9);
}
.floor-plan-floor {
display: none;
}
@ -4647,8 +4679,6 @@ body.dragging-location [data-drag-type="location"] {
.floor-plan-side-panel {
align-self: start;
display: grid;
gap: 12px;
position: sticky;
top: 82px;
}
@ -4663,6 +4693,47 @@ body.dragging-location [data-drag-type="location"] {
margin: 0 0 8px;
}
.floor-plan-toolbox {
background: var(--panel-bg, #fff);
border: 1px solid var(--border-color, #d8ded9);
border-radius: 8px;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
max-height: calc(100vh - 250px);
min-height: min(58vh, 520px);
overflow: hidden;
}
.floor-plan-toolbox-tabs {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
border-bottom: 1px solid var(--border-color, #d8ded9);
}
.floor-plan-toolbox-tab {
background: transparent;
border: 0;
border-right: 1px solid var(--border-color, #d8ded9);
color: inherit;
font-size: .82rem;
font-weight: 800;
padding: 10px 8px;
}
.floor-plan-toolbox-tab:last-child {
border-right: 0;
}
.floor-plan-toolbox-tab.active {
background: rgba(47, 111, 99, .1);
color: var(--plotline-accent-dark, #22534a);
}
.floor-plan-toolbox-body {
min-height: 0;
overflow: auto;
}
.floor-plan-tool-card {
background: var(--panel-bg, #fff);
border: 1px solid var(--border-color, #d8ded9);
@ -4670,6 +4741,16 @@ body.dragging-location [data-drag-type="location"] {
padding: 14px;
}
.floor-plan-toolbox .floor-plan-tool-card {
background: transparent;
border: 0;
border-radius: 0;
}
.floor-plan-tool-card[hidden] {
display: none;
}
.floor-plan-tool-card p {
margin-bottom: 10px;
}
@ -4718,11 +4799,24 @@ body.dragging-location [data-drag-type="location"] {
[data-bs-theme="dark"] .floor-plan-editor-toolbar,
.dark .floor-plan-editor-toolbar,
[data-bs-theme="dark"] .floor-plan-toolbox,
.dark .floor-plan-toolbox,
[data-bs-theme="dark"] .floor-plan-tool-card,
.dark .floor-plan-tool-card {
background: var(--panel-bg, #1f2522);
}
[data-bs-theme="dark"] .floor-plan-toolbox .floor-plan-tool-card,
.dark .floor-plan-toolbox .floor-plan-tool-card {
background: transparent;
}
[data-bs-theme="dark"] .floor-plan-toolbox-tab.active,
.dark .floor-plan-toolbox-tab.active {
background: rgba(212, 154, 98, .16);
color: var(--plotline-accent-dark, #f0c99d);
}
[data-bs-theme="dark"] .floor-plan-block,
.dark .floor-plan-block {
color: #f1f6f3;
@ -4766,4 +4860,8 @@ body.dragging-location [data-drag-type="location"] {
.floor-plan-side-panel {
position: static;
}
.floor-plan-toolbox {
max-height: none;
}
}

File diff suppressed because one or more lines are too long