diff --git a/frontend/index.html b/frontend/index.html
index 6c87cd8..212de32 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -114,14 +114,19 @@
@@ -133,7 +138,7 @@
diff --git a/frontend/package.json b/frontend/package.json
index 1316acf..cdb17af 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "mapdash-frontend",
"private": true,
- "version": "0.1.0",
+ "version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index d193a4b..71a3ca6 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -27,7 +27,10 @@ async function getJson(url: string): Promise {
export const api = {
meta: (f: Filters): Promise => getJson('/api/meta?' + buildQuery(f)),
- boards: (f: Filters): Promise => getJson('/api/boards?' + buildQuery(f)),
+ // allSurfaces=true adds every inventory surface (occupied + free + never
+ // booked) as a timeline row; the backend forces it on for anonymous visitors.
+ boards: (f: Filters, allSurfaces: boolean): Promise =>
+ getJson('/api/boards?' + buildQuery(f) + '&all_surfaces=' + (allSurfaces ? '1' : '0')),
bookings: (f: Filters): Promise => getJson('/api/bookings?' + buildQuery(f)),
// Map surfaces. The backend ignores the date params here (occupancy is "today"),
// but we reuse buildQuery — extra query params are harmless.
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index bb66ed2..e018b04 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -32,6 +32,7 @@ const el = {
showBrand: document.getElementById('show-brand') as HTMLInputElement,
showCompany: document.getElementById('show-company') as HTMLInputElement,
showCollisions: document.getElementById('show-collisions') as HTMLInputElement,
+ showAllSurfaces: document.getElementById('show-all-surfaces') as HTMLInputElement,
zoomSlider: document.getElementById('zoom-slider') as HTMLInputElement,
zoomLabel: document.getElementById('zoom-label') as HTMLElement,
chartWrap: document.getElementById('chart-wrap') as HTMLElement,
@@ -80,6 +81,9 @@ function applyRoleUi(): void {
// Login / logout button.
el.authBtn.style.display = '';
el.authBtn.textContent = isAuthenticated() ? 'Выйти' : 'Войти';
+ // Anonymous "Войти" is highlighted (filled accent) so it stands out; once
+ // logged in the "Выйти" button reverts to the subtle outline style.
+ el.authBtn.classList.toggle('auth-btn--login', !isAuthenticated());
el.authBtn.onclick = () => (isAuthenticated() ? logout() : login());
}
@@ -143,6 +147,13 @@ function flags(): RenderFlags {
};
}
+// Whether the timeline should list every inventory surface (not just booked
+// ones). The checkbox is manager-only; anonymous visitors always get all
+// surfaces so they can spot what's currently free with no future bookings.
+function allSurfacesParam(): boolean {
+ return !isManager() || el.showAllSurfaces.checked;
+}
+
function currentFilters(): Filters {
return {
cities: cityDropdown.getSelected(),
@@ -292,7 +303,7 @@ async function loadData(fit: boolean): Promise {
loadStart();
try {
const f = currentFilters();
- const [meta, boards, bookings] = await Promise.all([api.meta(f), api.boards(f), api.bookings(f)]);
+ const [meta, boards, bookings] = await Promise.all([api.meta(f), api.boards(f, allSurfacesParam()), api.bookings(f)]);
// Dependent filters: refresh each dropdown's options (selections preserved).
cityDropdown.updateValues(meta.cities);
brandDropdown.updateValues(meta.brands);
@@ -302,7 +313,7 @@ async function loadData(fit: boolean): Promise {
lastBoards = boards;
lastBookings = bookings;
view.render(boards, bookings, fit, flags());
- el.status.innerHTML = `Поверхности: ${boards.length}
Брони: ${bookings.length}`;
+ el.status.textContent = `Поверхности: ${boards.length}`;
rebuildDecorMenu(); // status list may have changed
renderChips();
filtersToUrl();
@@ -374,6 +385,8 @@ function scheduleReload(): void {
for (const cb of [el.showBrand, el.showCompany, el.showCollisions]) {
cb.addEventListener('change', () => view.render(lastBoards, lastBookings, false, flags()));
}
+// "All surfaces" changes which rows exist (server-side), so it must refetch.
+el.showAllSurfaces.addEventListener('change', scheduleReload);
cityDropdown.onChange(scheduleReload);
brandDropdown.onChange(scheduleReload);
dimensionDropdown.onChange(scheduleReload);
diff --git a/frontend/src/map.ts b/frontend/src/map.ts
index cf24670..3e37ecf 100644
--- a/frontend/src/map.ts
+++ b/frontend/src/map.ts
@@ -229,7 +229,9 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
// Live legend counts (react to the current filter).
if (legendFree) legendFree.textContent = `свободно: ${free}`;
if (legendBusy) legendBusy.textContent = `занято: ${busy}`;
- els.counter.textContent = `всего: ${withCoords.length}`;
+ // Total counts the whole inventory, including surfaces without coordinates
+ // (which can't be drawn but are still surfaces — listed under "без координат").
+ els.counter.textContent = `всего: ${surfaces.length}`;
// Actionable "no coordinates" badge + list.
if (noCoordsBtn) {
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index d802aa1..7c87bfb 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -163,6 +163,7 @@ input::placeholder { color: var(--text-muted); }
.zoom-row { display: flex; align-items: center; gap: 8px; }
.checks { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; }
.checks .checkbox-field { align-self: flex-start; }
+.checks-row { display: flex; flex-direction: row; align-items: center; gap: 18px; }
/* Let the brand block fill the header height so the counters can sit at the
bottom, level with the date fields / mode switch. */
@@ -329,6 +330,10 @@ html.role-pending .manager-only { display: none !important; }
transition: border-color var(--transition), background var(--transition);
}
.auth-btn:hover { border-color: var(--border-strong); background: var(--surface); }
+/* Anonymous "Войти": filled accent for visibility; hover inverts to white with
+ an accent outline. */
+.auth-btn--login { background: var(--accent); border-color: var(--accent); color: #fff; }
+.auth-btn--login:hover { background: var(--bg); border-color: var(--accent); color: var(--accent); }
/* Dark theme temporarily hidden — toggle stays in the DOM, just not shown.
Re-enable by removing this rule + restoring the head script in index.html. */
.theme-toggle { display: none; }
diff --git a/frontend/src/timeline.ts b/frontend/src/timeline.ts
index 2eb022d..5dd397d 100644
--- a/frontend/src/timeline.ts
+++ b/frontend/src/timeline.ts
@@ -224,10 +224,20 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
el.tooltip.style.display = 'none';
});
document.addEventListener('mousemove', (e) => {
- if (hoveredItemId !== null) {
- el.tooltip.style.left = e.clientX + 12 + 'px';
- el.tooltip.style.top = e.clientY + 12 + 'px';
- }
+ if (hoveredItemId === null) return;
+ const pad = 12;
+ const tw = el.tooltip.offsetWidth;
+ const th = el.tooltip.offsetHeight;
+ let left = e.clientX + pad;
+ let top = e.clientY + pad;
+ // Flip above the cursor when the tooltip would spill past the bottom edge.
+ if (top + th > window.innerHeight - 8) top = e.clientY - pad - th;
+ // Flip to the left when it would spill past the right edge.
+ if (left + tw > window.innerWidth - 8) left = e.clientX - pad - tw;
+ if (top < 8) top = 8;
+ if (left < 8) left = 8;
+ el.tooltip.style.left = left + 'px';
+ el.tooltip.style.top = top + 'px';
});
// ---- row hover highlight ----
diff --git a/main.py b/main.py
index 71d98ef..4e51328 100644
--- a/main.py
+++ b/main.py
@@ -216,24 +216,53 @@ async def meta(role: str = Depends(get_role), city: str = "", dimension: str = "
@app.get("/api/boards")
-async def boards(role: str = Depends(get_role), city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = "", date_start: str = "", date_end: str = ""):
+async def boards(role: str = Depends(get_role), city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = "", date_start: str = "", date_end: str = "", all_surfaces: bool = False):
# Board inventory (address/city/dimension) is visible to everyone; anonymous
- # just can't filter by the hidden facets.
+ # just can't filter by the hidden facets. The "all surfaces" default for
+ # anonymous is driven by the client (allSurfacesParam) — the data here is
+ # non-sensitive inventory, so no server-side force is needed.
if role != "manager":
brand = manager = status = ""
params = parse_filters(city, dimension, brand, manager, status, search, date_start, date_end)
- where = build_where(city=True, dimension=True, brand=True, manager=True, status=True, search=True, date_start=bool(params["date_start"]), date_end=bool(params["date_end"]))
- sql = f"""
- SELECT
- board_id,
- argMax(address, last_activity) AS board_address,
- argMax(city, last_activity) AS board_city,
- argMax(dimension, last_activity) AS board_dimension
- FROM default.pf_board
- WHERE {where}
- GROUP BY board_id
- ORDER BY board_address
- """
+ # A "booking facet" (brand/manager/status) narrows to specific bookings, so
+ # when one is active the timeline shows only the relevant booked surfaces
+ # regardless of the toggle. Dates are a *window*, not an identity filter:
+ # they narrow the bars, not the surface set, so free surfaces stay visible.
+ booking_facet = bool(params["brands"] or params["managers"] or params["statuses"])
+ if all_surfaces and not booking_facet:
+ # Full inventory from board_info. Union pf_board so a just-booked surface
+ # not yet in the inventory snapshot still gets a row (board_info wins the
+ # label via prio). Only the surface facets (city/dimension/search) apply.
+ surface_where = " AND ".join(["char_length(city) > 0", CITY_COND, DIM_COND, SEARCH_COND])
+ sql = f"""
+ SELECT
+ board_id,
+ argMax(board_address, prio) AS board_address,
+ argMax(board_city, prio) AS board_city,
+ argMax(board_dimension, prio) AS board_dimension
+ FROM (
+ SELECT board_id, address AS board_address, city AS board_city, dimension AS board_dimension, 1 AS prio
+ FROM default.board_info FINAL WHERE {surface_where}
+ UNION ALL
+ SELECT board_id, address AS board_address, city AS board_city, dimension AS board_dimension, 0 AS prio
+ FROM default.pf_board FINAL WHERE {surface_where}
+ )
+ GROUP BY board_id
+ ORDER BY board_city, board_address
+ """
+ else:
+ where = build_where(city=True, dimension=True, brand=True, manager=True, status=True, search=True, date_start=bool(params["date_start"]), date_end=bool(params["date_end"]))
+ sql = f"""
+ SELECT
+ board_id,
+ argMax(address, last_activity) AS board_address,
+ argMax(city, last_activity) AS board_city,
+ argMax(dimension, last_activity) AS board_dimension
+ FROM default.pf_board
+ WHERE {where}
+ GROUP BY board_id
+ ORDER BY board_address
+ """
rows = await ch_query(sql, params)
return rows