Integrate project overview filters and icon actions
Document filter combinations, reset and browser persistence. Keep the search hint applicable before board information fields are introduced. Validation: 20 focused tests, 431 total tests, API/web builds, scripts and Next typechecks, and diff checks passed. GUI retest skipped as authorized based on the colleague's prior testing.
This commit is contained in:
commit
2e223be8b4
11 changed files with 1457 additions and 156 deletions
249
src/frontend/utils/overview-filters.ts
Normal file
249
src/frontend/utils/overview-filters.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
import type {
|
||||
DistributionBoardDto,
|
||||
FloorDto,
|
||||
ProjectDeviceDto,
|
||||
} from "../types";
|
||||
|
||||
/**
|
||||
* Pure filter logic for the project overview cards (Verteilungen,
|
||||
* Projektgeräte). Every multi-select filter uses the PM semantics: an empty
|
||||
* selection means "Alle", otherwise a record must match one selected value.
|
||||
* Records without a value are addressed through the dedicated "none" ids.
|
||||
*/
|
||||
|
||||
export interface FilterOption {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Option id for boards without floor ("Ohne Etage"). */
|
||||
export const withoutFloorFilterId = "__without_floor__";
|
||||
/** Option id for boards without supply type ("Nicht festgelegt"). */
|
||||
export const withoutSupplyTypeFilterId = "__without_supply_type__";
|
||||
/** Option id for project devices without cost group ("Ohne Kostengruppe"). */
|
||||
export const withoutCostGroupFilterId = "__without_cost_group__";
|
||||
|
||||
export interface DistributionBoardFilters {
|
||||
query: string;
|
||||
floorIds: string[];
|
||||
supplyTypes: string[];
|
||||
}
|
||||
|
||||
export interface ProjectDeviceFilters {
|
||||
query: string;
|
||||
categories: string[];
|
||||
phaseTypes: string[];
|
||||
costGroups: string[];
|
||||
}
|
||||
|
||||
export const emptyDistributionBoardFilters: DistributionBoardFilters = {
|
||||
query: "",
|
||||
floorIds: [],
|
||||
supplyTypes: [],
|
||||
};
|
||||
|
||||
export const emptyProjectDeviceFilters: ProjectDeviceFilters = {
|
||||
query: "",
|
||||
categories: [],
|
||||
phaseTypes: [],
|
||||
costGroups: [],
|
||||
};
|
||||
|
||||
export function normalizeFilterQuery(query: string) {
|
||||
return query.trim().toLocaleLowerCase("de");
|
||||
}
|
||||
|
||||
function matchesQuery(
|
||||
normalizedQuery: string,
|
||||
values: readonly (string | null | undefined)[]
|
||||
) {
|
||||
return (
|
||||
!normalizedQuery ||
|
||||
values.some((value) =>
|
||||
value?.toLocaleLowerCase("de").includes(normalizedQuery)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function matchesSelection(selected: readonly string[], value: string) {
|
||||
return selected.length === 0 || selected.includes(value);
|
||||
}
|
||||
|
||||
/** Adds or removes one value from a multi-select selection. */
|
||||
export function toggleFilterValue(selected: readonly string[], value: string) {
|
||||
return selected.includes(value)
|
||||
? selected.filter((entry) => entry !== value)
|
||||
: [...selected, value];
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops selected values that are no longer offered (e.g. a deleted floor), so
|
||||
* no invisible selection keeps hiding records.
|
||||
*/
|
||||
export function retainAvailableFilterValues(
|
||||
selected: readonly string[],
|
||||
options: readonly FilterOption[]
|
||||
) {
|
||||
const available = new Set(options.map((option) => option.id));
|
||||
return selected.filter((value) => available.has(value));
|
||||
}
|
||||
|
||||
export function isDistributionBoardFilterActive(
|
||||
filters: DistributionBoardFilters
|
||||
) {
|
||||
return (
|
||||
normalizeFilterQuery(filters.query) !== "" ||
|
||||
filters.floorIds.length > 0 ||
|
||||
filters.supplyTypes.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function isProjectDeviceFilterActive(filters: ProjectDeviceFilters) {
|
||||
return (
|
||||
normalizeFilterQuery(filters.query) !== "" ||
|
||||
filters.categories.length > 0 ||
|
||||
filters.phaseTypes.length > 0 ||
|
||||
filters.costGroups.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/** Floors in their configured order, followed by "Ohne Etage". */
|
||||
export function buildFloorFilterOptions(
|
||||
floors: readonly FloorDto[]
|
||||
): FilterOption[] {
|
||||
return [
|
||||
...[...floors]
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.sortOrder - right.sortOrder ||
|
||||
left.name.localeCompare(right.name, "de", { numeric: true })
|
||||
)
|
||||
.map((floor) => ({ id: floor.id, label: floor.name })),
|
||||
{ id: withoutFloorFilterId, label: "Ohne Etage" },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Supply types in catalog order: every enabled or used type, plus
|
||||
* "Nicht festgelegt" when a board has no supply type.
|
||||
*/
|
||||
export function buildSupplyTypeFilterOptions(
|
||||
catalog: readonly string[],
|
||||
enabledSupplyTypes: readonly string[],
|
||||
boards: readonly Pick<DistributionBoardDto, "supplyType">[],
|
||||
labels: Readonly<Record<string, string>>
|
||||
): FilterOption[] {
|
||||
const offered = new Set<string>(enabledSupplyTypes);
|
||||
let hasBoardWithoutSupplyType = false;
|
||||
for (const board of boards) {
|
||||
if (board.supplyType) {
|
||||
offered.add(board.supplyType);
|
||||
} else {
|
||||
hasBoardWithoutSupplyType = true;
|
||||
}
|
||||
}
|
||||
const options: FilterOption[] = catalog
|
||||
.filter((supplyType) => offered.has(supplyType))
|
||||
.map((supplyType) => ({
|
||||
id: supplyType,
|
||||
label: labels[supplyType] ?? supplyType,
|
||||
}));
|
||||
if (hasBoardWithoutSupplyType) {
|
||||
options.push({ id: withoutSupplyTypeFilterId, label: "Nicht festgelegt" });
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Board number and location come with the distribution-board info card
|
||||
* (feature/print-view). They are optional here so the filter works with and
|
||||
* without those fields and searches them as soon as the DTO carries them.
|
||||
*/
|
||||
export function filterDistributionBoards<
|
||||
Board extends Pick<DistributionBoardDto, "name" | "floorId" | "supplyType"> & {
|
||||
boardNumber?: string | null;
|
||||
location?: string | null;
|
||||
},
|
||||
>(
|
||||
boards: readonly Board[],
|
||||
filters: DistributionBoardFilters,
|
||||
floorNameById: ReadonlyMap<string, string>
|
||||
): Board[] {
|
||||
const query = normalizeFilterQuery(filters.query);
|
||||
return boards.filter(
|
||||
(board) =>
|
||||
matchesQuery(query, [
|
||||
board.name,
|
||||
board.boardNumber,
|
||||
board.location,
|
||||
board.floorId ? floorNameById.get(board.floorId) : undefined,
|
||||
]) &&
|
||||
matchesSelection(
|
||||
filters.floorIds,
|
||||
board.floorId ?? withoutFloorFilterId
|
||||
) &&
|
||||
matchesSelection(
|
||||
filters.supplyTypes,
|
||||
board.supplyType ?? withoutSupplyTypeFilterId
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function getCostGroupFilterValue(costGroup: string | null) {
|
||||
const trimmed = costGroup?.trim() ?? "";
|
||||
return trimmed || withoutCostGroupFilterId;
|
||||
}
|
||||
|
||||
/** Distinct cost groups in natural order, followed by "Ohne Kostengruppe". */
|
||||
export function buildCostGroupFilterOptions(
|
||||
devices: readonly Pick<ProjectDeviceDto, "costGroup">[]
|
||||
): FilterOption[] {
|
||||
const costGroups = new Set<string>();
|
||||
for (const device of devices) {
|
||||
const value = getCostGroupFilterValue(device.costGroup);
|
||||
if (value !== withoutCostGroupFilterId) {
|
||||
costGroups.add(value);
|
||||
}
|
||||
}
|
||||
return [
|
||||
...[...costGroups]
|
||||
.sort((left, right) => left.localeCompare(right, "de", { numeric: true }))
|
||||
.map((costGroup) => ({ id: costGroup, label: costGroup })),
|
||||
{ id: withoutCostGroupFilterId, label: "Ohne Kostengruppe" },
|
||||
];
|
||||
}
|
||||
|
||||
export function filterProjectDevices<
|
||||
Device extends Pick<
|
||||
ProjectDeviceDto,
|
||||
| "name"
|
||||
| "displayName"
|
||||
| "category"
|
||||
| "phaseType"
|
||||
| "connectionKind"
|
||||
| "costGroup"
|
||||
>,
|
||||
>(
|
||||
devices: readonly Device[],
|
||||
filters: ProjectDeviceFilters,
|
||||
categoryLabels: Readonly<Record<string, string>> = {}
|
||||
): Device[] {
|
||||
const query = normalizeFilterQuery(filters.query);
|
||||
return devices.filter(
|
||||
(device) =>
|
||||
matchesQuery(query, [
|
||||
device.name,
|
||||
device.displayName,
|
||||
device.category,
|
||||
categoryLabels[device.category],
|
||||
device.connectionKind,
|
||||
device.costGroup,
|
||||
]) &&
|
||||
matchesSelection(filters.categories, device.category) &&
|
||||
matchesSelection(filters.phaseTypes, device.phaseType) &&
|
||||
matchesSelection(
|
||||
filters.costGroups,
|
||||
getCostGroupFilterValue(device.costGroup)
|
||||
)
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue