feat: POP 작업상세 체크리스트 터치 UX 개선
- 숫자 키패드 모달 추가 (NumericKeypadModal) — 64x64px 버튼 - input_type별 분기 렌더링: * checkbox: 큰 터치 토글 버튼 (확인/미확인) 56px+ * number: 터치 시 숫자 키패드 모달 (기준치 ±범위 표시) * select: 큰 선택 버튼 56px+ * text: 입력칸 높이 56px로 키움 - MaterialQtyInput 컴포넌트: 자재 투입 수량도 키패드 모달 사용 - 모든 터치 영역 ISA-101 기준 (56px+) 적용
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user