From 9361b2484ade250d08041396d0fb155ad84d57d6 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 7 Apr 2026 11:25:01 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20POP=20=EC=9E=91=EC=97=85=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=B2=B4=ED=81=AC=EB=A6=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=84=B0=EC=B9=98=20UX=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 숫자 키패드 모달 추가 (NumericKeypadModal) — 64x64px 버튼 - input_type별 분기 렌더링: * checkbox: 큰 터치 토글 버튼 (확인/미확인) 56px+ * number: 터치 시 숫자 키패드 모달 (기준치 ±범위 표시) * select: 큰 선택 버튼 56px+ * text: 입력칸 높이 56px로 키움 - MaterialQtyInput 컴포넌트: 자재 투입 수량도 키패드 모달 사용 - 모든 터치 영역 ISA-101 기준 (56px+) 적용 --- .../PopWorkDetailComponent.tsx | 563 +++++++++++++++--- 1 file changed, 470 insertions(+), 93 deletions(-) diff --git a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx index c5d5f169..3ae24070 100644 --- a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx @@ -263,6 +263,185 @@ const COLORS = { kpiDefect: 'text-red-600', } as const; +// ======================================== +// 숫자 키패드 모달 (체크리스트/자재투입 공용) +// ======================================== + +const NUMPAD_KEYS = [ + { label: "7", action: "7" }, + { label: "8", action: "8" }, + { label: "9", action: "9" }, + { label: "\u2190", action: "backspace" }, + { label: "4", action: "4" }, + { label: "5", action: "5" }, + { label: "6", action: "6" }, + { label: "C", action: "clear" }, + { label: "1", action: "1" }, + { label: "2", action: "2" }, + { label: "3", action: "3" }, + { label: ".", action: "dot" }, +]; + +interface NumericKeypadModalProps { + open: boolean; + onClose: () => void; + onConfirm: (value: string) => void; + title?: string; + unit?: string; + initialValue?: string; + lowerLimit?: number; + upperLimit?: number; +} + +function NumericKeypadModal({ + open, + onClose, + onConfirm, + title, + unit, + initialValue, + lowerLimit, + upperLimit, +}: NumericKeypadModalProps) { + const [value, setValue] = useState(initialValue || "0"); + const hasRange = lowerLimit !== undefined && upperLimit !== undefined && !isNaN(lowerLimit) && !isNaN(upperLimit); + + useEffect(() => { + if (open) { + setValue(initialValue || "0"); + } + }, [open, initialValue]); + + const handleKey = useCallback((action: string) => { + setValue((prev) => { + switch (action) { + case "backspace": + return prev.length <= 1 ? "0" : prev.slice(0, -1); + case "clear": + return "0"; + case "dot": + return prev.includes(".") ? prev : prev + "."; + default: { + if (prev === "0" && action !== ".") return action; + return prev + action; + } + } + }); + }, []); + + const handleConfirm = () => { + const num = parseFloat(value); + if (isNaN(num)) return; + onConfirm(value); + onClose(); + }; + + if (!open) return null; + + const numValue = parseFloat(value); + const isOutOfRange = hasRange && !isNaN(numValue) && (numValue < lowerLimit! || numValue > upperLimit!); + + return ( +
+
+
+ {/* Header */} +
+ + {title || "숫자 입력"} + + +
+ +
+ {/* Range indicator */} + {hasRange && ( +
+ 기준: {lowerLimit} ~ {upperLimit}{unit ? ` ${unit}` : ""} +
+ )} + + {/* Display */} +
+ + {unit && ( + {unit} + )} +
+ + {/* Numpad grid */} +
+ {NUMPAD_KEYS.map((key) => ( + + ))} + {/* Bottom row: 0 (span 2) + Confirm (span 2) */} + + +
+
+
+
+ ); +} + // ======================================== // Props // ======================================== @@ -2200,6 +2379,65 @@ interface MaterialInputRecord { recorded_at: string | null; } +// === 자재 수량 입력 (터치 키패드) === +interface MaterialQtyInputProps { + materialId: string; + materialName?: string; + unit: string; + requiredQty: number; + value: string; + isConfirmed: boolean; + onChange: (val: string) => void; +} + +function MaterialQtyInput({ + materialId: _materialId, + materialName, + unit, + requiredQty, + value, + isConfirmed, + onChange, +}: MaterialQtyInputProps) { + const [keypadOpen, setKeypadOpen] = useState(false); + + if (isConfirmed) { + return ( +
+ {value || "-"} + {unit} +
+ ); + } + + // ±20% 범위 + const lower = requiredQty * 0.8; + const upper = requiredQty * 1.2; + + return ( +
+ + {unit} + setKeypadOpen(false)} + onConfirm={(val) => onChange(val)} + title={materialName || "자재 투입"} + unit={unit} + initialValue={value || ""} + lowerLimit={lower} + upperLimit={upper} + /> +
+ ); +} + interface MaterialInputSectionProps { workOrderProcessId: string; isConfirmed: boolean; @@ -2369,21 +2607,15 @@ function MaterialInputSection({ workOrderProcessId, isConfirmed }: MaterialInput {mat.unit || "EA"} -
- {isConfirmed ? ( - {inputValues[mat.id] || "-"} - ) : ( - updateInputValue(mat.id, e.target.value)} - placeholder="0" - /> - )} - {mat.unit || "EA"} -
+ updateInputValue(mat.id, val)} + /> {status.label && ( @@ -2934,43 +3166,53 @@ function InspectInputRouter(props: { item: WorkResultRow; disabled: boolean; sav } } -// === 수치 입력 (우측 전용) === +// === 수치 입력 (우측 전용) — 터치 키패드 모달 === function InspectNumericInput({ item, disabled, saving, onSave }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) { - const [inputVal, setInputVal] = useState(item.result_value ?? ""); + const [keypadOpen, setKeypadOpen] = useState(false); const lower = parseFloat(item.lower_limit ?? ""); const upper = parseFloat(item.upper_limit ?? ""); const hasRange = !isNaN(lower) && !isNaN(upper); - const handleSave = () => { - if (!inputVal || disabled) return; - const numVal = parseFloat(inputVal); + const handleConfirm = (val: string) => { + const numVal = parseFloat(val); let passed: string | null = null; if (hasRange) passed = numVal >= lower && numVal <= upper ? "Y" : "N"; - onSave(item.id, inputVal, passed, "completed"); + onSave(item.id, val, passed, "completed"); }; + const displayValue = item.result_value ?? ""; + return ( <> - setInputVal(e.target.value)} - onBlur={handleSave} + {item.unit && {item.unit}} {item.is_passed === "Y" && !saving && PASS} {item.is_passed === "N" && !saving && FAIL} {item.status === "completed" && item.is_passed === null && !saving && 완료} - {!item.status || (item.status !== "completed" && !saving) ? ( - - 저장 - - ) : null} {saving && } + setKeypadOpen(false)} + onConfirm={handleConfirm} + title={item.detail_label || item.detail_content} + unit={item.unit ?? undefined} + initialValue={displayValue} + lowerLimit={hasRange ? lower : undefined} + upperLimit={hasRange ? upper : undefined} + /> ); } @@ -3039,7 +3281,10 @@ function InspectSelectInput({ item, disabled, saving, onSave }: { item: WorkResu variant={currentValue === opt ? "blue" : "gray"} onClick={() => handleSelect(opt)} disabled={disabled} - style={{ minHeight: 52, minWidth: 100, fontSize: 16 }} + style={{ + minHeight: 56, minWidth: 100, fontSize: 16, + ...(currentValue === opt ? { boxShadow: '0 0 0 3px #3b82f6, 0 4px 12px rgba(59,130,246,0.3), inset 0 1px 0 rgba(255,255,255,0.4)' } : {}), + }} > {opt} @@ -3063,7 +3308,7 @@ function InspectTextInput({ item, disabled, saving, onSave }: { item: WorkResult <> setInputVal(e.target.value)} disabled={disabled} @@ -3073,7 +3318,7 @@ function InspectTextInput({ item, disabled, saving, onSave }: { item: WorkResult variant={judged === "Y" ? "green" : "gray"} onClick={() => handleJudge("Y")} disabled={disabled} - style={{ minHeight: 48, minWidth: 80, fontSize: 15 }} + style={{ minHeight: 56, minWidth: 90, fontSize: 16 }} > 합격 @@ -3081,7 +3326,7 @@ function InspectTextInput({ item, disabled, saving, onSave }: { item: WorkResult variant={judged === "N" ? "red" : "gray"} onClick={() => handleJudge("N")} disabled={disabled} - style={{ minHeight: 48, minWidth: 80, fontSize: 15 }} + style={{ minHeight: 56, minWidth: 90, fontSize: 16 }} > 불합격 @@ -3090,40 +3335,92 @@ function InspectTextInput({ item, disabled, saving, onSave }: { item: WorkResult ); } -// === 체크박스 (우측 전용) === +// === 체크박스 (우측 전용) — 큰 터치 토글 === function CheckInputOnly({ item, disabled, saving, onSave }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) { const checked = item.result_value === "Y"; return ( <> - {item.status === "completed" && 완료} - { - const val = v ? "Y" : "N"; - onSave(item.id, val, v ? "Y" : "N", v ? "completed" : "pending"); + { + if (disabled) return; + onSave(item.id, "Y", "Y", "completed"); }} - /> + disabled={disabled} + style={{ + minHeight: 56, minWidth: 110, fontSize: 18, + ...(checked ? { boxShadow: '0 0 0 3px #22c55e, 0 4px 12px rgba(34,197,94,0.3), inset 0 1px 0 rgba(255,255,255,0.4)' } : {}), + }} + > + 확인 + + { + if (disabled) return; + onSave(item.id, "N", "N", "pending"); + }} + disabled={disabled} + style={{ + minHeight: 56, minWidth: 110, fontSize: 18, + ...(!checked && item.result_value === "N" ? { boxShadow: '0 0 0 3px #ef4444, 0 4px 12px rgba(239,68,68,0.3), inset 0 1px 0 rgba(255,255,255,0.4)' } : {}), + }} + > + 미확인 + {saving && } ); } -// === 자유입력 (우측 전용) === +// === 자유입력 (우측 전용) — number는 키패드, text는 큰 입력칸 === function InputOnlyItem({ item, disabled, saving, onSave }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) { + const isNumber = item.input_type === "number"; const [inputVal, setInputVal] = useState(item.result_value ?? ""); - const inputType = item.input_type === "number" ? "number" : "text"; + const [keypadOpen, setKeypadOpen] = useState(false); + const handleBlur = () => { if (!inputVal || disabled) return; onSave(item.id, inputVal, null, "completed"); }; + + if (isNumber) { + return ( + <> + + {item.status === "completed" && !saving && 완료} + {saving && } + setKeypadOpen(false)} + onConfirm={(val) => { + setInputVal(val); + onSave(item.id, val, null, "completed"); + }} + title={item.detail_label || item.detail_content} + unit={item.unit ?? undefined} + initialValue={inputVal} + /> + + ); + } + return ( <> setInputVal(e.target.value)} onBlur={handleBlur} @@ -3135,22 +3432,26 @@ function InputOnlyItem({ item, disabled, saving, onSave }: { item: WorkResultRow ); } -// === 절차 확인 (우측 전용) === +// === 절차 확인 (우측 전용) — 큰 터치 토글 === function ProcedureInputOnly({ item, disabled, saving, onSave }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) { const checked = item.result_value === "Y"; return ( <> - { - onSave(item.id, v ? "Y" : "N", null, v ? "completed" : "pending"); + { + if (disabled) return; + onSave(item.id, checked ? "N" : "Y", null, checked ? "pending" : "completed"); }} - /> - 확인 + disabled={disabled} + style={{ + minHeight: 56, minWidth: 130, fontSize: 18, + ...(checked ? { boxShadow: '0 0 0 3px #22c55e, 0 4px 12px rgba(34,197,94,0.3), inset 0 1px 0 rgba(255,255,255,0.4)' } : {}), + }} + > + {checked ? "확인됨" : "확인"} + {saving && } - {item.status === "completed" && !saving && 완료} ); } @@ -3218,20 +3519,42 @@ function ResultInputOnly({ item, disabled, saving, onSave }: { item: WorkResultR function CheckItem({ item, disabled, saving, onSave }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) { const checked = item.result_value === "Y"; return ( -
- { - const val = v ? "Y" : "N"; - onSave(item.id, val, v ? "Y" : "N", v ? "completed" : "pending"); - }} - /> - {item.detail_label || item.detail_content} - {item.is_required === "Y" && 필수} - {saving && } - {item.status === "completed" && !saving && 완료} +
+
+ {item.detail_label || item.detail_content} + {item.is_required === "Y" && 필수} + {saving && } +
+
+ { + if (disabled) return; + onSave(item.id, "Y", "Y", "completed"); + }} + disabled={disabled} + style={{ + minHeight: 56, minWidth: 130, fontSize: 18, + ...(checked ? { boxShadow: '0 0 0 3px #22c55e, 0 4px 12px rgba(34,197,94,0.3), inset 0 1px 0 rgba(255,255,255,0.4)' } : {}), + }} + > + 확인 + + { + if (disabled) return; + onSave(item.id, "N", "N", "pending"); + }} + disabled={disabled} + style={{ + minHeight: 56, minWidth: 130, fontSize: 18, + ...(!checked && item.result_value === "N" ? { boxShadow: '0 0 0 3px #ef4444, 0 4px 12px rgba(239,68,68,0.3), inset 0 1px 0 rgba(255,255,255,0.4)' } : {}), + }} + > + 미확인 + +
); } @@ -3257,21 +3580,22 @@ function InspectRouter(props: { item: WorkResultRow; disabled: boolean; saving: // ===== inspect: 수치(범위) ===== function InspectNumeric({ item, disabled, saving, onSave }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) { - const [inputVal, setInputVal] = useState(item.result_value ?? ""); + const [keypadOpen, setKeypadOpen] = useState(false); const lower = parseFloat(item.lower_limit ?? ""); const upper = parseFloat(item.upper_limit ?? ""); const hasRange = !isNaN(lower) && !isNaN(upper); - const handleBlur = () => { - if (!inputVal || disabled) return; - const numVal = parseFloat(inputVal); + const handleConfirm = (val: string) => { + const numVal = parseFloat(val); let passed: string | null = null; if (hasRange) { passed = numVal >= lower && numVal <= upper ? "Y" : "N"; } - onSave(item.id, inputVal, passed, "completed"); + onSave(item.id, val, passed, "completed"); }; + const displayValue = item.result_value ?? ""; + return (
@@ -3290,12 +3614,32 @@ function InspectNumeric({ item, disabled, saving, onSave }: { item: WorkResultRo
{item.inspection_method}
)}
- setInputVal(e.target.value)} onBlur={handleBlur} disabled={disabled} placeholder="측정값 입력" /> + {item.unit && {item.unit}} {saving && } {item.is_passed === "Y" && !saving && 합격} {item.is_passed === "N" && !saving && 불합격}
+ setKeypadOpen(false)} + onConfirm={handleConfirm} + title={item.detail_label || item.detail_content} + unit={item.unit ?? undefined} + initialValue={displayValue} + lowerLimit={hasRange ? lower : undefined} + upperLimit={hasRange ? upper : undefined} + />
); } @@ -3454,8 +3798,9 @@ function InspectText({ item, disabled, saving, onSave }: { item: WorkResultRow; // ======================================== function InputItem({ item, disabled, saving, onSave }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) { + const isNumber = item.input_type === "number"; const [inputVal, setInputVal] = useState(item.result_value ?? ""); - const inputType = item.input_type === "number" ? "number" : "text"; + const [keypadOpen, setKeypadOpen] = useState(false); const handleBlur = () => { if (!inputVal || disabled) return; @@ -3466,7 +3811,34 @@ function InputItem({ item, disabled, saving, onSave }: { item: WorkResultRow; di
{item.detail_label || item.detail_content}
- setInputVal(e.target.value)} onBlur={handleBlur} disabled={disabled} placeholder="값 입력" /> + {isNumber ? ( + <> + + setKeypadOpen(false)} + onConfirm={(val) => { + setInputVal(val); + onSave(item.id, val, null, "completed"); + }} + title={item.detail_label || item.detail_content} + unit={item.unit ?? undefined} + initialValue={inputVal} + /> + + ) : ( + setInputVal(e.target.value)} onBlur={handleBlur} disabled={disabled} placeholder="값 입력" /> + )} {saving && }
@@ -3489,15 +3861,20 @@ function ProcedureItem({ item, disabled, saving, onSave }: { item: WorkResultRow {item.is_required === "Y" && 필수}
- { - onSave(item.id, v ? "Y" : "N", null, v ? "completed" : "pending"); + { + if (disabled) return; + onSave(item.id, checked ? "N" : "Y", null, checked ? "pending" : "completed"); }} - /> - 확인 + disabled={disabled} + style={{ + minHeight: 56, minWidth: 130, fontSize: 18, + ...(checked ? { boxShadow: '0 0 0 3px #22c55e, 0 4px 12px rgba(34,197,94,0.3), inset 0 1px 0 rgba(255,255,255,0.4)' } : {}), + }} + > + {checked ? "확인됨" : "확인"} + {saving && }