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 (
}>