452 lines
17 KiB
TypeScript
452 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useMemo } from "react";
|
|
import { Delete, Trash2, Plus, ArrowLeft } from "lucide-react";
|
|
import {
|
|
Dialog,
|
|
DialogPortal,
|
|
DialogOverlay,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
|
import {
|
|
PackageUnitModal,
|
|
PACKAGE_UNITS,
|
|
} from "./PackageUnitModal";
|
|
import type { CardPackageConfig, PackageEntry } from "../types";
|
|
|
|
type InputStep =
|
|
| "quantity" // 기본: 직접 수량 입력 (포장 OFF)
|
|
| "package_count" // 포장: 포장 수량 (N개)
|
|
| "quantity_per_unit" // 포장: 개당 수량 (M EA)
|
|
| "summary"; // 포장: 결과 확인 + 추가/완료
|
|
|
|
interface NumberInputModalProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
unit?: string;
|
|
initialValue?: number;
|
|
initialPackageUnit?: string;
|
|
min?: number;
|
|
maxValue?: number;
|
|
/** @deprecated packageConfig 사용 */
|
|
showPackageUnit?: boolean;
|
|
packageConfig?: CardPackageConfig;
|
|
onConfirm: (value: number, packageUnit?: string, packageEntries?: PackageEntry[]) => void;
|
|
}
|
|
|
|
export function NumberInputModal({
|
|
open,
|
|
onOpenChange,
|
|
unit = "EA",
|
|
initialValue = 0,
|
|
initialPackageUnit,
|
|
min = 0,
|
|
maxValue = 999999,
|
|
showPackageUnit,
|
|
packageConfig,
|
|
onConfirm,
|
|
}: NumberInputModalProps) {
|
|
const [displayValue, setDisplayValue] = useState("");
|
|
const [step, setStep] = useState<InputStep>("quantity");
|
|
const [isPackageModalOpen, setIsPackageModalOpen] = useState(false);
|
|
|
|
// 포장 2단계 플로우용 상태
|
|
const [selectedUnit, setSelectedUnit] = useState<{ id: string; label: string } | null>(null);
|
|
const [packageCount, setPackageCount] = useState(0);
|
|
const [entries, setEntries] = useState<PackageEntry[]>([]);
|
|
|
|
const isPackageEnabled = packageConfig?.enabled ?? showPackageUnit ?? true;
|
|
const showSummary = packageConfig?.showSummaryMessage !== false;
|
|
|
|
const entriesTotal = useMemo(
|
|
() => entries.reduce((sum, e) => sum + e.totalQuantity, 0),
|
|
[entries]
|
|
);
|
|
const remainingQuantity = Math.max(0, maxValue - entriesTotal);
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
setDisplayValue(initialValue > 0 ? String(initialValue) : "");
|
|
setStep("quantity");
|
|
setSelectedUnit(null);
|
|
setPackageCount(0);
|
|
setEntries([]);
|
|
}
|
|
}, [open, initialValue]);
|
|
|
|
// --- 키패드 핸들러 ---
|
|
|
|
const currentMax = step === "quantity"
|
|
? maxValue
|
|
: step === "package_count"
|
|
? 9999
|
|
: step === "quantity_per_unit"
|
|
? Math.max(1, remainingQuantity)
|
|
: maxValue;
|
|
|
|
const handleNumberClick = (num: string) => {
|
|
const newStr = displayValue + num;
|
|
const numericValue = parseInt(newStr, 10);
|
|
setDisplayValue(numericValue > currentMax ? String(currentMax) : newStr);
|
|
};
|
|
|
|
const handleBackspace = () =>
|
|
setDisplayValue((prev) => prev.slice(0, -1));
|
|
const handleClear = () => setDisplayValue("");
|
|
const handleMax = () => setDisplayValue(String(currentMax));
|
|
|
|
// --- 확인 버튼: step에 따라 다르게 동작 ---
|
|
|
|
const handleConfirm = () => {
|
|
const numericValue = parseInt(displayValue, 10) || 0;
|
|
|
|
if (step === "quantity") {
|
|
const finalValue = Math.max(min, Math.min(maxValue, numericValue));
|
|
onConfirm(finalValue, undefined, undefined);
|
|
onOpenChange(false);
|
|
return;
|
|
}
|
|
|
|
if (step === "package_count") {
|
|
if (numericValue <= 0) return;
|
|
setPackageCount(numericValue);
|
|
setDisplayValue("");
|
|
setStep("quantity_per_unit");
|
|
return;
|
|
}
|
|
|
|
if (step === "quantity_per_unit") {
|
|
if (numericValue <= 0 || !selectedUnit) return;
|
|
const total = Math.min(packageCount * numericValue, remainingQuantity);
|
|
const newEntry: PackageEntry = {
|
|
unitId: selectedUnit.id,
|
|
unitLabel: selectedUnit.label,
|
|
packageCount,
|
|
quantityPerUnit: numericValue,
|
|
totalQuantity: total,
|
|
};
|
|
setEntries((prev) => [...prev, newEntry]);
|
|
setDisplayValue("");
|
|
setStep("summary");
|
|
return;
|
|
}
|
|
};
|
|
|
|
// --- 포장 단위 선택 콜백 ---
|
|
|
|
const handlePackageUnitSelect = (unitId: string) => {
|
|
const matched = PACKAGE_UNITS.find((u) => u.value === unitId);
|
|
const matchedCustom = packageConfig?.customUnits?.find((cu) => cu.id === unitId);
|
|
const label = matched?.label ?? matchedCustom?.label ?? unitId;
|
|
|
|
setSelectedUnit({ id: unitId, label });
|
|
setDisplayValue("");
|
|
setStep("package_count");
|
|
};
|
|
|
|
// --- summary 액션 ---
|
|
|
|
const handleAddMore = () => {
|
|
setIsPackageModalOpen(true);
|
|
};
|
|
|
|
const handleRemoveEntry = (index: number) => {
|
|
setEntries((prev) => prev.filter((_, i) => i !== index));
|
|
};
|
|
|
|
const handleComplete = () => {
|
|
if (entries.length === 0) return;
|
|
const total = entries.reduce((sum, e) => sum + e.totalQuantity, 0);
|
|
const lastUnit = entries[entries.length - 1].unitId;
|
|
onConfirm(total, lastUnit, entries);
|
|
onOpenChange(false);
|
|
};
|
|
|
|
const handleBack = () => {
|
|
if (step === "package_count") {
|
|
setStep("quantity");
|
|
setSelectedUnit(null);
|
|
setDisplayValue("");
|
|
} else if (step === "quantity_per_unit") {
|
|
setStep("package_count");
|
|
setDisplayValue(String(packageCount));
|
|
} else if (step === "summary") {
|
|
if (entries.length > 0) {
|
|
const last = entries[entries.length - 1];
|
|
setEntries((prev) => prev.slice(0, -1));
|
|
setSelectedUnit({ id: last.unitId, label: last.unitLabel });
|
|
setPackageCount(last.packageCount);
|
|
setDisplayValue(String(last.quantityPerUnit));
|
|
setStep("quantity_per_unit");
|
|
} else {
|
|
setStep("quantity");
|
|
setDisplayValue("");
|
|
}
|
|
}
|
|
};
|
|
|
|
// --- 안내 메시지 ---
|
|
|
|
const guideMessage = useMemo(() => {
|
|
switch (step) {
|
|
case "quantity":
|
|
return "수량을 입력하세요";
|
|
case "package_count":
|
|
return `${selectedUnit?.label || "포장"}을(를) 몇 개 사용하시나요?`;
|
|
case "quantity_per_unit":
|
|
return `${selectedUnit?.label || "포장"} 1개에 몇 ${unit} 넣으시나요?`;
|
|
case "summary":
|
|
return "";
|
|
default:
|
|
return "";
|
|
}
|
|
}, [step, selectedUnit, unit]);
|
|
|
|
// --- 헤더 정보 ---
|
|
|
|
const headerLabel = useMemo(() => {
|
|
if (step === "summary") {
|
|
return `등록: ${entriesTotal.toLocaleString()} ${unit} / 남은: ${remainingQuantity.toLocaleString()} ${unit}`;
|
|
}
|
|
if (entries.length > 0) {
|
|
return `남은 ${remainingQuantity.toLocaleString()} ${unit}`;
|
|
}
|
|
return `최대 ${maxValue.toLocaleString()} ${unit}`;
|
|
}, [step, entriesTotal, remainingQuantity, maxValue, unit, entries.length]);
|
|
|
|
const displayText = displayValue
|
|
? parseInt(displayValue, 10).toLocaleString()
|
|
: "";
|
|
|
|
const isBackVisible = step !== "quantity";
|
|
|
|
return (
|
|
<>
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogPortal>
|
|
<DialogOverlay />
|
|
<DialogPrimitive.Content
|
|
className="bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[1000] w-full max-w-[95vw] translate-x-[-50%] translate-y-[-50%] overflow-hidden border shadow-lg duration-200 sm:max-w-[360px] sm:rounded-lg"
|
|
>
|
|
<VisuallyHidden><DialogTitle>수량 입력</DialogTitle></VisuallyHidden>
|
|
{/* 헤더 */}
|
|
<div className="flex items-center justify-between bg-blue-500 px-4 py-3">
|
|
<div className="flex items-center gap-2">
|
|
{isBackVisible && (
|
|
<button
|
|
type="button"
|
|
onClick={handleBack}
|
|
className="flex items-center justify-center rounded-full bg-white/20 p-1.5 text-white hover:bg-white/30 active:bg-white/40"
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
<span className="rounded-full bg-blue-400/50 px-3 py-1 text-sm font-medium text-white">
|
|
{headerLabel}
|
|
</span>
|
|
</div>
|
|
{isPackageEnabled && step === "quantity" && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsPackageModalOpen(true)}
|
|
className="flex items-center gap-1 rounded-full bg-white/20 px-3 py-1.5 text-sm font-medium text-white hover:bg-white/30 active:bg-white/40"
|
|
>
|
|
포장등록
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-3 p-4">
|
|
{/* summary 단계: 포장 내역 리스트 */}
|
|
{step === "summary" ? (
|
|
<div className="space-y-3">
|
|
{/* 안내 메시지 - 마지막 등록 결과 */}
|
|
{showSummary && entries.length > 0 && (
|
|
<div className="rounded-lg bg-blue-50 px-3 py-2 text-center text-sm font-medium text-blue-700">
|
|
{(() => {
|
|
const last = entries[entries.length - 1];
|
|
return `${last.packageCount}${last.unitLabel} x ${last.quantityPerUnit}${unit} = ${last.totalQuantity.toLocaleString()}${unit}`;
|
|
})()}
|
|
</div>
|
|
)}
|
|
|
|
{/* 포장 내역 리스트 */}
|
|
<div className="space-y-1.5">
|
|
<p className="text-xs font-medium text-gray-500">포장 내역</p>
|
|
{entries.map((entry, idx) => (
|
|
<div
|
|
key={idx}
|
|
className="flex items-center justify-between rounded-lg border border-gray-200 bg-gray-50 px-3 py-2"
|
|
>
|
|
<span className="text-sm text-gray-700">
|
|
{entry.packageCount}{entry.unitLabel} x {entry.quantityPerUnit}{unit} = {entry.totalQuantity.toLocaleString()}{unit}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleRemoveEntry(idx)}
|
|
className="rounded-full p-1 text-gray-400 hover:bg-red-50 hover:text-red-500"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* 합계 */}
|
|
<div className="flex items-center justify-between rounded-lg bg-green-50 px-3 py-2">
|
|
<span className="text-sm font-medium text-green-700">합계</span>
|
|
<span className="text-lg font-bold text-green-700">
|
|
{entriesTotal.toLocaleString()} {unit}
|
|
</span>
|
|
</div>
|
|
|
|
{/* 남은 수량 */}
|
|
{remainingQuantity > 0 && (
|
|
<div className="flex items-center justify-between rounded-lg bg-amber-50 px-3 py-2">
|
|
<span className="text-sm font-medium text-amber-700">남은 수량</span>
|
|
<span className="text-sm font-bold text-amber-700">
|
|
{remainingQuantity.toLocaleString()} {unit}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* 액션 버튼 */}
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{remainingQuantity > 0 && (
|
|
<button
|
|
type="button"
|
|
className="flex h-12 items-center justify-center gap-1.5 rounded-2xl border border-blue-200 bg-blue-50 text-sm font-bold text-blue-600 active:bg-blue-100"
|
|
onClick={handleAddMore}
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
추가 포장
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className={`flex h-12 items-center justify-center rounded-2xl bg-green-500 text-base font-bold text-white active:bg-green-600 ${remainingQuantity <= 0 ? "col-span-2" : ""}`}
|
|
onClick={handleComplete}
|
|
>
|
|
완료
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* 숫자 표시 영역 */}
|
|
<div className="flex min-h-[72px] items-center justify-end rounded-xl border-2 border-gray-200 bg-gray-50 px-4 py-4">
|
|
{displayText ? (
|
|
<span className="text-4xl font-bold tracking-tight text-gray-900">
|
|
{displayText}
|
|
</span>
|
|
) : (
|
|
<span className="text-2xl text-gray-300">0</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* 단계별 안내 텍스트 */}
|
|
<p className="text-muted-foreground text-center text-sm">
|
|
{guideMessage}
|
|
</p>
|
|
|
|
{/* 키패드 4x4 */}
|
|
<div className="grid grid-cols-4 gap-2">
|
|
{["7", "8", "9"].map((n) => (
|
|
<button
|
|
key={n}
|
|
type="button"
|
|
className="h-14 rounded-2xl border border-gray-200 bg-gray-50 text-xl font-semibold text-gray-800 active:bg-gray-200"
|
|
onClick={() => handleNumberClick(n)}
|
|
>
|
|
{n}
|
|
</button>
|
|
))}
|
|
<button
|
|
type="button"
|
|
className="flex h-14 items-center justify-center rounded-2xl bg-amber-100 text-amber-600 active:bg-amber-200"
|
|
onClick={handleBackspace}
|
|
>
|
|
<Delete className="h-5 w-5" />
|
|
</button>
|
|
|
|
{["4", "5", "6"].map((n) => (
|
|
<button
|
|
key={n}
|
|
type="button"
|
|
className="h-14 rounded-2xl border border-gray-200 bg-gray-50 text-xl font-semibold text-gray-800 active:bg-gray-200"
|
|
onClick={() => handleNumberClick(n)}
|
|
>
|
|
{n}
|
|
</button>
|
|
))}
|
|
<button
|
|
type="button"
|
|
className="h-14 rounded-2xl bg-amber-100 text-base font-bold text-amber-600 active:bg-amber-200"
|
|
onClick={handleClear}
|
|
>
|
|
C
|
|
</button>
|
|
|
|
{["1", "2", "3"].map((n) => (
|
|
<button
|
|
key={n}
|
|
type="button"
|
|
className="h-14 rounded-2xl border border-gray-200 bg-gray-50 text-xl font-semibold text-gray-800 active:bg-gray-200"
|
|
onClick={() => handleNumberClick(n)}
|
|
>
|
|
{n}
|
|
</button>
|
|
))}
|
|
<button
|
|
type="button"
|
|
className="h-14 rounded-2xl bg-blue-100 text-sm font-bold text-blue-600 active:bg-blue-200"
|
|
onClick={handleMax}
|
|
>
|
|
MAX
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
className="h-14 rounded-2xl border border-gray-200 bg-gray-50 text-xl font-semibold text-gray-800 active:bg-gray-200"
|
|
onClick={() => handleNumberClick("0")}
|
|
>
|
|
0
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="col-span-3 h-14 rounded-2xl bg-green-500 text-base font-bold text-white active:bg-green-600"
|
|
onClick={handleConfirm}
|
|
>
|
|
{step === "package_count" ? "다음" : "확인"}
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
</DialogPrimitive.Content>
|
|
</DialogPortal>
|
|
</Dialog>
|
|
|
|
{/* 포장 단위 선택 모달 */}
|
|
<PackageUnitModal
|
|
open={isPackageModalOpen}
|
|
onOpenChange={(isOpen) => {
|
|
setIsPackageModalOpen(isOpen);
|
|
if (!isOpen && step === "summary") {
|
|
// summary에서 추가 포장 모달 닫힘 -> 단위 선택 안 한 경우 유지
|
|
}
|
|
}}
|
|
onSelect={(unitId) => {
|
|
handlePackageUnitSelect(unitId);
|
|
setIsPackageModalOpen(false);
|
|
}}
|
|
enabledUnits={packageConfig?.enabledUnits}
|
|
customUnits={packageConfig?.customUnits}
|
|
/>
|
|
</>
|
|
);
|
|
}
|