diff --git a/frontend/index.html b/frontend/index.html index 9de3d74..c08ab9f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -76,6 +76,16 @@ +
+ + +
diff --git a/frontend/src/api.ts b/frontend/src/api.ts index e2480fd..c3fbe7d 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -8,6 +8,7 @@ function buildQuery(f: Filters): string { dimension: f.dimensions.join(','), brand: f.brands.join(','), manager: f.managers.join(','), + status: f.statuses.join(','), search: f.search, date_start: f.dateStart, date_end: f.dateEnd, diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 47f4272..d20db05 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -45,6 +45,7 @@ const cityDropdown = createDropdown('city'); const brandDropdown = createDropdown('brand'); const dimensionDropdown = createDropdown('dimension'); const managerDropdown = createDropdown('manager'); +const statusDropdown = createDropdown('status'); const view = createTimelineView( { timelineEl: el.timelineEl, @@ -78,6 +79,7 @@ function currentFilters(): Filters { dimensions: dimensionDropdown.getSelected(), brands: brandDropdown.getSelected(), managers: managerDropdown.getSelected(), + statuses: statusDropdown.getSelected(), search: el.search.value.trim(), dateStart: el.dateStart.value, dateEnd: el.dateEnd.value, @@ -116,6 +118,7 @@ async function loadData(fit: boolean): Promise { brandDropdown.updateValues(meta.brands); dimensionDropdown.updateValues(meta.dimensions); managerDropdown.updateValues(meta.managers); + statusDropdown.updateValues(meta.statuses); lastBoards = boards; lastBookings = bookings; view.render(boards, bookings, fit, flags()); @@ -131,7 +134,7 @@ async function loadData(fit: boolean): Promise { let mapLoadedKey = ''; function facetKey(): string { const f = currentFilters(); - return JSON.stringify([f.cities, f.dimensions, f.brands, f.search]); + return JSON.stringify([f.cities, f.dimensions, f.brands, f.managers, f.statuses, f.search]); } function mapVisible(): boolean { return !!viewModes && viewModes.getMode() !== 'timeline'; @@ -187,6 +190,7 @@ cityDropdown.onChange(scheduleReload); brandDropdown.onChange(scheduleReload); dimensionDropdown.onChange(scheduleReload); managerDropdown.onChange(scheduleReload); +statusDropdown.onChange(scheduleReload); el.search.addEventListener('input', scheduleReload); el.dateStart.addEventListener('change', scheduleReload); el.dateEnd.addEventListener('change', scheduleReload); @@ -202,6 +206,7 @@ async function init(): Promise { brandDropdown.setValues([], 'Все бренды'); dimensionDropdown.setValues([], 'Все размеры'); managerDropdown.setValues([], 'Все менеджеры'); + statusDropdown.setValues([], 'Все статусы'); rebuildDecorMenu(); view.applyLayout(); await loadData(true); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index c1e60f2..91613a9 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -8,6 +8,7 @@ export interface Meta { dimensions: string[]; brands: string[]; managers: string[]; + statuses: string[]; } /** One row of GET /api/boards (grouped per board_id). */ @@ -35,6 +36,7 @@ export interface Filters { dimensions: string[]; brands: string[]; managers: string[]; + statuses: string[]; search: string; dateStart: string; // YYYY-MM-DD or '' — start_date >= dateStart dateEnd: string; // YYYY-MM-DD or '' — end_date <= dateEnd diff --git a/main.py b/main.py index a54eea7..b78d354 100644 --- a/main.py +++ b/main.py @@ -20,6 +20,7 @@ BRAND_COND = "(length({brands:Array(String)}) = 0 OR brand IN {brands:Array(Stri # manager is Array(String) in pf_board (a task may have several managers), so we # match with hasAny: keep the row if any selected manager is among its managers. MANAGER_COND = "(length({managers:Array(String)}) = 0 OR hasAny(manager, {managers:Array(String)}))" +STATUS_COND = "(length({statuses:Array(String)}) = 0 OR task_status IN {statuses:Array(String)})" SEARCH_COND = ( "({search:String} = ''" " OR positionCaseInsensitive(address, {search:String}) > 0" @@ -46,9 +47,15 @@ MAP_MANAGER_COND = ( "(length({managers:Array(String)}) = 0" " OR board_key IN (SELECT board_key FROM default.pf_board WHERE hasAny(manager, {managers:Array(String)})))" ) +# Same idea for the status facet on the map: keep surfaces that have at least one +# booking whose task_status is among the selected values. +MAP_STATUS_COND = ( + "(length({statuses:Array(String)}) = 0" + " OR board_key IN (SELECT board_key FROM default.pf_board WHERE task_status IN {statuses:Array(String)}))" +) -def build_where(city=False, dimension=False, brand=False, manager=False, search=False, date_start=False, date_end=False): +def build_where(city=False, dimension=False, brand=False, manager=False, status=False, search=False, date_start=False, date_end=False): # Faceted WHERE: include only the requested facet conditions. For the # dependent-filter option lists we exclude a facet's own condition so its # available values reflect the OTHER filters (all-but-self). @@ -61,6 +68,8 @@ def build_where(city=False, dimension=False, brand=False, manager=False, search= parts.append(BRAND_COND) if manager: parts.append(MANAGER_COND) + if status: + parts.append(STATUS_COND) if search: parts.append(SEARCH_COND) if date_start: @@ -87,16 +96,18 @@ async def ch_query(sql: str, params: dict): return [json.loads(line) for line in text.splitlines()] -def parse_filters(city: str, dimension: str, brand: str, manager: str, search: str, date_start: str, date_end: str): +def parse_filters(city: str, dimension: str, brand: str, manager: str, status: str, search: str, date_start: str, date_end: str): cities = [c for c in city.split(",") if c] if city else [] dimensions = [d for d in dimension.split(",") if d] if dimension else [] brands = [b for b in brand.split(",") if b] if brand else [] managers = [m for m in manager.split(",") if m] if manager else [] + statuses = [s for s in status.split(",") if s] if status else [] return { "cities": cities, "dimensions": dimensions, "brands": brands, "managers": managers, + "statuses": statuses, "search": search or "", "date_start": date_start or "", "date_end": date_end or "", @@ -104,25 +115,29 @@ def parse_filters(city: str, dimension: str, brand: str, manager: str, search: s @app.get("/api/meta") -async def meta(city: str = "", dimension: str = "", brand: str = "", manager: str = "", search: str = "", date_start: str = "", date_end: str = ""): - params = parse_filters(city, dimension, brand, manager, search, date_start, date_end) +async def meta(city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = "", date_start: str = "", date_end: str = ""): + params = parse_filters(city, dimension, brand, manager, status, search, date_start, date_end) hds = bool(params["date_start"]) hde = bool(params["date_end"]) cities = await ch_query( - f"SELECT DISTINCT city FROM default.pf_board WHERE {build_where(dimension=True, brand=True, manager=True, search=True, date_start=hds, date_end=hde)} AND char_length(city) > 0 ORDER BY city", + f"SELECT DISTINCT city FROM default.pf_board WHERE {build_where(dimension=True, brand=True, manager=True, status=True, search=True, date_start=hds, date_end=hde)} AND char_length(city) > 0 ORDER BY city", params, ) brands = await ch_query( - f"SELECT DISTINCT brand FROM default.pf_board WHERE {build_where(city=True, dimension=True, manager=True, search=True, date_start=hds, date_end=hde)} AND char_length(brand) > 0 ORDER BY brand", + f"SELECT DISTINCT brand FROM default.pf_board WHERE {build_where(city=True, dimension=True, manager=True, status=True, search=True, date_start=hds, date_end=hde)} AND char_length(brand) > 0 ORDER BY brand", params, ) dimensions = await ch_query( - f"SELECT DISTINCT dimension FROM default.pf_board WHERE {build_where(city=True, brand=True, manager=True, search=True, date_start=hds, date_end=hde)} AND char_length(dimension) > 0 ORDER BY dimension", + f"SELECT DISTINCT dimension FROM default.pf_board WHERE {build_where(city=True, brand=True, manager=True, status=True, search=True, date_start=hds, date_end=hde)} AND char_length(dimension) > 0 ORDER BY dimension", params, ) # manager is an Array(String) column — ARRAY JOIN to enumerate distinct names. managers = await ch_query( - f"SELECT DISTINCT m AS manager FROM default.pf_board ARRAY JOIN manager AS m WHERE {build_where(city=True, dimension=True, brand=True, search=True, date_start=hds, date_end=hde)} AND char_length(m) > 0 ORDER BY manager", + f"SELECT DISTINCT m AS manager FROM default.pf_board ARRAY JOIN manager AS m WHERE {build_where(city=True, dimension=True, brand=True, status=True, search=True, date_start=hds, date_end=hde)} AND char_length(m) > 0 ORDER BY manager", + params, + ) + statuses = await ch_query( + f"SELECT DISTINCT task_status FROM default.pf_board WHERE {build_where(city=True, dimension=True, brand=True, manager=True, search=True, date_start=hds, date_end=hde)} AND char_length(task_status) > 0 ORDER BY task_status", params, ) return { @@ -130,13 +145,14 @@ async def meta(city: str = "", dimension: str = "", brand: str = "", manager: st "dimensions": [r["dimension"] for r in dimensions], "brands": [r["brand"] for r in brands], "managers": [r["manager"] for r in managers], + "statuses": [r["task_status"] for r in statuses], } @app.get("/api/boards") -async def boards(city: str = "", dimension: str = "", brand: str = "", manager: str = "", search: str = "", date_start: str = "", date_end: str = ""): - params = parse_filters(city, dimension, brand, manager, search, date_start, date_end) - where = build_where(city=True, dimension=True, brand=True, manager=True, search=True, date_start=bool(params["date_start"]), date_end=bool(params["date_end"])) +async def boards(city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = "", date_start: str = "", date_end: str = ""): + 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, @@ -153,9 +169,9 @@ async def boards(city: str = "", dimension: str = "", brand: str = "", manager: @app.get("/api/bookings") -async def bookings(city: str = "", dimension: str = "", brand: str = "", manager: str = "", search: str = "", date_start: str = "", date_end: str = ""): - params = parse_filters(city, dimension, brand, manager, search, date_start, date_end) - where = build_where(city=True, dimension=True, brand=True, manager=True, search=True, date_start=bool(params["date_start"]), date_end=bool(params["date_end"])) +async def bookings(city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = "", date_start: str = "", date_end: str = ""): + 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, @@ -174,11 +190,11 @@ async def bookings(city: str = "", dimension: str = "", brand: str = "", manager @app.get("/api/map") -async def map_data(city: str = "", dimension: str = "", brand: str = "", manager: str = "", search: str = ""): +async def map_data(city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = ""): # Surfaces come from board_info (has coordinates). Occupancy is computed "as of # today" and is intentionally NOT affected by the date-range filters — only the # Город/Бренд/Размер/Менеджер/Поиск facets narrow which surfaces are shown. - params = parse_filters(city, dimension, brand, manager, search, "", "") + params = parse_filters(city, dimension, brand, manager, status, search, "", "") where = " AND ".join([ "char_length(city) > 0", CITY_COND, @@ -186,6 +202,7 @@ async def map_data(city: str = "", dimension: str = "", brand: str = "", manager SEARCH_COND, MAP_BRAND_COND, MAP_MANAGER_COND, + MAP_STATUS_COND, ]) surfaces_sql = f""" SELECT