ERP-node/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx

449 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,
} from "@/components/ui/dialog";
import * as DialogPrimitive from "@radix-ui/react-dialog";
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 = 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"
? remainingQuantity > 0 ? remainingQuantity : maxValue
: 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 = packageCount * numericValue;
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"
>
{/* 헤더 */}
<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}
/>
</>
);
}