diff --git a/assets/icons/functionicon.svg b/assets/icons/functionicon.svg new file mode 100644 index 0000000..426d23b --- /dev/null +++ b/assets/icons/functionicon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/stylesheets/App.css b/assets/stylesheets/App.css index fce4ef2..e9e4d54 100644 --- a/assets/stylesheets/App.css +++ b/assets/stylesheets/App.css @@ -417,3 +417,32 @@ body { .ant-badge .ant-badge-count-sm { font-size: 10px; } + +.input-number-cal { + position: relative; +} + +.input-number-cal .ant-input-suffix { + margin-right: 28px; +} + +.input-number-cal .ant-input-outlined { + border-color: var(--color-purple); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-purple) 31%, transparent); +} + +.input-number-cal .ant-input { + font-family: 'DM Mono'; + font-weight: 400; +} + +.input-number-cal-icon { + position: absolute; + right: 10px; + top: 0; + bottom: 1px; + display: flex; + align-items: center; + justify-content: center; + z-index: 1; +} diff --git a/src/components/Dashboard/common/InputNumberCal.jsx b/src/components/Dashboard/common/InputNumberCal.jsx new file mode 100644 index 0000000..2f2523a --- /dev/null +++ b/src/components/Dashboard/common/InputNumberCal.jsx @@ -0,0 +1,167 @@ +import { useState, useRef } from 'react' +import { Input, InputNumber } from 'antd' +import PropTypes from 'prop-types' +import FunctionIcon from '../../Icons/FunctionIcon' + +const OPERATOR_KEYS = ['+', '-', '*', '/'] + +/** + * Safely evaluate a math expression. Only allows numbers and +, -, *, / + */ +function safeEval(expr) { + const sanitized = String(expr) + .replace(/\s/g, '') + .replace(/[^0-9+\-*/().]/g, '') + if (!sanitized) return null + try { + const fn = new Function(`return (${sanitized})`) + const result = fn() + return typeof result === 'number' && Number.isFinite(result) ? result : null + } catch { + return null + } +} + +const InputNumberCal = ({ + value, + onChange, + onBlur, + min, + max, + prefix, + suffix, + placeholder, + disabled, + ...rest +}) => { + const [isExprMode, setIsExprMode] = useState(false) + const [exprValue, setExprValue] = useState('') + const inputRef = useRef(null) + + const switchToExprMode = (initialValue) => { + setIsExprMode(true) + setExprValue(initialValue) + setTimeout(() => { + //inputRef.current?.focus() + const input = inputRef.current.getElementsByTagName('input')[0] + if (input) { + input.focus() + } + }, 0) + } + + const exitExprMode = (result) => { + setIsExprMode(false) + setExprValue('') + if (result != null) { + const clamped = + min != null && result < min + ? min + : max != null && result > max + ? max + : result + onChange?.(clamped) + } + } + + const handleNumberKeyDown = (e) => { + if (OPERATOR_KEYS.includes(e.key)) { + e.preventDefault() + const current = value ?? '' + switchToExprMode(String(current) + e.key) + } + } + + const handleInputChange = (e) => { + const next = e.target.value + setExprValue(next) + const num = parseFloat(next) + if (!next) { + onChange?.(null) + } else if (!next.match(/[+\-*/=]/) && !Number.isNaN(num)) { + onChange?.(num) + } + } + + const handleInputKeyDown = (e) => { + if (e.key === '=') { + e.preventDefault() + const result = safeEval(exprValue) + if (result != null) { + exitExprMode(result) + } + } + } + + const handleInputBlur = (e) => { + const expr = exprValue.trim() + if (expr && expr.match(/[+\-*/]/)) { + const result = safeEval(expr) + if (result != null) { + exitExprMode(result) + } else { + exitExprMode(null) + } + } else { + const num = parseFloat(expr) + if (!Number.isNaN(num)) { + exitExprMode(num) + } else { + exitExprMode(null) + } + } + onBlur?.(e) + } + + const commonProps = { + prefix, + suffix, + placeholder, + disabled, + min, + max, + ...rest + } + + if (isExprMode) { + return ( +
+ +
+ +
+
+ ) + } + + return ( + + ) +} + +InputNumberCal.propTypes = { + value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + onChange: PropTypes.func, + onBlur: PropTypes.func, + min: PropTypes.number, + max: PropTypes.number, + step: PropTypes.number, + prefix: PropTypes.node, + suffix: PropTypes.node, + placeholder: PropTypes.string, + disabled: PropTypes.bool +} + +export default InputNumberCal diff --git a/src/components/Dashboard/common/ObjectProperty.jsx b/src/components/Dashboard/common/ObjectProperty.jsx index 58f1eef..e1e3f93 100644 --- a/src/components/Dashboard/common/ObjectProperty.jsx +++ b/src/components/Dashboard/common/ObjectProperty.jsx @@ -27,6 +27,7 @@ import { getPropertyValue } from '../../../database/ObjectModels' import PropertyChanges from './PropertyChanges' import NetGrossDisplay from './NetGrossDisplay' import NetGrossInput from './NetGrossInput' +import InputNumberCal from './InputNumberCal' import ObjectList from './ObjectList' import VarianceDisplay from './VarianceDisplay' import OperationDisplay from './OperationDisplay' @@ -713,7 +714,7 @@ const ObjectProperty = ({ ) case 'number': return ( - { root.style.setProperty('--color-purple', COLORS.colorPurple) root.style.setProperty('--color-magenta', COLORS.colorMagenta) root.style.setProperty('--color-volcano', COLORS.colorVolcano) - root.style.setProperty('--layout-header-bg', isDarkMode ? '#141414' : '#ffffff') + root.style.setProperty( + '--layout-header-bg', + isDarkMode ? '#141414' : '#ffffff' + ) }, [isDarkMode]) const themeConfig = { diff --git a/src/components/Icons/FunctionIcon.jsx b/src/components/Icons/FunctionIcon.jsx new file mode 100644 index 0000000..8b9c14e --- /dev/null +++ b/src/components/Icons/FunctionIcon.jsx @@ -0,0 +1,6 @@ +import Icon from '@ant-design/icons' +import CustomIconSvg from '../../../assets/icons/functionicon.svg?react' + +const FunctionIcon = (props) => + +export default FunctionIcon