Some checks failed
Build and Push Images / build-and-push (push) Failing after 1m30s
- POP 전용 39개 파일 추가 (홈/입고/출고/생산) - 백엔드 INSERT에 id gen_random_uuid 추가 (5개 파일) - POP 전용 API 7개 추가 (창고/위치/입고/동기화) - PC 코드 구조/순서/로직 변경 없음 (AppLayout, UserDropdown 미수정)
461 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|