- 홈 화면: KPI 캐러셀, 메뉴 아이콘, 최근 활동, 공지 배너 - 입고유형선택: 외부 7개 + 내부 3개 아이콘, 금일 입고 KPI - 구매입고: 거래처 선택, 발주 품목 카드, 담기/취소 - 장바구니: 체크박스, 포장 정보, 검사 상태, 확정 - 숫자 키패드: 터치 입력, MAX, 포장등록 - 포장 선택: 6종 단위 (박스/포대/팩/묶음/롤/통) - 검사 모달: 마스터 기반 체크리스트, 측정값, 양품/불량 - 공통 PopShell: 헤더(시계+프로필), 배너, 푸터 - 반응형 4모드 (태블릿/핸드폰 가로세로)
315 lines
11 KiB
TypeScript
315 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useCallback, useEffect } from "react";
|
|
import { PackagingModal, 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[];
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Component */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
export function NumberPadModal({
|
|
open,
|
|
onClose,
|
|
onConfirm,
|
|
maxQty,
|
|
itemName,
|
|
initialQty,
|
|
initialPackages,
|
|
}: NumberPadModalProps) {
|
|
const [display, setDisplay] = useState("0");
|
|
const [packages, setPackages] = useState<PackageEntry[]>([]);
|
|
const [packagingModalOpen, setPackagingModalOpen] = useState(false);
|
|
const [pendingUnit, setPendingUnit] = useState<PackageUnit | null>(null);
|
|
const [packQtyInput, setPackQtyInput] = useState("");
|
|
const [packCountInput, setPackCountInput] = useState("");
|
|
|
|
/* Reset on open */
|
|
useEffect(() => {
|
|
if (open) {
|
|
setDisplay(initialQty !== undefined ? String(initialQty) : "0");
|
|
setPackages(initialPackages ?? []);
|
|
setPendingUnit(null);
|
|
setPackQtyInput("");
|
|
setPackCountInput("");
|
|
}
|
|
}, [open, initialQty, initialPackages]);
|
|
|
|
/* Calculate package total */
|
|
const packageTotal = packages.reduce((sum, p) => sum + p.count * p.qtyPerUnit, 0);
|
|
const remainingQty = maxQty - packageTotal;
|
|
|
|
/* Numpad input handler */
|
|
const handleInput = useCallback(
|
|
(key: string) => {
|
|
setDisplay((prev) => {
|
|
switch (key) {
|
|
case "backspace":
|
|
return prev.length <= 1 ? "0" : prev.slice(0, -1);
|
|
case "clear":
|
|
return "0";
|
|
case "max":
|
|
return String(maxQty);
|
|
default: {
|
|
// digit
|
|
const next = prev === "0" ? key : prev + key;
|
|
const num = parseInt(next, 10);
|
|
if (isNaN(num)) return prev;
|
|
return String(Math.min(num, maxQty));
|
|
}
|
|
}
|
|
});
|
|
},
|
|
[maxQty]
|
|
);
|
|
|
|
/* Confirm */
|
|
const handleConfirm = () => {
|
|
const qty = parseInt(display, 10) || 0;
|
|
if (qty <= 0) return;
|
|
onConfirm(qty, packages);
|
|
onClose();
|
|
};
|
|
|
|
/* Package unit selected from modal */
|
|
const handlePackageUnitSelect = (unit: PackageUnit) => {
|
|
setPendingUnit(unit);
|
|
setPackQtyInput("");
|
|
setPackCountInput("1");
|
|
};
|
|
|
|
/* Add package entry */
|
|
const handleAddPackage = () => {
|
|
if (!pendingUnit) return;
|
|
const qtyPerUnit = parseInt(packQtyInput, 10);
|
|
const count = parseInt(packCountInput, 10) || 1;
|
|
if (isNaN(qtyPerUnit) || qtyPerUnit <= 0) return;
|
|
|
|
const newEntry: PackageEntry = {
|
|
unit: pendingUnit,
|
|
count,
|
|
qtyPerUnit,
|
|
};
|
|
const newPackages = [...packages, newEntry];
|
|
setPackages(newPackages);
|
|
|
|
// Update display to package total
|
|
const total = newPackages.reduce((s, p) => s + p.count * p.qtyPerUnit, 0);
|
|
setDisplay(String(Math.min(total, maxQty)));
|
|
|
|
setPendingUnit(null);
|
|
setPackQtyInput("");
|
|
setPackCountInput("");
|
|
};
|
|
|
|
/* Remove package */
|
|
const handleRemovePackage = (idx: number) => {
|
|
const newPackages = packages.filter((_, i) => i !== idx);
|
|
setPackages(newPackages);
|
|
const total = newPackages.reduce((s, p) => s + p.count * p.qtyPerUnit, 0);
|
|
if (total > 0) {
|
|
setDisplay(String(Math.min(total, maxQty)));
|
|
}
|
|
};
|
|
|
|
if (!open) return null;
|
|
|
|
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" },
|
|
];
|
|
|
|
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-[340px] 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%)" }}
|
|
>
|
|
<span className="text-[13px] text-white/90 bg-white/20 px-3 py-1 rounded-full">
|
|
최대 {maxQty.toLocaleString()} EA
|
|
</span>
|
|
<button
|
|
onClick={() => setPackagingModalOpen(true)}
|
|
className="px-3 py-1.5 bg-green-50/90 border border-green-300 rounded-lg text-[13px] font-medium text-green-800 hover:bg-green-100 active:scale-95 transition-all"
|
|
>
|
|
{"\uD83D\uDCE6"} 포장등록
|
|
</button>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="p-4">
|
|
{/* Display */}
|
|
<input
|
|
type="text"
|
|
readOnly
|
|
value={parseInt(display, 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" }}
|
|
/>
|
|
|
|
{/* Registered packages */}
|
|
{packages.length > 0 && (
|
|
<div className="mb-3 p-2.5 bg-green-50 border border-green-200 rounded-lg">
|
|
<div className="flex flex-col gap-1">
|
|
{packages.map((pkg, idx) => (
|
|
<div key={idx} className="flex items-center justify-between px-2 py-1.5 bg-white rounded text-xs">
|
|
<span className="text-gray-700">
|
|
{pkg.unit.icon} {pkg.count}{pkg.unit.label} x {pkg.qtyPerUnit.toLocaleString()}EA
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-semibold text-green-600">
|
|
= {(pkg.count * pkg.qtyPerUnit).toLocaleString()}EA
|
|
</span>
|
|
<button
|
|
onClick={() => handleRemovePackage(idx)}
|
|
className="w-5 h-5 bg-red-100 text-red-500 rounded text-xs flex items-center justify-center hover:bg-red-200"
|
|
>
|
|
x
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="flex justify-between mt-2 pt-2 border-t border-green-200 text-xs font-semibold">
|
|
<span className="text-green-700">포장 합계: {packageTotal.toLocaleString()}EA</span>
|
|
<span className="text-red-500">남은: {remainingQty.toLocaleString()}EA</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Pending unit input */}
|
|
{pendingUnit && (
|
|
<div className="mb-3 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
|
<p className="text-xs font-semibold text-blue-700 mb-2">
|
|
{pendingUnit.icon} {pendingUnit.label} 포장 정보 입력
|
|
</p>
|
|
<div className="flex gap-2 items-end">
|
|
<div className="flex-1">
|
|
<label className="text-[10px] text-gray-500">개수</label>
|
|
<input
|
|
type="number"
|
|
value={packCountInput}
|
|
onChange={(e) => setPackCountInput(e.target.value)}
|
|
className="w-full h-9 px-2 text-center text-sm font-semibold border border-gray-200 rounded-lg"
|
|
placeholder="1"
|
|
min={1}
|
|
/>
|
|
</div>
|
|
<span className="text-xs text-gray-400 pb-2">x</span>
|
|
<div className="flex-1">
|
|
<label className="text-[10px] text-gray-500">단위수량(EA)</label>
|
|
<input
|
|
type="number"
|
|
value={packQtyInput}
|
|
onChange={(e) => setPackQtyInput(e.target.value)}
|
|
className="w-full h-9 px-2 text-center text-sm font-semibold border border-gray-200 rounded-lg"
|
|
placeholder="100"
|
|
min={1}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={handleAddPackage}
|
|
className="h-9 px-3 bg-blue-500 text-white text-xs font-semibold rounded-lg hover:bg-blue-600 active:scale-95 transition-all shrink-0"
|
|
>
|
|
추가
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Guide text */}
|
|
<div className={`text-center text-[13px] py-2 mb-3 rounded-md ${
|
|
packages.length > 0
|
|
? "bg-blue-50 text-blue-700 font-medium"
|
|
: "bg-gray-50 text-gray-500"
|
|
}`}>
|
|
{packages.length > 0
|
|
? `${packages.map(p => `${p.count}${p.unit.label} x ${p.qtyPerUnit}EA`).join(" + ")} = ${packageTotal}EA`
|
|
: "수량을 입력하세요"
|
|
}
|
|
</div>
|
|
|
|
{/* Numpad grid: 4x3 + bottom row */}
|
|
<div className="grid grid-cols-4 gap-2.5">
|
|
{KEYS.map((key) => (
|
|
<button
|
|
key={key.action}
|
|
onClick={() => handleInput(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={() => handleInput("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={handleConfirm}
|
|
className="col-span-2 h-14 rounded-xl text-lg font-bold text-white active:scale-95 transition-all"
|
|
style={{
|
|
background: "linear-gradient(135deg, #10b981 0%, #059669 100%)",
|
|
}}
|
|
>
|
|
확인
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Packaging modal (nested) */}
|
|
<PackagingModal
|
|
open={packagingModalOpen}
|
|
onClose={() => setPackagingModalOpen(false)}
|
|
onSelect={handlePackageUnitSelect}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|