feat: POP 작업상세 체크리스트 터치 UX 개선

- 숫자 키패드 모달 추가 (NumericKeypadModal) — 64x64px 버튼
- input_type별 분기 렌더링:
  * checkbox: 큰 터치 토글 버튼 (확인/미확인) 56px+
  * number: 터치 시 숫자 키패드 모달 (기준치 ±범위 표시)
  * select: 큰 선택 버튼 56px+
  * text: 입력칸 높이 56px로 키움
- MaterialQtyInput 컴포넌트: 자재 투입 수량도 키패드 모달 사용
- 모든 터치 영역 ISA-101 기준 (56px+) 적용
This commit is contained in:
SeongHyun Kim
2026-04-07 11:25:01 +09:00
parent 3929ec17f2
commit 9361b2484a
@@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
<div className="relative bg-white w-[90%] max-w-[360px] rounded-2xl shadow-2xl z-10 overflow-hidden">
{/* Header */}
<div
className="flex items-center justify-between px-4 py-3"
style={{ background: "linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)" }}
>
<span className="text-sm font-semibold text-white truncate">
{title || "숫자 입력"}
</span>
<button
onClick={onClose}
className="w-8 h-8 rounded-lg bg-white/20 flex items-center justify-center text-white hover:bg-white/30"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="p-4">
{/* Range indicator */}
{hasRange && (
<div className={cn(
"mb-3 text-center text-sm font-semibold px-3 py-2 rounded-lg",
isOutOfRange
? "bg-red-50 text-red-600 border border-red-200"
: "bg-blue-50 text-blue-700 border border-blue-200"
)}>
: {lowerLimit} ~ {upperLimit}{unit ? ` ${unit}` : ""}
</div>
)}
{/* Display */}
<div className="relative mb-3">
<input
type="text"
readOnly
value={value === "0" ? "0" : value}
className={cn(
"w-full px-4 py-3 text-right text-3xl font-bold border-2 rounded-xl bg-gray-50",
isOutOfRange ? "border-red-300 text-red-600" : "border-gray-200 text-gray-900"
)}
style={{ fontVariantNumeric: "tabular-nums" }}
/>
{unit && (
<span className="absolute right-14 top-1/2 -translate-y-1/2 text-gray-400 text-lg">{unit}</span>
)}
</div>
{/* Numpad grid */}
<div className="grid grid-cols-4 gap-2.5">
{NUMPAD_KEYS.map((key) => (
<button
key={key.action}
onClick={() => handleKey(key.action)}
className={`h-16 rounded-xl text-lg font-semibold active:scale-95 transition-all ${
key.action === "backspace" || key.action === "clear"
? "bg-amber-100 text-amber-700 hover:bg-amber-200"
: key.action === "dot"
? "bg-gray-200 text-gray-700 hover:bg-gray-300"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
}`}
style={{ minWidth: 64, minHeight: 64 }}
>
{key.label}
</button>
))}
{/* Bottom row: 0 (span 2) + Confirm (span 2) */}
<button
onClick={() => handleKey("0")}
className="col-span-2 h-16 rounded-xl text-lg font-semibold bg-gray-100 text-gray-900 hover:bg-gray-200 active:scale-95 transition-all"
style={{ minHeight: 64 }}
>
0
</button>
<button
onClick={handleConfirm}
disabled={isNaN(parseFloat(value))}
className={cn(
"col-span-2 h-16 rounded-xl text-lg font-bold text-white active:scale-95 transition-all",
isNaN(parseFloat(value)) && "opacity-40 cursor-not-allowed"
)}
style={{
minHeight: 64,
background: isNaN(parseFloat(value))
? "#9ca3af"
: isOutOfRange
? "linear-gradient(135deg, #f87171 0%, #dc2626 100%)"
: "linear-gradient(135deg, #10b981 0%, #059669 100%)",
}}
>
{isOutOfRange ? "확인 (범위 외)" : "확인"}
</button>
</div>
</div>
</div>
</div>
);
}
// ========================================
// 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 (
<div className="flex items-center justify-center gap-1.5">
<span className="text-base font-bold text-gray-700">{value || "-"}</span>
<span className="text-xs text-gray-400">{unit}</span>
</div>
);
}
// ±20% 범위
const lower = requiredQty * 0.8;
const upper = requiredQty * 1.2;
return (
<div className="flex items-center justify-center gap-1.5">
<button
type="button"
className="h-14 w-28 rounded-xl border-2 border-gray-200 px-3 text-center text-lg font-bold text-gray-900 transition-colors hover:border-blue-400 active:scale-95"
onClick={() => setKeypadOpen(true)}
>
{value || <span className="text-gray-300">0</span>}
</button>
<span className="text-xs text-gray-400">{unit}</span>
<NumericKeypadModal
open={keypadOpen}
onClose={() => setKeypadOpen(false)}
onConfirm={(val) => onChange(val)}
title={materialName || "자재 투입"}
unit={unit}
initialValue={value || ""}
lowerLimit={lower}
upperLimit={upper}
/>
</div>
);
}
interface MaterialInputSectionProps {
workOrderProcessId: string;
isConfirmed: boolean;
@@ -2369,21 +2607,15 @@ function MaterialInputSection({ workOrderProcessId, isConfirmed }: MaterialInput
<span className="ml-1 text-xs text-gray-400">{mat.unit || "EA"}</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-center gap-1.5">
{isConfirmed ? (
<span className="text-base font-bold text-gray-700">{inputValues[mat.id] || "-"}</span>
) : (
<input
type="number"
inputMode="decimal"
className="h-14 w-28 rounded-xl border-2 border-gray-200 px-3 text-center text-lg font-bold text-gray-900 transition-colors focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100"
value={inputValues[mat.id] || ""}
onChange={(e) => updateInputValue(mat.id, e.target.value)}
placeholder="0"
/>
)}
<span className="text-xs text-gray-400">{mat.unit || "EA"}</span>
</div>
<MaterialQtyInput
materialId={mat.id}
materialName={mat.child_item_name || mat.child_item_code}
unit={mat.unit || "EA"}
requiredQty={mat.required_qty}
value={inputValues[mat.id] || ""}
isConfirmed={isConfirmed}
onChange={(val) => updateInputValue(mat.id, val)}
/>
</td>
<td className="px-4 py-3 text-center">
{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 (
<>
<input
type="number"
className="rounded-xl border-2 border-gray-200 text-center font-bold transition-colors focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100"
style={{ width: 140, height: 48, fontSize: 22, color: item.is_passed === "Y" ? '#16a34a' : item.is_passed === "N" ? '#dc2626' : '#1f2937' }}
value={inputVal}
onChange={(e) => setInputVal(e.target.value)}
onBlur={handleSave}
<button
className={cn(
"rounded-xl border-2 text-center font-bold transition-colors",
disabled ? "bg-gray-50 border-gray-100" : "bg-white border-gray-200 hover:border-blue-400 active:scale-95",
)}
style={{
width: 140, height: 56, fontSize: 22,
color: item.is_passed === "Y" ? '#16a34a' : item.is_passed === "N" ? '#dc2626' : '#1f2937',
}}
onClick={() => !disabled && setKeypadOpen(true)}
disabled={disabled}
placeholder="입력"
/>
>
{displayValue || <span className="text-gray-300"></span>}
</button>
{item.unit && <span className="text-gray-400" style={{ fontSize: 16 }}>{item.unit}</span>}
{item.is_passed === "Y" && !saving && <span className="rounded-lg bg-green-100 px-3 py-1.5 font-bold text-green-700" style={{ fontSize: 15 }}>PASS</span>}
{item.is_passed === "N" && !saving && <span className="rounded-lg bg-red-100 px-3 py-1.5 font-bold text-red-700" style={{ fontSize: 15 }}>FAIL</span>}
{item.status === "completed" && item.is_passed === null && !saving && <span className="font-bold text-green-600" style={{ fontSize: 15 }}></span>}
{!item.status || (item.status !== "completed" && !saving) ? (
<GlossyButton variant="blue" onClick={handleSave} disabled={disabled || !inputVal} style={{ minHeight: 48, minWidth: 80, fontSize: 16 }}>
</GlossyButton>
) : null}
{saving && <Loader2 className="h-5 w-5 animate-spin text-gray-400" />}
<NumericKeypadModal
open={keypadOpen}
onClose={() => 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}
</GlossyButton>
@@ -3063,7 +3308,7 @@ function InspectTextInput({ item, disabled, saving, onSave }: { item: WorkResult
<>
<input
className="flex-1 rounded-xl border-2 border-gray-200 px-3 transition-colors focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100"
style={{ height: 48, fontSize: 16 }}
style={{ height: 56, fontSize: 16 }}
value={inputVal}
onChange={(e) => 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 }}
>
</GlossyButton>
@@ -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 }}
>
</GlossyButton>
@@ -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" && <span className="font-bold text-green-600" style={{ fontSize: 16 }}></span>}
<Checkbox
checked={checked}
disabled={disabled}
className="h-8 w-8"
onCheckedChange={(v) => {
const val = v ? "Y" : "N";
onSave(item.id, val, v ? "Y" : "N", v ? "completed" : "pending");
<GlossyButton
variant={checked ? "green" : "gray"}
onClick={() => {
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)' } : {}),
}}
>
<Check className="h-5 w-5" />
</GlossyButton>
<GlossyButton
variant={!checked && item.result_value === "N" ? "red" : "gray"}
onClick={() => {
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)' } : {}),
}}
>
<X className="h-5 w-5" />
</GlossyButton>
{saving && <Loader2 className="h-5 w-5 animate-spin text-gray-400" />}
</>
);
}
// === 자유입력 (우측 전용) ===
// === 자유입력 (우측 전용) — 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 (
<>
<button
className={cn(
"flex-1 rounded-xl border-2 text-center font-semibold transition-colors",
disabled ? "bg-gray-50 border-gray-100" : "bg-white border-gray-200 hover:border-blue-400 active:scale-95",
)}
style={{ height: 56, fontSize: 18, color: '#1f2937' }}
onClick={() => !disabled && setKeypadOpen(true)}
disabled={disabled}
>
{inputVal || <span className="text-gray-300"> </span>}
</button>
{item.status === "completed" && !saving && <span className="font-bold text-green-600" style={{ fontSize: 15 }}></span>}
{saving && <Loader2 className="h-5 w-5 animate-spin text-gray-400" />}
<NumericKeypadModal
open={keypadOpen}
onClose={() => 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 (
<>
<input
type={inputType}
type="text"
className="flex-1 rounded-xl border-2 border-gray-200 px-3 text-center font-semibold transition-colors focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100"
style={{ height: 48, fontSize: 16 }}
style={{ height: 56, fontSize: 16 }}
value={inputVal}
onChange={(e) => 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 (
<>
<Checkbox
checked={checked}
disabled={disabled}
className="h-5 w-5"
onCheckedChange={(v) => {
onSave(item.id, v ? "Y" : "N", null, v ? "completed" : "pending");
<GlossyButton
variant={checked ? "green" : "gray"}
onClick={() => {
if (disabled) return;
onSave(item.id, checked ? "N" : "Y", null, checked ? "pending" : "completed");
}}
/>
<span className="text-gray-600" style={{ fontSize: 15 }}></span>
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)' } : {}),
}}
>
<Check className="h-5 w-5" /> {checked ? "확인됨" : "확인"}
</GlossyButton>
{saving && <Loader2 className="h-5 w-5 animate-spin text-gray-400" />}
{item.status === "completed" && !saving && <span className="font-bold text-green-600" style={{ fontSize: 15 }}></span>}
</>
);
}
@@ -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 (
<div className={cn("flex items-center gap-3 rounded-lg border px-4 py-3", item.status === "completed" && "border-green-200 bg-green-50")}>
<Checkbox
checked={checked}
disabled={disabled}
className="h-8 w-8"
onCheckedChange={(v) => {
const val = v ? "Y" : "N";
onSave(item.id, val, v ? "Y" : "N", v ? "completed" : "pending");
}}
/>
<span className="flex-1 text-sm">{item.detail_label || item.detail_content}</span>
{item.is_required === "Y" && <span className="text-xs font-semibold text-destructive"></span>}
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
{item.status === "completed" && !saving && <Badge variant="outline" className="text-xs text-green-600"></Badge>}
<div className={cn("rounded-lg border px-4 py-3", item.status === "completed" && "border-green-200 bg-green-50")}>
<div className="mb-2 flex items-center gap-2">
<span className="flex-1 text-sm font-medium">{item.detail_label || item.detail_content}</span>
{item.is_required === "Y" && <span className="text-xs font-semibold text-destructive"></span>}
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
</div>
<div className="flex items-center gap-3">
<GlossyButton
variant={checked ? "green" : "gray"}
onClick={() => {
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)' } : {}),
}}
>
<Check className="h-5 w-5" />
</GlossyButton>
<GlossyButton
variant={!checked && item.result_value === "N" ? "red" : "gray"}
onClick={() => {
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)' } : {}),
}}
>
<X className="h-5 w-5" />
</GlossyButton>
</div>
</div>
);
}
@@ -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 (
<div className={cn("rounded-lg border px-4 py-3", item.is_passed === "Y" && "border-green-200 bg-green-50", item.is_passed === "N" && "border-red-200 bg-red-50")}>
<div className="mb-2 flex items-center justify-between">
@@ -3290,12 +3614,32 @@ function InspectNumeric({ item, disabled, saving, onSave }: { item: WorkResultRo
<div className="mb-2 text-xs text-muted-foreground">{item.inspection_method}</div>
)}
<div className="flex items-center gap-2.5">
<Input type="number" className="w-36" style={{ height: `${DESIGN.input.height}px`, fontSize: `${DESIGN.section.titleSize}px` }} value={inputVal} onChange={(e) => setInputVal(e.target.value)} onBlur={handleBlur} disabled={disabled} placeholder="측정값 입력" />
<button
className={cn(
"w-36 rounded-xl border-2 text-center font-bold transition-colors",
disabled ? "bg-gray-50 border-gray-100" : "bg-white border-gray-200 hover:border-blue-400 active:scale-95",
)}
style={{ height: 56, fontSize: 20, color: '#1f2937' }}
onClick={() => !disabled && setKeypadOpen(true)}
disabled={disabled}
>
{displayValue || <span className="text-gray-300"></span>}
</button>
{item.unit && <span className="text-xs text-muted-foreground">{item.unit}</span>}
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
{item.is_passed === "Y" && !saving && <Badge variant="outline" className="text-xs text-green-600"></Badge>}
{item.is_passed === "N" && !saving && <Badge variant="outline" className="text-xs text-red-600"></Badge>}
</div>
<NumericKeypadModal
open={keypadOpen}
onClose={() => 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}
/>
</div>
);
}
@@ -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
<div className={cn("rounded-lg border px-4 py-3", item.status === "completed" && "border-green-200 bg-green-50")}>
<div className="mb-2 text-sm font-medium">{item.detail_label || item.detail_content}</div>
<div className="flex items-center gap-2.5">
<Input type={inputType} className="flex-1" style={{ height: `${DESIGN.input.height}px`, fontSize: `${DESIGN.section.titleSize}px` }} value={inputVal} onChange={(e) => setInputVal(e.target.value)} onBlur={handleBlur} disabled={disabled} placeholder="값 입력" />
{isNumber ? (
<>
<button
className={cn(
"flex-1 rounded-xl border-2 text-center font-bold transition-colors",
disabled ? "bg-gray-50 border-gray-100" : "bg-white border-gray-200 hover:border-blue-400 active:scale-95",
)}
style={{ height: 56, fontSize: 20, color: '#1f2937' }}
onClick={() => !disabled && setKeypadOpen(true)}
disabled={disabled}
>
{inputVal || <span className="text-gray-300"> </span>}
</button>
<NumericKeypadModal
open={keypadOpen}
onClose={() => 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}
/>
</>
) : (
<Input type="text" className="flex-1" style={{ height: 56, fontSize: `${DESIGN.section.titleSize}px` }} value={inputVal} onChange={(e) => setInputVal(e.target.value)} onBlur={handleBlur} disabled={disabled} placeholder="값 입력" />
)}
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
</div>
</div>
@@ -3489,15 +3861,20 @@ function ProcedureItem({ item, disabled, saving, onSave }: { item: WorkResultRow
{item.is_required === "Y" && <span className="text-xs font-semibold text-destructive"></span>}
</div>
<div className="flex items-center gap-2.5 pl-9">
<Checkbox
checked={checked}
disabled={disabled}
className="h-8 w-8"
onCheckedChange={(v) => {
onSave(item.id, v ? "Y" : "N", null, v ? "completed" : "pending");
<GlossyButton
variant={checked ? "green" : "gray"}
onClick={() => {
if (disabled) return;
onSave(item.id, checked ? "N" : "Y", null, checked ? "pending" : "completed");
}}
/>
<span className="text-sm text-muted-foreground"></span>
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)' } : {}),
}}
>
<Check className="h-5 w-5" /> {checked ? "확인됨" : "확인"}
</GlossyButton>
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
</div>
</div>