diff --git a/src/Components/History/EventFilters.tsx b/src/Components/History/EventFilters.tsx new file mode 100644 index 0000000..af472cc --- /dev/null +++ b/src/Components/History/EventFilters.tsx @@ -0,0 +1,173 @@ +import { ScaleButton, ScaleDropdownSelect, ScaleDropdownSelectItem, ScaleTextField } from "@telekom/scale-components-react"; +import dayjs from "dayjs"; +import { EventStatus, EventType } from "~/Components/Event/Enums"; +import { Models } from "~/Services/Status.Models"; + +export interface IFilterState { + startDate: string; + endDate: string; + serviceName: string; + region: string; + eventType: string; + eventStatus: string; +} + +export interface IFilterValidation { + startDate: string; + endDate: string; +} + +interface IEventFilters { + filters: IFilterState; + validation: IFilterValidation; + regions: Models.IRegion[]; + totalEvents: number; + filteredCount: number; + onFiltersChange: (filters: IFilterState) => void; + onValidationChange: (validation: IFilterValidation) => void; + onClearFilters: () => void; +} + +/** + * @author Aloento + * @since 1.2.0 + * @version 1.0.0 + */ +export function EventFilters({ + filters, + validation, + regions, + totalEvents, + filteredCount, + onFiltersChange, + onValidationChange, + onClearFilters, +}: IEventFilters) { + function validateDates(startDate: string, endDate: string) { + const errors = { startDate: "", endDate: "" }; + + if (startDate && endDate) { + const start = dayjs(startDate); + const end = dayjs(endDate); + + if (start.isAfter(end)) { + errors.startDate = "Start date cannot be later than end date."; + } + } + + onValidationChange(errors); + return !errors.startDate && !errors.endDate; + } + + return ( +
+
+ { + const newStartDate = e.target.value as string; + onFiltersChange({ + ...filters, + startDate: newStartDate + }); + validateDates(newStartDate, filters.endDate); + }} + /> + + { + const newEndDate = e.target.value as string; + onFiltersChange({ + ...filters, + endDate: newEndDate + }); + validateDates(filters.startDate, newEndDate); + }} + /> + + onFiltersChange({ + ...filters, + serviceName: e.target.value as string + })} + /> + + onFiltersChange({ + ...filters, + region: e.target.value as string + })} + > + All Regions + {regions.map((region, i) => ( + + {region.Name} + + ))} + + + onFiltersChange({ + ...filters, + eventType: e.target.value as string + })} + > + All Types + {Object.values(EventType).slice(1).map((type, i) => ( + + {type} + + ))} + + + onFiltersChange({ + ...filters, + eventStatus: e.target.value as string + })} + > + All Status + {Object.values(EventStatus).map((status, i) => ( + + {status} + + ))} + +
+ +
+ + Found {filteredCount} events, {totalEvents - filteredCount} filtered out + + + Clear Filters + +
+
+ ); +} diff --git a/src/Components/History/EventItem.tsx b/src/Components/History/EventItem.tsx deleted file mode 100644 index d07cf52..0000000 --- a/src/Components/History/EventItem.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import { ScaleTag } from "@telekom/scale-components-react"; -import dayjs from "dayjs"; -import { chain } from "lodash"; -import { useEffect, useMemo, useRef } from "react"; -import { Dic } from "~/Helpers/Entities"; -import { Models } from "~/Services/Status.Models"; -import { EventStatus } from "../Event/Enums"; -import { Indicator } from "../Home/Indicator"; - -interface IEventItem { - Prev?: Models.IEvent; - Curr: Models.IEvent; -} - -/** - * @author Aloento - * @since 1.0.0 - * @version 0.2.0 - */ -export function EventItem({ Prev, Curr }: IEventItem) { - const isBegin = useMemo(() => { - if (!Prev) - return true; - - return Prev.Start.getMonth() != Curr.Start.getMonth(); - }, [Prev]); - - const label = useRef(null); - - useEffect(() => { - if (label.current) { - const prev = label.current.previousElementSibling; - if (prev && prev instanceof HTMLElement) { - prev.style.paddingBottom = "0"; - prev.classList.add("mb-6"); - } - } - }, [label.current]); - - const services = useMemo(() => { - return chain(Array.from(Curr.RegionServices)) - .map(x => x.Service) - .uniqBy(x => x.Id) - .value(); - }, [Curr.RegionServices]); - - const upper = services.map(x => ({ - Name: x.Name, - Abbr: x.Abbr.toUpperCase() - })); - - const servicesTxt = upper.length > 3 - ? upper.slice(0, 3).map(x => x.Abbr).join(", ") + ` (+${upper.length - 3})` - : upper.map(x => x.Abbr).join(", "); - - const regions = useMemo(() => { - return chain(Array.from(Curr.RegionServices)) - .map(x => x.Region.Name) - .uniq() - .value(); - }, [Curr.RegionServices]); - - const regionsTxt = regions.length > 2 - ? regions.slice(0, 2).join(", ") + ` (+${regions.length - 2})` - : regions.join(", "); - - let color: any; - - switch (Curr.Status) { - case EventStatus.Detected: - case EventStatus.Analysing: - color = "red"; - break; - - case EventStatus.Fixing: - color = "orange"; - break; - - case EventStatus.Observing: - color = "yellow"; - break; - - case EventStatus.Planned: - color = "cyan"; - break; - - case EventStatus.Active: - color = "teal"; - break; - - case EventStatus.Modified: - case EventStatus.InProgress: - color = "violet"; - break; - - case EventStatus.Resolved: - case EventStatus.Completed: - color = "green"; - break; - - default: - color = "standard"; - break; - } - - return ( - <> - {isBegin && - } - -
  • - - {servicesTxt} {regionsTxt} {Curr.Type} - - -
    - - {Curr.Status} - - - {services.slice(0, 3).map(service => ( - - {service.Name} - - ))} - - {services.length > 3 && ( - - +{services.length - 3} - - )} -
    - - - - -
  • - - ); -} diff --git a/src/Components/History/useEventFilters.ts b/src/Components/History/useEventFilters.ts new file mode 100644 index 0000000..59dbd8f --- /dev/null +++ b/src/Components/History/useEventFilters.ts @@ -0,0 +1,91 @@ +import dayjs from "dayjs"; +import { chain } from "lodash"; +import { useState } from "react"; +import { Models } from "~/Services/Status.Models"; +import { IFilterState, IFilterValidation } from "./EventFilters"; + +const DEFAULT_FILTERS: IFilterState = { + startDate: dayjs().add(-6, 'month').format('YYYY-MM-DD'), + endDate: "", + serviceName: "", + region: "", + eventType: "", + eventStatus: "", +}; + +const DEFAULT_VALIDATION: IFilterValidation = { + startDate: "", + endDate: "", +}; + +/** + * @author Aloento + * @since 1.2.0 + * @version 1.0.0 + */ +export function useEventFilters(events: Models.IEvent[]) { + const [filters, setFilters] = useState(DEFAULT_FILTERS); + const [validation, setValidation] = useState(DEFAULT_VALIDATION); + + function clearFilters() { + setFilters(DEFAULT_FILTERS); + setValidation(DEFAULT_VALIDATION); + } + + const filteredEvents = chain(events) + .filter(event => { + if (filters.startDate) { + const filterStart = dayjs(filters.startDate).startOf('day'); + const eventStart = dayjs(event.Start); + if (eventStart.isBefore(filterStart)) return false; + } + + if (filters.endDate) { + const filterEnd = dayjs(filters.endDate).endOf('day'); + const eventStart = dayjs(event.Start); + const eventEnd = event.End ? dayjs(event.End) : eventStart; + if (eventEnd.isAfter(filterEnd)) return false; + } + + if (filters.serviceName) { + const serviceNames = Array.from(event.RegionServices) + .map(rs => ({ + name: rs.Service.Name.toLowerCase(), + abbr: rs.Service.Abbr.toLowerCase() + })); + const searchTerm = filters.serviceName.toLowerCase(); + const hasService = serviceNames.some(service => + service.name.includes(searchTerm) || service.abbr.includes(searchTerm) + ); + if (!hasService) return false; + } + + if (filters.region) { + const regionNames = Array.from(event.RegionServices) + .map(rs => rs.Region.Name); + const hasRegion = regionNames.includes(filters.region); + if (!hasRegion) return false; + } + + if (filters.eventType && event.Type !== filters.eventType) { + return false; + } + + if (filters.eventStatus && event.Status !== filters.eventStatus) { + return false; + } + + return true; + }) + .orderBy(x => x.Start, "desc") + .value(); + + return { + filters, + validation, + filteredEvents, + setFilters, + setValidation, + clearFilters, + }; +} diff --git a/src/Pages/History.tsx b/src/Pages/History.tsx index 9813314..984aca1 100644 --- a/src/Pages/History.tsx +++ b/src/Pages/History.tsx @@ -1,35 +1,149 @@ +import { ScaleDataGrid, ScaleIconActionCheckmark, ScaleIconActionMenu, ScaleMenuFlyoutItem, ScaleMenuFlyoutList } from "@telekom/scale-components-react"; +import dayjs from "dayjs"; import { chain } from "lodash"; +import { useEffect, useRef, useState } from "react"; import { Helmet } from "react-helmet"; -import { EventItem } from "~/Components/History/EventItem"; +import { EventFilters } from "~/Components/History/EventFilters"; +import { getEventTag } from "~/Components/History/EventTag"; +import { useEventFilters } from "~/Components/History/useEventFilters"; +import { Dic } from "~/Helpers/Entities"; import { useStatus } from "~/Services/Status"; +const PAGE_SIZE_KEY = "historyPageSize"; +const PAGE_SIZE_OPTIONS = [10, 20, 50]; + /** * @author Aloento - * @since 1.0.0 - * @version 0.1.0 + * @since 1.2.0 + * @version 1.2.2 */ export function History() { const { DB } = useStatus(); + const gridRef = useRef(null); + + const [pageSize, setPageSize] = useState(() => { + const stored = localStorage.getItem(PAGE_SIZE_KEY); + return stored ? parseInt(stored, 10) : 20; + }); + + const { + filters, + validation, + filteredEvents, + setFilters, + setValidation, + clearFilters, + } = useEventFilters(DB.Events); + + useEffect(() => { + if (!gridRef.current) { + return; + } + + const grid = gridRef.current; + + grid.fields = [ + { type: "number", label: "ID", sortable: true }, + { type: "tags", label: "Type", sortable: true }, + { type: "date", label: "Start CET", sortable: true }, + { type: "date", label: "End CET", sortable: true }, + { type: "text", label: "Status", sortable: true }, + { type: "text", label: "Region", sortable: true }, + { type: "text", label: "Service", sortable: true, stretchWeight: 0.8 }, + { type: "actions", label: "Detail" }, + ]; + + const events = chain(filteredEvents) + .map((x) => { + const rs = Array.from(x.RegionServices); + + const Services = chain(rs) + .map(s => s.Service.Name) + .uniq() + .value(); - return <> + const Regions = chain(rs) + .map(r => r.Region.Name) + .uniq() + .value(); + + const tagArray = getEventTag(x.Type); + + return [ + x.Id, + tagArray, + dayjs(x.Start).tz(Dic.TZ).format(Dic.Time), + x.End ? dayjs(x.End).tz(Dic.TZ).format(Dic.Time) : "-", + x.Status, + Regions.join(", "), + Services.length > 2 + ? `${Services.slice(0, 2).join(", ")} +${Services.length - 2}` + : Services.join(", "), + [ + { + label: "↗", + variant: "secondary", + href: `/Event/${x.Id}` + } + ] + ]; + }) + .value(); + + grid.rows = events; + }, [gridRef.current, filteredEvents]); + + return
    - Timeline - OTC Status Dashboard + History - OTC Status Dashboard -
    -

    - OTC Event Timeline -

    -
    + + +
    + + + Page Size + -
      - {chain(DB.Events) - .orderBy(x => x.Start, "desc") - .map((event, index, events) => [events[index - 1], event]) - .map(([prev, curr]) => ( - - )) - .value()} -
    - ; + + {PAGE_SIZE_OPTIONS.map((size) => ( + { + setPageSize(size); + localStorage.setItem(PAGE_SIZE_KEY, size.toString()); + }} + > + {size} + + + ))} + +
    +
    +
    +
    ; }