diff --git a/src/components/Dashboard/common/SimpleDateTimePropertyFilter.jsx b/src/components/Dashboard/common/SimpleDateTimePropertyFilter.jsx new file mode 100644 index 0000000..f0f30d5 --- /dev/null +++ b/src/components/Dashboard/common/SimpleDateTimePropertyFilter.jsx @@ -0,0 +1,527 @@ +import { useState, useEffect, useContext, useMemo, useCallback } from 'react' +import { Spin, Tree } from 'antd' +import PropTypes from 'prop-types' +import dayjs from 'dayjs' +import { ApiServerContext } from '../context/ApiServerContext' +import { LoadingOutlined } from '@ant-design/icons' + +const MONTH_NAMES = Array.from({ length: 12 }, (_, i) => + dayjs().month(i).format('MMMM') +) + +const pad2 = (n) => String(n).padStart(2, '0') + +const nodeKey = { + year: (y) => `${y}`, + month: (y, m) => `${y}-${pad2(m)}`, + day: (y, m, d) => `${y}-${pad2(m)}-${pad2(d)}`, + hour: (y, m, d, h) => `${y}-${pad2(m)}-${pad2(d)}T${pad2(h)}`, + minute: (y, m, d, h, min) => + `${y}-${pad2(m)}-${pad2(d)}T${pad2(h)}:${pad2(min)}`, + second: (y, m, d, h, min, s) => + `${y}-${pad2(m)}-${pad2(d)}T${pad2(h)}:${pad2(min)}:${pad2(s)}` +} + +const lastDayOfMonth = (year, month) => new Date(year, month, 0).getDate() + +const toYearExpression = (year) => `1 1 ${year}..31 12 ${year}` + +const toMonthExpression = (year, month) => { + const last = lastDayOfMonth(year, month) + return `1 ${month} ${year}..${last} ${month} ${year}` +} + +const toDayExpression = (year, month, day) => `${day} ${month} ${year}` + +const toHourExpression = (year, month, day, hour) => + `${day} ${month} ${year} ${hour}` + +const toMinuteExpression = (year, month, day, hour, minute) => + `${day} ${month} ${year} ${hour} ${minute}` + +const toSecondExpression = (year, month, day, hour, minute, second) => + `${day} ${month} ${year} ${hour} ${minute} ${second}` + +const parseDateValue = (value) => { + if (value == null || value === '') return null + const date = value instanceof Date ? value : new Date(value) + return Number.isNaN(date.getTime()) ? null : date +} + +const hasNonMidnightTime = (date) => + date.getHours() !== 0 || + date.getMinutes() !== 0 || + date.getSeconds() !== 0 || + date.getMilliseconds() !== 0 + +/** Nested map: year → month → day → hour → minute → Set(second) */ +const buildDateHierarchy = (dates) => { + const root = new Map() + + for (const date of dates) { + const y = date.getFullYear() + const m = date.getMonth() + 1 + const d = date.getDate() + const h = date.getHours() + const min = date.getMinutes() + const s = date.getSeconds() + + if (!root.has(y)) root.set(y, new Map()) + const months = root.get(y) + if (!months.has(m)) months.set(m, new Map()) + const days = months.get(m) + if (!days.has(d)) days.set(d, { times: new Map(), dates: [] }) + const dayNode = days.get(d) + dayNode.dates.push(date) + + if (!dayNode.times.has(h)) dayNode.times.set(h, new Map()) + const minutes = dayNode.times.get(h) + if (!minutes.has(min)) minutes.set(min, new Set()) + minutes.get(min).add(s) + } + + return root +} + +const dayNeedsTimeLevels = (dayNode) => { + const { dates, times } = dayNode + if (dates.some(hasNonMidnightTime)) return true + let distinct = 0 + for (const minutes of times.values()) { + for (const seconds of minutes.values()) { + distinct += seconds.size + if (distinct > 1) return true + } + } + return false +} + +const sortNumericKeys = (map) => [...map.keys()].sort((a, b) => a - b) + +const buildTreeData = (hierarchy) => { + const years = sortNumericKeys(hierarchy) + + return years.map((year) => { + const monthsMap = hierarchy.get(year) + const monthNodes = sortNumericKeys(monthsMap).map((month) => { + const daysMap = monthsMap.get(month) + const dayNodes = sortNumericKeys(daysMap).map((day) => { + const dayNode = daysMap.get(day) + const includeTime = dayNeedsTimeLevels(dayNode) + let children + + if (includeTime) { + children = sortNumericKeys(dayNode.times).map((hour) => { + const minutesMap = dayNode.times.get(hour) + const minuteNodes = sortNumericKeys(minutesMap).map((minute) => { + const seconds = [...minutesMap.get(minute)].sort((a, b) => a - b) + const secondNodes = seconds.map((second) => ({ + key: nodeKey.second(year, month, day, hour, minute, second), + title: pad2(second), + level: 'second', + year, + month, + day, + hour, + minute, + second, + expression: toSecondExpression( + year, + month, + day, + hour, + minute, + second + ), + isLeaf: true + })) + + return { + key: nodeKey.minute(year, month, day, hour, minute), + title: pad2(minute), + level: 'minute', + year, + month, + day, + hour, + minute, + expression: toMinuteExpression( + year, + month, + day, + hour, + minute + ), + children: secondNodes + } + }) + + return { + key: nodeKey.hour(year, month, day, hour), + title: `${pad2(hour)}:00`, + level: 'hour', + year, + month, + day, + hour, + expression: toHourExpression(year, month, day, hour), + children: minuteNodes + } + }) + } + + return { + key: nodeKey.day(year, month, day), + title: String(day), + level: 'day', + year, + month, + day, + expression: toDayExpression(year, month, day), + ...(children ? { children } : { isLeaf: true }) + } + }) + + return { + key: nodeKey.month(year, month), + title: MONTH_NAMES[month - 1], + level: 'month', + year, + month, + expression: toMonthExpression(year, month), + children: dayNodes + } + }) + + return { + key: nodeKey.year(year), + title: String(year), + level: 'year', + year, + expression: toYearExpression(year), + children: monthNodes + } + }) +} + +const collectAllKeys = (nodes, keys = []) => { + for (const node of nodes) { + keys.push(node.key) + if (node.children?.length) collectAllKeys(node.children, keys) + } + return keys +} + +const collectLeafKeys = (nodes, keys = []) => { + for (const node of nodes) { + if (!node.children?.length) keys.push(node.key) + else collectLeafKeys(node.children, keys) + } + return keys +} + +const indexNodesByKey = (nodes, map = new Map()) => { + for (const node of nodes) { + map.set(node.key, node) + if (node.children?.length) indexNodesByKey(node.children, map) + } + return map +} + +/** Prefer fully-selected ancestors over listing every leaf (Excel-style). */ +const collectCollapsedExpressions = (nodes, checkedSet) => { + const expressions = [] + + const walk = (nodeList) => { + for (const node of nodeList) { + const leafKeys = collectLeafKeys([node]) + const allChecked = + leafKeys.length > 0 && leafKeys.every((key) => checkedSet.has(key)) + + if (allChecked) { + expressions.push(node.expression) + continue + } + + if (node.children?.length) walk(node.children) + } + } + + walk(nodes) + return expressions +} + +const filterTreeBySearch = (nodes, query) => { + if (!query) return nodes + + const filterNodes = (list) => { + const result = [] + for (const node of list) { + const titleMatch = String(node.title).toLowerCase().includes(query) + if (titleMatch) { + result.push(node) + continue + } + const filteredChildren = node.children?.length + ? filterNodes(node.children) + : [] + if (filteredChildren.length > 0) { + result.push({ ...node, children: filteredChildren }) + } + } + return result + } + + return filterNodes(nodes) +} + +const parseExpressionToKeys = (expression, nodeByKey) => { + const text = String(expression).trim() + if (!text) return [] + + // Year: 1 1 Y..31 12 Y + let match = text.match(/^1 1 (\d+)\.\.31 12 \1$/) + if (match) { + const key = nodeKey.year(Number(match[1])) + return nodeByKey.has(key) ? [key] : [] + } + + // Month: 1 M Y..last M Y + match = text.match(/^1 (\d+) (\d+)\.\.(\d+) \1 \2$/) + if (match) { + const month = Number(match[1]) + const year = Number(match[2]) + const last = Number(match[3]) + if (last === lastDayOfMonth(year, month)) { + const key = nodeKey.month(year, month) + return nodeByKey.has(key) ? [key] : [] + } + } + + // Day-first numeric: D M Y [H [Min [S]]] + match = text.match( + /^(\d+)\s+(\d+)\s+(\d+)(?:\s+(\d+)(?:\s+(\d+)(?:\s+(\d+))?)?)?$/ + ) + if (match) { + const day = Number(match[1]) + const month = Number(match[2]) + const year = Number(match[3]) + const hour = match[4] != null ? Number(match[4]) : null + const minute = match[5] != null ? Number(match[5]) : null + const second = match[6] != null ? Number(match[6]) : null + + let key + if (second != null) { + key = nodeKey.second(year, month, day, hour, minute, second) + } else if (minute != null) { + key = nodeKey.minute(year, month, day, hour, minute) + } else if (hour != null) { + key = nodeKey.hour(year, month, day, hour) + } else { + key = nodeKey.day(year, month, day) + } + return nodeByKey.has(key) ? [key] : [] + } + + // Interval spanning a single calendar day already handled; try ISO / Date parse + // for a specific instant → check the deepest matching node. + if (/[-/T]/.test(text) || /\d:\d/.test(text)) { + const date = parseDateValue(text) + if (!date) return [] + const y = date.getFullYear() + const m = date.getMonth() + 1 + const d = date.getDate() + const h = date.getHours() + const min = date.getMinutes() + const s = date.getSeconds() + + const candidates = [ + nodeKey.second(y, m, d, h, min, s), + nodeKey.minute(y, m, d, h, min), + nodeKey.hour(y, m, d, h), + nodeKey.day(y, m, d) + ] + for (const key of candidates) { + if (nodeByKey.has(key)) return [key] + } + } + + return [] +} + +const expandKeysForChecked = (keys, nodeByKey) => { + const checked = new Set() + + for (const key of keys) { + const node = nodeByKey.get(key) + if (!node) { + checked.add(key) + continue + } + for (const leaf of collectLeafKeys([node])) { + checked.add(leaf) + } + // Also mark intermediate keys so Tree shows parent check state correctly + checked.add(key) + if (node.children?.length) { + for (const childKey of collectAllKeys(node.children)) { + checked.add(childKey) + } + } + } + + return [...checked] +} + +const SimpleDateTimePropertyFilter = ({ + modelType, + propertyName, + value = [], + onChange, + search = '' +}) => { + const { getModelPropertyValues } = useContext(ApiServerContext) + const [dates, setDates] = useState([]) + const [loading, setLoading] = useState(false) + const [localChecked, setLocalChecked] = useState(null) + + useEffect(() => { + let cancelled = false + + const loadOptions = async () => { + if (!modelType || !propertyName) return + setLoading(true) + try { + const values = await getModelPropertyValues(modelType, propertyName) + if (cancelled) return + const parsed = (values || []) + .map(parseDateValue) + .filter(Boolean) + .sort((a, b) => a.getTime() - b.getTime()) + setDates(parsed) + } finally { + if (!cancelled) setLoading(false) + } + } + + loadOptions() + return () => { + cancelled = true + } + }, [modelType, propertyName, getModelPropertyValues]) + + const treeData = useMemo( + () => buildTreeData(buildDateHierarchy(dates)), + [dates] + ) + + const nodeByKey = useMemo(() => indexNodesByKey(treeData), [treeData]) + const allKeys = useMemo(() => collectAllKeys(treeData), [treeData]) + const allLeafKeys = useMemo(() => collectLeafKeys(treeData), [treeData]) + + const valueKey = useMemo( + () => JSON.stringify(value || []), + [value] + ) + + useEffect(() => { + if (treeData.length === 0) return + + if (value?.length > 0) { + const matched = [] + for (const expr of value) { + matched.push(...parseExpressionToKeys(expr, nodeByKey)) + } + if (matched.length > 0) { + setLocalChecked(expandKeysForChecked(matched, nodeByKey)) + } else { + // Unrecognized expression — treat as no tree selection highlight + setLocalChecked([]) + } + } else { + setLocalChecked(allKeys) + } + // valueKey captures value contents + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [treeData, valueKey, allKeys, nodeByKey]) + + const filteredTreeData = useMemo(() => { + const query = search.trim().toLowerCase() + return filterTreeBySearch(treeData, query) + }, [treeData, search]) + + const checkedKeys = localChecked ?? allKeys + + const emitChange = useCallback( + (nextKeys) => { + setLocalChecked(nextKeys) + const checkedSet = new Set(nextKeys) + + if ( + allLeafKeys.length > 0 && + allLeafKeys.every((key) => checkedSet.has(key)) + ) { + onChange?.([]) + return + } + + const expressions = collectCollapsedExpressions(treeData, checkedSet) + onChange?.(expressions) + }, + [allLeafKeys, onChange, treeData] + ) + + const handleCheck = (checked) => { + const next = Array.isArray(checked) ? checked : checked.checked + const query = search.trim().toLowerCase() + + if (!query) { + emitChange(next) + return + } + + // Preserve checked leaves that are hidden by the search filter + const visibleLeafKeys = new Set(collectLeafKeys(filteredTreeData)) + const hiddenSelectedLeaves = checkedKeys.filter((key) => { + const node = nodeByKey.get(key) + return node && !node.children?.length && !visibleLeafKeys.has(key) + }) + const visibleSelectedLeaves = next.filter((key) => { + const node = nodeByKey.get(key) + return node && !node.children?.length + }) + + emitChange( + expandKeysForChecked( + [...hiddenSelectedLeaves, ...visibleSelectedLeaves], + nodeByKey + ) + ) + } + + return ( + }> + {treeData.length > 0 ? ( + + ) : null} + + ) +} + +SimpleDateTimePropertyFilter.propTypes = { + modelType: PropTypes.string, + propertyName: PropTypes.string, + value: PropTypes.array, + onChange: PropTypes.func, + search: PropTypes.string +} + +export default SimpleDateTimePropertyFilter diff --git a/src/components/Dashboard/common/SimplePropertyFilter.jsx b/src/components/Dashboard/common/SimplePropertyFilter.jsx index 0954419..ea26933 100644 --- a/src/components/Dashboard/common/SimplePropertyFilter.jsx +++ b/src/components/Dashboard/common/SimplePropertyFilter.jsx @@ -7,9 +7,13 @@ import { getModelProperties } from '../../../database/ObjectModels' import ObjectProperty from './ObjectProperty' +import SimpleDateTimePropertyFilter from './SimpleDateTimePropertyFilter' import { LoadingOutlined } from '@ant-design/icons' const { Text } = Typography +const isDateTimeProperty = (property) => + property?.type === 'dateTime' || property?.type === 'date' + const getOptionKey = (option) => { if (option && typeof option === 'object') { if (option.objectType) { @@ -63,7 +67,7 @@ const SimplePropertyFilter = ({ let cancelled = false const loadOptions = async () => { - if (!modelType || !propertyName) return + if (!modelType || !propertyName || isDateTimeProperty(property)) return setLoading(true) try { const values = await getModelPropertyValues(modelType, propertyName) @@ -81,7 +85,7 @@ const SimplePropertyFilter = ({ return () => { cancelled = true } - }, [modelType, propertyName, getModelPropertyValues]) + }, [modelType, propertyName, getModelPropertyValues, property]) const valueKey = useMemo( () => JSON.stringify((value || []).map(getOptionKey)), @@ -132,6 +136,18 @@ const SimplePropertyFilter = ({ emitChange([...hiddenSelected, ...visibleCheckedKeys]) } + if (isDateTimeProperty(property)) { + return ( + + ) + } + return ( }>