feat: all-surfaces timeline + UI polish (v0.2.0)
Timeline can now list the entire inventory (occupied + free + never booked), not just booked surfaces. New manager-only "Все поверхности" checkbox (on by default; forced on and hidden for anonymous visitors so they can spot what is currently free with no future bookings). /api/boards gains all_surfaces: sources rows from board_info and unions pf_board so a just-booked surface not yet in the inventory snapshot still gets a row. Surface facets (city/dimension/search) narrow rows; booking facets (brand/manager/status) narrow to booked surfaces; dates are a window that narrows bars, not the surface set. UI polish: - timeline tooltip flips up/left near viewport edges - anonymous "Войти" button filled accent, inverts to white-on-accent hover - appearance button uses a gear icon instead of "+" - header counter shows only "Поверхности: N" - map "всего" counter includes surfaces without coordinates Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
341bc42778
commit
e1798a36af
@ -114,14 +114,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="checks manager-only">
|
<div class="checks manager-only">
|
||||||
|
<div class="checks-row">
|
||||||
<div class="field checkbox-field">
|
<div class="field checkbox-field">
|
||||||
<label for="show-brand"><input type="checkbox" id="show-brand" checked /> Бренд</label>
|
<label for="show-brand"><input type="checkbox" id="show-brand" checked /> Бренд</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field checkbox-field">
|
||||||
|
<label for="show-company"><input type="checkbox" id="show-company" /> Компания</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="field checkbox-field">
|
<div class="field checkbox-field">
|
||||||
<label for="show-collisions"><input type="checkbox" id="show-collisions" checked /> Коллизии размещения</label>
|
<label for="show-collisions"><input type="checkbox" id="show-collisions" checked /> Коллизии размещения</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="field checkbox-field">
|
<div class="field checkbox-field">
|
||||||
<label for="show-company"><input type="checkbox" id="show-company" /> Компания</label>
|
<label for="show-all-surfaces"><input type="checkbox" id="show-all-surfaces" checked /> Все поверхности</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -133,7 +138,7 @@
|
|||||||
|
|
||||||
<!-- Appearance ("+") button, pinned to the header's bottom-right corner -->
|
<!-- Appearance ("+") button, pinned to the header's bottom-right corner -->
|
||||||
<div class="decor-wrap manager-only">
|
<div class="decor-wrap manager-only">
|
||||||
<button type="button" class="decor-btn" id="decor-btn" aria-label="Оформление" data-tip="Оформление">+</button>
|
<button type="button" class="decor-btn" id="decor-btn" aria-label="Оформление" data-tip="Оформление"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
|
||||||
<div class="decor-menu" id="decor-menu"></div>
|
<div class="decor-menu" id="decor-menu"></div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mapdash-frontend",
|
"name": "mapdash-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@ -27,7 +27,10 @@ async function getJson<T>(url: string): Promise<T> {
|
|||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
meta: (f: Filters): Promise<Meta> => getJson<Meta>('/api/meta?' + buildQuery(f)),
|
meta: (f: Filters): Promise<Meta> => getJson<Meta>('/api/meta?' + buildQuery(f)),
|
||||||
boards: (f: Filters): Promise<Board[]> => getJson<Board[]>('/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<Board[]> =>
|
||||||
|
getJson<Board[]>('/api/boards?' + buildQuery(f) + '&all_surfaces=' + (allSurfaces ? '1' : '0')),
|
||||||
bookings: (f: Filters): Promise<Booking[]> => getJson<Booking[]>('/api/bookings?' + buildQuery(f)),
|
bookings: (f: Filters): Promise<Booking[]> => getJson<Booking[]>('/api/bookings?' + buildQuery(f)),
|
||||||
// Map surfaces. The backend ignores the date params here (occupancy is "today"),
|
// Map surfaces. The backend ignores the date params here (occupancy is "today"),
|
||||||
// but we reuse buildQuery — extra query params are harmless.
|
// but we reuse buildQuery — extra query params are harmless.
|
||||||
|
|||||||
@ -32,6 +32,7 @@ const el = {
|
|||||||
showBrand: document.getElementById('show-brand') as HTMLInputElement,
|
showBrand: document.getElementById('show-brand') as HTMLInputElement,
|
||||||
showCompany: document.getElementById('show-company') as HTMLInputElement,
|
showCompany: document.getElementById('show-company') as HTMLInputElement,
|
||||||
showCollisions: document.getElementById('show-collisions') as HTMLInputElement,
|
showCollisions: document.getElementById('show-collisions') as HTMLInputElement,
|
||||||
|
showAllSurfaces: document.getElementById('show-all-surfaces') as HTMLInputElement,
|
||||||
zoomSlider: document.getElementById('zoom-slider') as HTMLInputElement,
|
zoomSlider: document.getElementById('zoom-slider') as HTMLInputElement,
|
||||||
zoomLabel: document.getElementById('zoom-label') as HTMLElement,
|
zoomLabel: document.getElementById('zoom-label') as HTMLElement,
|
||||||
chartWrap: document.getElementById('chart-wrap') as HTMLElement,
|
chartWrap: document.getElementById('chart-wrap') as HTMLElement,
|
||||||
@ -80,6 +81,9 @@ function applyRoleUi(): void {
|
|||||||
// Login / logout button.
|
// Login / logout button.
|
||||||
el.authBtn.style.display = '';
|
el.authBtn.style.display = '';
|
||||||
el.authBtn.textContent = isAuthenticated() ? 'Выйти' : 'Войти';
|
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());
|
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 {
|
function currentFilters(): Filters {
|
||||||
return {
|
return {
|
||||||
cities: cityDropdown.getSelected(),
|
cities: cityDropdown.getSelected(),
|
||||||
@ -292,7 +303,7 @@ async function loadData(fit: boolean): Promise<void> {
|
|||||||
loadStart();
|
loadStart();
|
||||||
try {
|
try {
|
||||||
const f = currentFilters();
|
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).
|
// Dependent filters: refresh each dropdown's options (selections preserved).
|
||||||
cityDropdown.updateValues(meta.cities);
|
cityDropdown.updateValues(meta.cities);
|
||||||
brandDropdown.updateValues(meta.brands);
|
brandDropdown.updateValues(meta.brands);
|
||||||
@ -302,7 +313,7 @@ async function loadData(fit: boolean): Promise<void> {
|
|||||||
lastBoards = boards;
|
lastBoards = boards;
|
||||||
lastBookings = bookings;
|
lastBookings = bookings;
|
||||||
view.render(boards, bookings, fit, flags());
|
view.render(boards, bookings, fit, flags());
|
||||||
el.status.innerHTML = `Поверхности: ${boards.length}<br>Брони: ${bookings.length}`;
|
el.status.textContent = `Поверхности: ${boards.length}`;
|
||||||
rebuildDecorMenu(); // status list may have changed
|
rebuildDecorMenu(); // status list may have changed
|
||||||
renderChips();
|
renderChips();
|
||||||
filtersToUrl();
|
filtersToUrl();
|
||||||
@ -374,6 +385,8 @@ function scheduleReload(): void {
|
|||||||
for (const cb of [el.showBrand, el.showCompany, el.showCollisions]) {
|
for (const cb of [el.showBrand, el.showCompany, el.showCollisions]) {
|
||||||
cb.addEventListener('change', () => view.render(lastBoards, lastBookings, false, flags()));
|
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);
|
cityDropdown.onChange(scheduleReload);
|
||||||
brandDropdown.onChange(scheduleReload);
|
brandDropdown.onChange(scheduleReload);
|
||||||
dimensionDropdown.onChange(scheduleReload);
|
dimensionDropdown.onChange(scheduleReload);
|
||||||
|
|||||||
@ -229,7 +229,9 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
|
|||||||
// Live legend counts (react to the current filter).
|
// Live legend counts (react to the current filter).
|
||||||
if (legendFree) legendFree.textContent = `свободно: ${free}`;
|
if (legendFree) legendFree.textContent = `свободно: ${free}`;
|
||||||
if (legendBusy) legendBusy.textContent = `занято: ${busy}`;
|
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.
|
// Actionable "no coordinates" badge + list.
|
||||||
if (noCoordsBtn) {
|
if (noCoordsBtn) {
|
||||||
|
|||||||
@ -163,6 +163,7 @@ input::placeholder { color: var(--text-muted); }
|
|||||||
.zoom-row { display: flex; align-items: center; gap: 8px; }
|
.zoom-row { display: flex; align-items: center; gap: 8px; }
|
||||||
.checks { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; }
|
.checks { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; }
|
||||||
.checks .checkbox-field { align-self: flex-start; }
|
.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
|
/* Let the brand block fill the header height so the counters can sit at the
|
||||||
bottom, level with the date fields / mode switch. */
|
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);
|
transition: border-color var(--transition), background var(--transition);
|
||||||
}
|
}
|
||||||
.auth-btn:hover { border-color: var(--border-strong); background: var(--surface); }
|
.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.
|
/* 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. */
|
Re-enable by removing this rule + restoring the head script in index.html. */
|
||||||
.theme-toggle { display: none; }
|
.theme-toggle { display: none; }
|
||||||
|
|||||||
@ -224,10 +224,20 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
|
|||||||
el.tooltip.style.display = 'none';
|
el.tooltip.style.display = 'none';
|
||||||
});
|
});
|
||||||
document.addEventListener('mousemove', (e) => {
|
document.addEventListener('mousemove', (e) => {
|
||||||
if (hoveredItemId !== null) {
|
if (hoveredItemId === null) return;
|
||||||
el.tooltip.style.left = e.clientX + 12 + 'px';
|
const pad = 12;
|
||||||
el.tooltip.style.top = e.clientY + 12 + 'px';
|
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 ----
|
// ---- row hover highlight ----
|
||||||
|
|||||||
33
main.py
33
main.py
@ -216,12 +216,41 @@ async def meta(role: str = Depends(get_role), city: str = "", dimension: str = "
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/boards")
|
@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
|
# 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":
|
if role != "manager":
|
||||||
brand = manager = status = ""
|
brand = manager = status = ""
|
||||||
params = parse_filters(city, dimension, brand, manager, status, search, date_start, date_end)
|
params = parse_filters(city, dimension, brand, manager, status, search, date_start, date_end)
|
||||||
|
# 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"]))
|
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"""
|
sql = f"""
|
||||||
SELECT
|
SELECT
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user