Files
vexplor_dev/frontend/components/pop/hardcoded/inbound/NumberPadModal.tsx
SeongHyun Kim 9b7b88ff7c
Some checks failed
Build and Push Images / build-and-push (push) Failing after 1m30s
feat: POP 하드코딩 화면 추가 (PC 코드 무변경 재병합)
- POP 전용 39개 파일 추가 (홈/입고/출고/생산)
- 백엔드 INSERT에 id gen_random_uuid 추가 (5개 파일)
- POP 전용 API 7개 추가 (창고/위치/입고/동기화)
- PC 코드 구조/순서/로직 변경 없음 (AppLayout, UserDropdown 미수정)
2026-04-02 17:39:42 +09:00

461 lines
17 KiB
TypeScript

"use client";
import React, { useState, useCallback, useEffect } from "react";
import type { PackageUnit } from "./PackagingModal";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export interface PackageEntry {
unit: PackageUnit;
count: number;
qtyPerUnit: number;
}
interface NumberPadModalProps {
open: boolean;
onClose: () => void;
onConfirm: (qty: number, packages: PackageEntry[]) => void;
maxQty: number;
itemName: string;
initialQty?: number;
initialPackages?: PackageEntry[];
}
/* ------------------------------------------------------------------ */
/* Packaging options (inline, no separate modal needed) */
/* ------------------------------------------------------------------ */
const PACKAGE_UNITS: PackageUnit[] = [
{ label: "박스", icon: "\uD83D\uDCE6", value: "box" },
{ label: "포대", icon: "\uD83D\uDECD\uFE0F", value: "bag" },
{ label: "팩", icon: "\uD83D\uDCCB", value: "pack" },
{ label: "묶음", icon: "\uD83D\uDD17", value: "bundle" },
{ label: "롤", icon: "\uD83E\uDDFB", value: "roll" },
{ label: "통", icon: "\uD83E\uDEB3", value: "barrel" },
];
type Step = "packaging" | "pkg-count" | "qty-per-pkg" | "direct-qty" | "confirm";
/* ------------------------------------------------------------------ */
/* Numpad Keys */
/* ------------------------------------------------------------------ */
const 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: "MAX", action: "max" },
];
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function NumberPadModal({
open,
onClose,
onConfirm,
maxQty,
itemName,
initialQty,
initialPackages,
}: NumberPadModalProps) {
const [step, setStep] = useState<Step>("packaging");
const [selectedUnit, setSelectedUnit] = useState<PackageUnit | null>(null);
const [pkgCount, setPkgCount] = useState("0");
const [qtyPerPkg, setQtyPerPkg] = useState("0");
const [directQty, setDirectQty] = useState("0");
/* Reset on open */
useEffect(() => {
if (open) {
if (initialPackages && initialPackages.length > 0) {
// Editing existing -- go to confirm
const p = initialPackages[0];
setSelectedUnit(p.unit);
setPkgCount(String(p.count));
setQtyPerPkg(String(p.qtyPerUnit));
setDirectQty("0");
setStep("confirm");
} else if (initialQty !== undefined && initialQty > 0) {
setDirectQty(String(initialQty));
setSelectedUnit(null);
setStep("confirm");
} else {
setStep("packaging");
setSelectedUnit(null);
setPkgCount("0");
setQtyPerPkg("0");
setDirectQty("0");
}
}
}, [open, initialQty, initialPackages]);
/* Computed values */
const pkgCountNum = parseInt(pkgCount, 10) || 0;
const qtyPerPkgNum = parseInt(qtyPerPkg, 10) || 0;
const directQtyNum = parseInt(directQty, 10) || 0;
const totalQty = selectedUnit ? pkgCountNum * qtyPerPkgNum : directQtyNum;
const isOverMax = totalQty > maxQty;
/* Generic numpad input handler */
const handleInput = useCallback(
(key: string, setter: React.Dispatch<React.SetStateAction<string>>, max?: number) => {
setter((prev) => {
switch (key) {
case "backspace":
return prev.length <= 1 ? "0" : prev.slice(0, -1);
case "clear":
return "0";
case "max":
return String(max ?? maxQty);
default: {
const next = prev === "0" ? key : prev + key;
const num = parseInt(next, 10);
if (isNaN(num)) return prev;
if (max !== undefined) return String(Math.min(num, max));
return next;
}
}
});
},
[maxQty]
);
/* Step handlers */
const handleSelectPackaging = (unit: PackageUnit) => {
setSelectedUnit(unit);
setPkgCount("0");
setQtyPerPkg("0");
setStep("pkg-count");
};
const handleSkipPackaging = () => {
setSelectedUnit(null);
setDirectQty("0");
setStep("direct-qty");
};
const handlePkgCountConfirm = () => {
if (pkgCountNum <= 0) return;
setStep("qty-per-pkg");
};
const handleQtyPerPkgConfirm = () => {
if (qtyPerPkgNum <= 0) return;
setStep("confirm");
};
const handleDirectQtyConfirm = () => {
if (directQtyNum <= 0) return;
setStep("confirm");
};
const handleEdit = () => {
if (selectedUnit) {
setStep("packaging");
} else {
setStep("direct-qty");
}
};
const handleFinalConfirm = () => {
if (totalQty <= 0) return;
const finalQty = Math.min(totalQty, maxQty);
const packages: PackageEntry[] = selectedUnit
? [{ unit: selectedUnit, count: pkgCountNum, qtyPerUnit: qtyPerPkgNum }]
: [];
onConfirm(finalQty, packages);
onClose();
};
if (!open) return null;
/* Render numpad grid */
const renderNumpad = (
currentValue: string,
onKey: (key: string) => void,
onConfirmStep: () => void,
confirmLabel: string,
confirmDisabled: boolean,
) => (
<>
{/* Display */}
<input
type="text"
readOnly
value={parseInt(currentValue, 10).toLocaleString()}
className="w-full px-4 py-3 text-right text-3xl font-bold border-2 border-gray-200 rounded-xl bg-gray-50 text-gray-900 mb-3"
style={{ fontVariantNumeric: "tabular-nums" }}
/>
{/* Numpad grid: 4x3 + bottom row */}
<div className="grid grid-cols-4 gap-2.5">
{KEYS.map((key) => (
<button
key={key.action}
onClick={() => onKey(key.action)}
className={`h-14 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 === "max"
? "bg-blue-100 text-blue-700 hover:bg-blue-200 text-sm"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
}`}
>
{key.label}
</button>
))}
{/* Bottom row: 0 (span 2) + Confirm (span 2) */}
<button
onClick={() => onKey("0")}
className="col-span-2 h-14 rounded-xl text-lg font-semibold bg-gray-100 text-gray-900 hover:bg-gray-200 active:scale-95 transition-all"
>
0
</button>
<button
onClick={onConfirmStep}
disabled={confirmDisabled}
className={`col-span-2 h-14 rounded-xl text-lg font-bold text-white active:scale-95 transition-all ${
confirmDisabled ? "opacity-40 cursor-not-allowed" : ""
}`}
style={{
background: confirmDisabled
? "#9ca3af"
: "linear-gradient(135deg, #10b981 0%, #059669 100%)",
}}
>
{confirmLabel}
</button>
</div>
</>
);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Overlay */}
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
{/* Panel */}
<div className="relative bg-white w-[90%] max-w-[360px] rounded-2xl shadow-2xl z-10 overflow-hidden">
{/* Header - blue gradient */}
<div
className="flex items-center justify-between px-4 py-3"
style={{ background: "linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)" }}
>
<div className="flex items-center gap-2">
{step !== "packaging" && (
<button
onClick={() => {
if (step === "pkg-count") setStep("packaging");
else if (step === "qty-per-pkg") setStep("pkg-count");
else if (step === "direct-qty") setStep("packaging");
else if (step === "confirm") {
if (selectedUnit) setStep("qty-per-pkg");
else setStep("direct-qty");
}
}}
className="w-8 h-8 rounded-lg bg-white/20 flex items-center justify-center text-white hover:bg-white/30 active:scale-95 transition-all"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
)}
<span className="text-[13px] text-white/90 bg-white/20 px-3 py-1 rounded-full">
{maxQty.toLocaleString()} EA
</span>
</div>
{/* Step indicator */}
<div className="flex items-center gap-1.5">
{(selectedUnit
? ["packaging", "pkg-count", "qty-per-pkg", "confirm"] as Step[]
: ["packaging", "direct-qty", "confirm"] as Step[]
).map((s) => (
<div
key={s}
className={`w-2 h-2 rounded-full transition-all ${
s === step ? "bg-white scale-125" : "bg-white/30"
}`}
/>
))}
</div>
</div>
{/* Body */}
<div className="p-4">
{/* ====== Step 1: Packaging Selection ====== */}
{step === "packaging" && (
<>
<p className="text-center text-sm font-semibold text-gray-700 mb-4">
</p>
<div className="grid grid-cols-3 gap-3 mb-3">
{PACKAGE_UNITS.map((unit) => (
<button
key={unit.value}
onClick={() => handleSelectPackaging(unit)}
className="flex flex-col items-center gap-2 py-4 px-3 border-2 border-gray-200 rounded-xl bg-white text-sm font-medium text-gray-700 hover:border-blue-400 hover:bg-blue-50 active:scale-95 transition-all min-h-[80px]"
>
<span className="text-2xl">{unit.icon}</span>
<span>{unit.label}</span>
</button>
))}
</div>
{/* Skip button */}
<button
onClick={handleSkipPackaging}
className="w-full py-3.5 rounded-xl border-2 border-dashed border-gray-300 text-sm font-medium text-gray-500 hover:border-gray-400 hover:bg-gray-50 active:scale-[0.98] transition-all flex items-center justify-center gap-2"
>
<span className="text-lg">{"\u23ED\uFE0F"}</span>
( )
</button>
</>
)}
{/* ====== Step 2: Package Count ====== */}
{step === "pkg-count" && selectedUnit && (
<>
<p className="text-center text-sm font-semibold text-gray-700 mb-1">
{selectedUnit.icon} {selectedUnit.label} ?
</p>
<p className="text-center text-xs text-gray-400 mb-3">
</p>
{renderNumpad(
pkgCount,
(key) => handleInput(key, setPkgCount, 9999),
handlePkgCountConfirm,
"다음",
pkgCountNum <= 0,
)}
</>
)}
{/* ====== Step 3: Qty per Package ====== */}
{step === "qty-per-pkg" && selectedUnit && (
<>
<p className="text-center text-sm font-semibold text-gray-700 mb-1">
{selectedUnit.icon} {selectedUnit.label} 1 ?
</p>
<p className="text-center text-xs text-gray-400 mb-3">
(EA)
</p>
{renderNumpad(
qtyPerPkg,
(key) => handleInput(key, setQtyPerPkg),
handleQtyPerPkgConfirm,
"다음",
qtyPerPkgNum <= 0,
)}
{/* Live calculation preview */}
{pkgCountNum > 0 && qtyPerPkgNum > 0 && (
<div className={`mt-3 text-center text-sm font-semibold px-3 py-2 rounded-lg ${
pkgCountNum * qtyPerPkgNum > maxQty
? "bg-red-50 text-red-600 border border-red-200"
: "bg-blue-50 text-blue-700 border border-blue-200"
}`}>
{pkgCountNum}{selectedUnit.label} x {qtyPerPkgNum.toLocaleString()}EA = {(pkgCountNum * qtyPerPkgNum).toLocaleString()}EA
{pkgCountNum * qtyPerPkgNum > maxQty && (
<span className="block text-xs mt-0.5"> ({maxQty.toLocaleString()}EA) </span>
)}
</div>
)}
</>
)}
{/* ====== Step: Direct Qty (skip packaging) ====== */}
{step === "direct-qty" && (
<>
<p className="text-center text-sm font-semibold text-gray-700 mb-1">
</p>
<p className="text-center text-xs text-gray-400 mb-3">
(EA)
</p>
{renderNumpad(
directQty,
(key) => handleInput(key, setDirectQty, maxQty),
handleDirectQtyConfirm,
"다음",
directQtyNum <= 0,
)}
</>
)}
{/* ====== Step: Confirm ====== */}
{step === "confirm" && (
<>
<div className="flex flex-col items-center gap-3 py-4">
<p className="text-sm font-semibold text-gray-500"> </p>
{selectedUnit ? (
<div className="w-full bg-gradient-to-br from-green-50 to-emerald-50 border border-green-200 rounded-xl p-4 text-center">
<p className="text-3xl mb-2">{selectedUnit.icon}</p>
<p className="text-lg font-bold text-gray-900">
{pkgCountNum}{selectedUnit.label} x {qtyPerPkgNum.toLocaleString()}EA
</p>
<p className={`text-2xl font-black mt-1 ${isOverMax ? "text-red-500" : "text-green-600"}`}
style={{ fontVariantNumeric: "tabular-nums" }}
>
= {totalQty.toLocaleString()} EA
</p>
{isOverMax && (
<p className="text-xs text-red-500 mt-1 font-medium">
({maxQty.toLocaleString()}EA) . {maxQty.toLocaleString()}EA로 .
</p>
)}
</div>
) : (
<div className="w-full bg-gradient-to-br from-blue-50 to-indigo-50 border border-blue-200 rounded-xl p-4 text-center">
<p className="text-sm text-gray-500 mb-1"> </p>
<p className="text-3xl font-black text-blue-600"
style={{ fontVariantNumeric: "tabular-nums" }}
>
{directQtyNum.toLocaleString()} EA
</p>
</div>
)}
<p className="text-xs text-gray-400 truncate max-w-full px-2">
{itemName}
</p>
</div>
{/* Action buttons */}
<div className="flex gap-3">
<button
onClick={handleEdit}
className="flex-1 h-14 rounded-xl text-base font-semibold bg-gray-100 text-gray-700 hover:bg-gray-200 active:scale-95 transition-all"
>
</button>
<button
onClick={handleFinalConfirm}
className="flex-1 h-14 rounded-xl text-base font-bold text-white active:scale-95 transition-all"
style={{
background: "linear-gradient(135deg, #10b981 0%, #059669 100%)",
}}
>
</button>
</div>
</>
)}
</div>
</div>
</div>
);
}