diff --git a/src/components/common/input/CalcInput.jsx b/src/components/common/input/CalcInput.jsx
new file mode 100644
index 00000000..8e60506b
--- /dev/null
+++ b/src/components/common/input/CalcInput.jsx
@@ -0,0 +1,403 @@
+import React, { useState, useRef, useEffect } from 'react'
+import { createCalculator } from '@/util/calc-utils'
+import '@/styles/calc.scss'
+
+export const CalculatorInput = ({ value, onChange, label, options = {}, id, className = 'calculator-input', readOnly = false }) => {
+ const [showKeypad, setShowKeypad] = useState(false)
+ const [displayValue, setDisplayValue] = useState(value || '0')
+ const [hasOperation, setHasOperation] = useState(false)
+ const calculatorRef = useRef(createCalculator(options))
+ const containerRef = useRef(null)
+ const inputRef = useRef(null)
+
+ // Sync displayValue with value prop
+ useEffect(() => {
+ setDisplayValue(value || '0')
+ }, [value])
+
+ // 클릭 외부 감지
+ useEffect(() => {
+ const handleClickOutside = (event) => {
+ if (containerRef.current && !containerRef.current.contains(event.target)) {
+ setShowKeypad(false)
+ if (hasOperation) {
+ // If there's an operation in progress, compute the result when losing focus
+ handleCompute()
+ }
+ }
+ }
+
+ document.addEventListener('mousedown', handleClickOutside)
+ return () => document.removeEventListener('mousedown', handleClickOutside)
+ }, [value, onChange, hasOperation])
+
+ // 숫자 입력 처리 함수 수정
+ const handleNumber = (num) => {
+ const calculator = calculatorRef.current
+ let newDisplayValue = ''
+
+ if (hasOperation) {
+ // 연산자 이후 숫자 입력 시
+ if (calculator.currentOperand === '0' || calculator.shouldResetDisplay) {
+ calculator.currentOperand = num.toString()
+ calculator.shouldResetDisplay = false
+ } else {
+ calculator.currentOperand = (calculator.currentOperand || '') + num
+ }
+ newDisplayValue = calculator.previousOperand + calculator.operation + calculator.currentOperand
+ setDisplayValue(newDisplayValue)
+ } else {
+ // 첫 번째 숫자 입력 시
+ if (displayValue === '0' || calculator.shouldResetDisplay) {
+ calculator.currentOperand = num.toString()
+ calculator.shouldResetDisplay = false
+ newDisplayValue = calculator.currentOperand
+ setDisplayValue(newDisplayValue)
+ if (!hasOperation) {
+ onChange(calculator.currentOperand)
+ }
+ } else {
+ calculator.currentOperand = (calculator.currentOperand || '') + num
+ newDisplayValue = calculator.currentOperand
+ setDisplayValue(newDisplayValue)
+ if (!hasOperation) {
+ onChange(newDisplayValue)
+ }
+ }
+ }
+
+ // 포커스와 커서 위치 설정 (새로운 값의 길이로 설정)
+ requestAnimationFrame(() => {
+ if (inputRef.current) {
+ inputRef.current.focus()
+ const len = newDisplayValue.length
+ inputRef.current.setSelectionRange(len, len)
+ }
+ })
+ }
+
+ // 연산자 처리 함수 수정
+ const handleOperation = (operation) => {
+ const calculator = calculatorRef.current
+ let newDisplayValue = ''
+
+ // 현재 입력된 값이 없으면 이전 값 사용 (연속 연산 시)
+ if (!calculator.currentOperand && calculator.previousOperand) {
+ calculator.operation = operation
+ newDisplayValue = calculator.previousOperand + operation
+ setDisplayValue(newDisplayValue)
+ setHasOperation(true)
+ } else if (hasOperation) {
+ // 이미 연산자가 있는 경우, 계산 실행
+ const result = calculator.compute()
+ if (result !== undefined) {
+ calculator.previousOperand = result.toString()
+ calculator.operation = operation
+ calculator.currentOperand = ''
+ newDisplayValue = calculator.previousOperand + operation
+ setDisplayValue(newDisplayValue)
+ }
+ } else {
+ // 첫 번째 연산자 입력 시
+ calculator.previousOperand = calculator.currentOperand || '0'
+ calculator.operation = operation
+ calculator.currentOperand = ''
+ setHasOperation(true)
+ newDisplayValue = calculator.previousOperand + operation
+ setDisplayValue(newDisplayValue)
+ }
+
+ // 포커스와 커서 위치 설정 (새로운 값의 길이로 설정)
+ requestAnimationFrame(() => {
+ if (inputRef.current) {
+ inputRef.current.focus()
+ const len = newDisplayValue.length
+ inputRef.current.setSelectionRange(len, len)
+ }
+ })
+ }
+
+ // AC 버튼 클릭 핸들러
+ const handleClear = () => {
+ const calculator = calculatorRef.current
+ const newValue = calculator.clear()
+ const displayValue = newValue || '0'
+ setDisplayValue(displayValue)
+ setHasOperation(false)
+ onChange(displayValue)
+ // 포커스와 커서 위치 설정
+ requestAnimationFrame(() => {
+ if (inputRef.current) {
+ inputRef.current.focus()
+ const len = displayValue.length
+ inputRef.current.setSelectionRange(len, len)
+ // Ensure focus is maintained
+ inputRef.current.focus()
+ }
+ })
+ }
+
+ // 계산 실행 함수 수정
+ const handleCompute = () => {
+ const calculator = calculatorRef.current
+ if (!hasOperation || !calculator.currentOperand) return
+
+ const result = calculator.compute()
+ if (result !== undefined) {
+ const resultStr = result.toString()
+ setDisplayValue(resultStr)
+ setHasOperation(false)
+ // Only call onChange with the final result
+ onChange(resultStr)
+
+ // 포커스 유지 및 커서 위치 설정 (맨 뒤로)
+ requestAnimationFrame(() => {
+ if (inputRef.current) {
+ inputRef.current.focus()
+ const len = resultStr.length
+ inputRef.current.setSelectionRange(len, len)
+ }
+ })
+ }
+ }
+
+ // DEL 버튼 클릭 핸들러
+ const handleDelete = () => {
+ const calculator = calculatorRef.current
+ const newValue = calculator.deleteNumber()
+ const displayValue = newValue || '0'
+ setDisplayValue(displayValue)
+ setHasOperation(!!calculator.operation)
+ onChange(displayValue)
+ // 포커스와 커서 위치 설정
+ requestAnimationFrame(() => {
+ if (inputRef.current) {
+ inputRef.current.focus()
+ const len = displayValue.length
+ inputRef.current.setSelectionRange(len, len)
+ // Ensure focus is maintained
+ inputRef.current.focus()
+ }
+ })
+ }
+
+ // input의 onChange 이벤트 처리 - 허용된 계산기 입력만 처리
+ const handleInputChange = (e) => {
+ if (readOnly) return
+
+ const inputValue = e.target.value
+
+ // 허용된 문자만 필터링 (숫자, 연산자, 소수점)
+ const filteredValue = inputValue.replace(/[^0-9+\-×÷.]/g, '')
+
+ // 연산자 연속 입력 방지
+ const lastChar = filteredValue[filteredValue.length - 1]
+ const prevChar = filteredValue[filteredValue.length - 2]
+
+ if (['+', '×', '÷'].includes(lastChar) && ['+', '-', '×', '÷', '.'].includes(prevChar)) {
+ // 연산자나 소수점이 연속으로 입력된 경우 이전 문자 유지
+ return
+ }
+
+ // 소수점 중복 입력 방지
+ const parts = filteredValue.split(/[+\-×÷]/)
+ if (parts[parts.length - 1].split('.').length > 2) {
+ // 한 숫자에 소수점이 2개 이상인 경우
+ return
+ }
+
+ setDisplayValue(filteredValue)
+
+ // 계산기 상태 업데이트
+ if (filteredValue !== displayValue) {
+ const calculator = calculatorRef.current
+ const hasOperation = /[+\-×÷]/.test(filteredValue)
+
+ if (hasOperation) {
+ const [operand1, operator, operand2] = filteredValue.split(/([+\-×÷])/)
+ calculator.previousOperand = operand1 || ''
+ calculator.operation = operator || ''
+ calculator.currentOperand = operand2 || ''
+ setHasOperation(true)
+ } else {
+ calculator.currentOperand = filteredValue
+ setHasOperation(false)
+ }
+
+ onChange(filteredValue)
+ }
+ }
+
+ // 키패드 토글 함수
+ const toggleKeypad = (e) => {
+ if (e) {
+ e.preventDefault()
+ e.stopPropagation()
+ }
+ const newShowKeypad = !showKeypad
+ setShowKeypad(newShowKeypad)
+
+ // Show keypad 시에만 포커스 유지
+ if (newShowKeypad) {
+ setTimeout(() => {
+ inputRef.current?.focus()
+ }, 0)
+ }
+ }
+
+ // 키보드 이벤트 처리 수정
+ const handleKeyDown = (e) => {
+ if (readOnly) return
+
+ // Tab 키는 계산기 숨기고 기본 동작 허용
+ if (e.key === 'Tab') {
+ setShowKeypad(false)
+ return
+ }
+
+ // 방향키는 기본 동작 허용
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
+ setShowKeypad(true)
+ return
+ }
+
+ e.preventDefault()
+ const calculator = calculatorRef.current
+ const { allowDecimal } = options
+
+ if (e.key === '.') {
+ // allowDecimal이 false이면 소수점 입력 무시
+ if (!allowDecimal) return
+
+ // 소수점 입력 처리
+ const currentValue = displayValue.toString()
+ const parts = currentValue.split(/[+\-×÷]/)
+ const lastPart = parts[parts.length - 1]
+
+ // 이미 소수점이 있으면 무시
+ if (!lastPart.includes('.')) {
+ handleNumber(e.key)
+ }
+ } else if (/^[0-9]$/.test(e.key)) {
+ handleNumber(e.key)
+ } else {
+ switch (e.key) {
+ case '+':
+ case '-':
+ case '*':
+ case '/':
+ const opMap = { '*': '×', '/': '÷' }
+ handleOperation(opMap[e.key] || e.key)
+ break
+ case 'Enter':
+ case '=':
+ handleCompute()
+ break
+ case 'Backspace':
+ case 'Delete':
+ handleDelete()
+ break
+
+ case 'Escape':
+ handleClear()
+ setShowKeypad(false)
+ break
+
+ default:
+ break
+ }
+ }
+ }
+
+ return (
+
+ {label && (
+
+ )}
+
!readOnly && setShowKeypad(true)}
+ onFocus={() => !readOnly && setShowKeypad(true)}
+ onChange={handleInputChange}
+ onKeyDown={handleKeyDown}
+ tabIndex={readOnly ? -1 : 0}
+ />
+
+ {showKeypad && !readOnly && (
+
+
+
+
+
+
+ {/* 숫자 버튼 */}
+ {[7, 8, 9, 4, 5, 6, 1, 2, 3].map((num) => (
+
+ ))}
+
+
+
+
+
+ {/* 0 버튼 */}
+
+
+ {/* = 버튼 */}
+
+
+
+ )}
+
+ )
+}
diff --git a/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx b/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx
index d6bab594..a8ee7102 100644
--- a/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx
+++ b/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx
@@ -21,6 +21,7 @@ import { useRoofFn } from '@/hooks/common/useRoofFn'
import { usePlan } from '@/hooks/usePlan'
import { normalizeDecimal, normalizeDigits } from '@/util/input-utils'
import { logger } from '@/util/logger'
+import { CalculatorInput } from '@/components/common/input/CalcInput'
/**
* 지붕 레이아웃
@@ -326,11 +327,11 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
- {
const v = normalizeDecimal(e.target.value)
e.target.value = v
@@ -342,6 +343,34 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
setCurrentRoof({ ...currentRoof, pitch: num === '' ? '' : getChonByDegree(num), angle: num === '' ? '' : num })
}
}}
+ /> */}
+ {
+ if (index === 0) {
+ const num = value === '' ? '' : Number(value)
+ setCurrentRoof(prev => ({
+ ...prev,
+ pitch: num === '' ? '' : num,
+ angle: num === '' ? '' : getDegreeByChon(num),
+ }))
+ } else {
+ const num = value === '' ? '' : Number(value)
+ setCurrentRoof( prev => ({
+ ...prev,
+ pitch: num === '' ? '' : getChonByDegree(num),
+ angle: num === '' ? '' : num,
+ }))
+ }
+ }}
+ options={{
+ allowNegative: false,
+ allowDecimal: false //(index !== 0),
+ }}
/>
{index === 0 ? '寸' : '度'}
@@ -420,10 +449,8 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
r.clCode === ( currentRoof.raft?? currentRoof?.raftBaseCd))?.clCodeNm
- }
- value={currentRoof?.raft??currentRoof?.raftBaseCd}
+ title={raftCodes?.find((r) => r.clCode === (currentRoof.raft ?? currentRoof?.raftBaseCd))?.clCodeNm}
+ value={currentRoof?.raft ?? currentRoof?.raftBaseCd}
onChange={(e) => handleRafterChange(e.clCode)}
sourceKey="clCode"
targetKey={currentRoof?.raft ? 'raft' : 'raftBaseCd'}
diff --git a/src/styles/calc.scss b/src/styles/calc.scss
new file mode 100644
index 00000000..f30bc691
--- /dev/null
+++ b/src/styles/calc.scss
@@ -0,0 +1,155 @@
+// Variables
+$colors: (
+ 'dark-700': #1f2937,
+ 'dark-600': #334155,
+ 'dark-500': #4b5563,
+ 'dark-400': #6b7280,
+ 'dark-300': #374151,
+ 'primary': #10b981,
+ 'primary-dark': #059669,
+ 'danger': #ef4444,
+ 'danger-dark': #dc2626,
+ 'warning': #f59e0b,
+ 'warning-dark': #d97706,
+ 'border': #475569
+);
+
+// Mixins
+@mixin button-styles {
+ padding: 0.125rem;
+ border-radius: 0.5rem;
+ font-weight: bold;
+ font-size: 0.625rem;
+ color: white;
+ border: none;
+ cursor: pointer;
+ transition: all 0.1s ease-in-out;
+
+ &:active {
+ transform: scale(0.95);
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
+ }
+}
+
+// Calculator Input Wrapper
+.calculator-input-wrapper {
+ position: relative;
+ width: 100%;
+ display: inline-block;
+
+ // Input Field
+ .calculator-input {
+ width: 100%;
+ padding: 0.5rem 1rem;
+ background-color: map-get($colors, 'dark-600');
+ border: 1px solid map-get($colors, 'border');
+ color: white;
+ font-weight: bold;
+ border-radius: 0.5rem;
+ text-align: right;
+ cursor: pointer;
+ font-size: 0.625rem;
+ box-sizing: border-box;
+
+ &:focus {
+ outline: none;
+ border-color: map-get($colors, 'primary');
+ box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
+ }
+ }
+
+ // Keypad Container
+ .keypad-container {
+ position: absolute;
+ top: calc(100% + 2px);
+ left: 0;
+ right: 0;
+ width: 120px;
+ background-color: map-get($colors, 'dark-700');
+ border-radius: 0.5rem;
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
+ padding: 0.5rem;
+ z-index: 1000;
+ animation: fadeIn 0.15s ease-out;
+ border: 1px solid map-get($colors, 'border');
+ box-sizing: border-box;
+
+ // Keypad Grid
+ .keypad-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 0.125rem;
+
+ // Button Base
+ button {
+ @include button-styles;
+ }
+
+ // Button Types
+ .btn-number {
+ background-color: map-get($colors, 'dark-500');
+
+ &:hover {
+ background-color: map-get($colors, 'dark-400');
+ }
+ }
+
+ .btn-operator {
+ background-color: map-get($colors, 'warning');
+
+ &:hover {
+ background-color: map-get($colors, 'warning-dark');
+ }
+ }
+
+ .btn-clear {
+ background-color: map-get($colors, 'danger');
+ grid-column: span 2;
+
+ &:hover {
+ background-color: map-get($colors, 'danger-dark');
+ }
+ }
+
+ .btn-delete {
+ background-color: map-get($colors, 'dark-500');
+
+ &:hover {
+ background-color: map-get($colors, 'dark-300');
+ }
+ }
+
+ .btn-equals {
+ background-color: map-get($colors, 'primary');
+
+ &:hover {
+ background-color: map-get($colors, 'primary-dark');
+ }
+ }
+
+ .btn-zero {
+ grid-column: span 2;
+ }
+ }
+ }
+}
+
+// Animations
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(-10px) scale(0.95);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+// Responsive Design
+@media (max-width: 640px) {
+ .keypad-grid button {
+ padding: 0.5rem;
+ font-size: 0.875rem;
+ }
+}
\ No newline at end of file
diff --git a/src/util/calc-utils.js b/src/util/calc-utils.js
new file mode 100644
index 00000000..49e76721
--- /dev/null
+++ b/src/util/calc-utils.js
@@ -0,0 +1,172 @@
+export const createCalculator = (options = {}) => {
+ const state = {
+ currentOperand: '',
+ previousOperand: '',
+ operation: undefined,
+ shouldResetDisplay: false,
+ allowNegative: options.allowNegative ?? true,
+ allowDecimal: options.allowDecimal ?? true,
+ allowZero: options.allowZero ?? true,
+ decimalPlaces: options.decimalPlaces ?? null,
+ }
+
+ // Expose state for debugging and direct access
+ const getState = () => ({ ...state })
+
+ const clear = () => {
+ state.currentOperand = ''
+ state.previousOperand = ''
+ state.operation = undefined
+ state.shouldResetDisplay = false
+ return state.currentOperand
+ }
+
+ const deleteNumber = () => {
+ if (state.currentOperand.length <= 1) {
+ state.currentOperand = '0'
+ } else {
+ state.currentOperand = state.currentOperand.toString().slice(0, -1)
+ }
+ return state.currentOperand
+ }
+
+ const appendNumber = (number) => {
+ if (number === '.' && !state.allowDecimal) return state.currentOperand
+ if (number === '.' && state.currentOperand.includes('.')) return state.currentOperand
+
+ if (state.shouldResetDisplay) {
+ state.currentOperand = number.toString()
+ state.shouldResetDisplay = false
+ } else {
+ if (state.currentOperand === '0' && number !== '.') {
+ state.currentOperand = number.toString()
+ } else {
+ state.currentOperand = state.currentOperand.toString() + number.toString()
+ }
+ }
+ return state.currentOperand
+ }
+
+ const chooseOperation = (operation) => {
+ if (operation === '-' && state.currentOperand === '0' && state.previousOperand === '' && state.allowNegative) {
+ state.currentOperand = '-'
+ return state.currentOperand
+ }
+
+ if (state.currentOperand === '' || state.currentOperand === '-') return state.currentOperand
+
+ // If there's a previous operation, compute it first
+ if (state.previousOperand !== '') {
+ compute()
+ }
+
+ state.operation = operation
+ state.previousOperand = state.currentOperand
+ state.currentOperand = ''
+ return state.previousOperand + state.operation
+ }
+
+ const compute = () => {
+ // If there's no operation, return the current value
+ if (!state.operation) return parseFloat(state.currentOperand || '0')
+
+ // If there's no current operand but we have a previous one, use previous as current
+ if (state.currentOperand === '' && state.previousOperand !== '') {
+ state.currentOperand = state.previousOperand
+ }
+
+ const prev = parseFloat(state.previousOperand)
+ const current = parseFloat(state.currentOperand)
+
+ if (isNaN(prev) || isNaN(current)) return 0
+
+ let result
+ switch (state.operation) {
+ case '+':
+ result = prev + current
+ break
+ case '-':
+ result = prev - current
+ break
+ case '×':
+ result = prev * current
+ break
+ case '÷':
+ if (current === 0) {
+ state.currentOperand = 'Error'
+ return 0
+ }
+ result = prev / current
+ break
+ default:
+ return parseFloat(state.currentOperand || '0')
+ }
+
+ // Apply formatting and constraints
+ if (state.decimalPlaces !== null) {
+ result = Number(result.toFixed(state.decimalPlaces))
+ }
+
+ if (!state.allowDecimal) {
+ result = Math.round(result)
+ }
+
+ if (!state.allowNegative && result < 0) {
+ result = 0
+ }
+
+ if (!state.allowZero && result === 0) {
+ result = 1
+ }
+
+ // Update state
+ state.currentOperand = result.toString()
+ state.previousOperand = ''
+ state.operation = undefined
+ state.shouldResetDisplay = true
+
+ return result
+ }
+
+ // Getter methods for the calculator state
+ const getCurrentOperand = () => state.currentOperand
+ const getPreviousOperand = () => state.previousOperand
+ const getOperation = () => state.operation
+ const getDisplayValue = () => {
+ if (state.operation && state.previousOperand) {
+ return `${state.previousOperand} ${state.operation} ${state.currentOperand || ''}`.trim()
+ }
+ return state.currentOperand
+ }
+
+ return {
+ // Core calculator methods
+ clear,
+ delete: deleteNumber, // Alias for deleteNumber for compatibility
+ deleteNumber,
+ appendNumber,
+ chooseOperation,
+ compute,
+
+ // State getters
+ getDisplayValue,
+ getCurrentOperand,
+ getPreviousOperand,
+ getOperation,
+
+ // Direct state access (for debugging)
+ getState,
+
+ // Direct property access (for compatibility with CalcInput.jsx)
+ get currentOperand() { return state.currentOperand },
+ get previousOperand() { return state.previousOperand },
+ get operation() { return state.operation },
+ get shouldResetDisplay() { return state.shouldResetDisplay },
+
+ // Setter for direct property access (if needed)
+ set currentOperand(value) { state.currentOperand = value },
+ set previousOperand(value) { state.previousOperand = value },
+ set operation(value) { state.operation = value },
+ set shouldResetDisplay(value) { state.shouldResetDisplay = value }
+ }
+}