diff --git a/frontend/app/(main)/COMPANY_10/pop/_components/common/BarcodeScanModal.tsx b/frontend/app/(main)/COMPANY_10/pop/_components/common/BarcodeScanModal.tsx new file mode 100644 index 00000000..ac7bec6b --- /dev/null +++ b/frontend/app/(main)/COMPANY_10/pop/_components/common/BarcodeScanModal.tsx @@ -0,0 +1,420 @@ +"use client"; + +import React, { useState, useRef, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import { Camera, CameraOff, CheckCircle2, AlertCircle, Scan } from "lucide-react"; +import Webcam from "react-webcam"; +import { BrowserMultiFormatReader, NotFoundException } from "@zxing/library"; + +export interface BarcodeScanModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + targetField?: string; + barcodeFormat?: "all" | "1d" | "2d"; + autoSubmit?: boolean; + onScanSuccess: (barcode: string) => void; + userId?: string; +} + +export const BarcodeScanModal: React.FC = ({ + open, + onOpenChange, + targetField, + barcodeFormat = "all", + autoSubmit = false, + onScanSuccess, + userId = "guest", +}) => { + const [isScanning, setIsScanning] = useState(false); + const [scannedCode, setScannedCode] = useState(""); + const [manualInput, setManualInput] = useState(""); + const [error, setError] = useState(""); + const [hasPermission, setHasPermission] = useState(null); + const webcamRef = useRef(null); + const codeReaderRef = useRef(null); + const scanIntervalRef = useRef(null); + const manualInputRef = useRef(null); + + // 바코드 리더 초기화 + 모달 열릴 때 상태 리셋 + useEffect(() => { + if (open) { + setScannedCode(""); + setManualInput(""); + setError(""); + setIsScanning(false); + setHasPermission(null); + codeReaderRef.current = new BrowserMultiFormatReader(); + } + + return () => { + stopScanning(); + if (codeReaderRef.current) { + codeReaderRef.current.reset(); + } + }; + }, [open]); + + // 카메라 권한 요청 + const requestCameraPermission = async () => { + // navigator.mediaDevices 지원 확인 + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + setHasPermission(false); + setError( + "이 브라우저는 카메라 접근을 지원하지 않거나, 보안 컨텍스트(HTTPS 또는 localhost)가 아닙니다. " + + "현재 프로토콜: " + window.location.protocol + ); + toast.error("카메라 접근이 불가능합니다."); + return; + } + + try { + // 후면 카메라 먼저 시도, 실패하면 전면 카메라 fallback + let stream: MediaStream; + try { + stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" } }); + } catch { + stream = await navigator.mediaDevices.getUserMedia({ video: true }); + } + setHasPermission(true); + stream.getTracks().forEach((track) => track.stop()); + } catch (err: any) { + setHasPermission(false); + + if (err.name === "NotAllowedError") { + setError("카메라 접근이 거부되었습니다. 브라우저 설정에서 카메라 권한을 허용해주세요."); + toast.error("카메라 권한이 거부되었습니다."); + } else if (err.name === "NotFoundError") { + setError("카메라를 찾을 수 없습니다. 카메라가 연결되어 있는지 확인해주세요."); + toast.error("카메라를 찾을 수 없습니다."); + } else if (err.name === "NotReadableError") { + setError("카메라가 이미 다른 애플리케이션에서 사용 중입니다."); + toast.error("카메라가 사용 중입니다."); + } else if (err.name === "NotSupportedError") { + setError("보안 컨텍스트(HTTPS 또는 localhost)가 아니어서 카메라를 사용할 수 없습니다."); + toast.error("HTTPS 환경이 필요합니다."); + } else { + setError(`카메라 접근 오류: ${err.name} - ${err.message}`); + toast.error("카메라 접근 중 오류가 발생했습니다."); + } + } + }; + + // 스캔 시작 + const startScanning = () => { + setIsScanning(true); + setError(""); + setScannedCode(""); + + scanIntervalRef.current = setInterval(() => { + scanBarcode(); + }, 500); + }; + + // 스캔 중지 + const stopScanning = () => { + setIsScanning(false); + if (scanIntervalRef.current) { + clearInterval(scanIntervalRef.current); + scanIntervalRef.current = null; + } + }; + + // 바코드 스캔 + const scanBarcode = async () => { + if (!webcamRef.current || !codeReaderRef.current) return; + + try { + const imageSrc = webcamRef.current.getScreenshot(); + if (!imageSrc) return; + + const img = new Image(); + img.src = imageSrc; + + await new Promise((resolve) => { + img.onload = resolve; + }); + + const result = await codeReaderRef.current.decodeFromImageElement(img); + + if (result) { + const barcode = result.getText(); + + setScannedCode(barcode); + stopScanning(); + toast.success(`바코드 스캔 완료: ${barcode}`); + + if (autoSubmit) { + onScanSuccess(barcode); + } + } + } catch (err) { + if (!(err instanceof NotFoundException)) { + // NotFoundException은 정상 (바코드 미인식) + } + } + }; + + // 수동 확인 버튼 (스캔 결과 또는 직접 입력) + const handleConfirm = () => { + const code = scannedCode || manualInput.trim(); + if (code) { + onScanSuccess(code); // 호출 측에서 검색 필드를 덮어쓰기 + onOpenChange(false); + } else { + toast.error("바코드를 스캔하거나 직접 입력해주세요."); + } + }; + + return ( + + + + 바코드 스캔 + + 카메라로 바코드를 스캔합니다. + {targetField && ` (대상 필드: ${targetField})`} + + + +
+ {/* 카메라 권한 요청 대기 중 */} + {hasPermission === null && ( +
+
+ +
+
+

카메라 권한이 필요합니다

+

+ 바코드를 스캔하려면 카메라 접근 권한을 허용해주세요. +

+
+ +
+

권한 요청 안내:

+
    +
  • 아래 버튼을 클릭하면 브라우저에서 권한 요청 팝업이 표시됩니다
  • +
  • 팝업에서 "허용" 버튼을 클릭해주세요
  • +
  • 권한은 언제든지 브라우저 설정에서 변경할 수 있습니다
  • +
+
+ +
+ +
+
+
+
+ )} + + {/* 카메라 권한 거부됨 */} + {hasPermission === false && ( +
+
+ +
+
+

카메라 접근 권한이 필요합니다

+

{error}

+
+ +
+

권한 허용 방법:

+
    +
  1. 브라우저 주소창 왼쪽의 자물쇠 아이콘을 클릭하세요
  2. +
  3. "카메라" 항목을 찾아 "허용"으로 변경하세요
  4. +
  5. 페이지를 새로고침하거나 다시 스캔을 시도하세요
  6. +
+
+ +
+ +
+
+
+
+ )} + + {/* 웹캠 뷰 */} + {hasPermission && ( +
+ { + // environment 카메라 실패 시 자동 fallback (Webcam 내부 처리) + }} + className="h-full w-full object-cover" + /> + + {/* 스캔 가이드 오버레이 */} + {isScanning && ( +
+
+
+
+ + 스캔 중... +
+
+
+ )} + + {/* 스캔 완료 오버레이 */} + {scannedCode && ( +
+
+ +

스캔 완료!

+

{scannedCode}

+
+
+ )} +
+ )} + + {/* 수동 입력 (카메라 사용 불가 시 또는 외장 스캐너 사용 시) */} +
+

직접 입력 또는 외장 스캐너

+
+ setManualInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && manualInput.trim()) { + e.preventDefault(); + onScanSuccess(manualInput.trim()); + onOpenChange(false); + } + }} + placeholder="바코드/QR 번호 입력 후 Enter" + className="flex-1 h-11 rounded-lg border border-border px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary" + autoFocus={hasPermission === false} + /> + +
+
+ + {/* 바코드 포맷 정보 */} +
+
+ +
+

지원 포맷

+

+ {barcodeFormat === "all" && "1D/2D 바코드 모두 지원 (Code 128, QR Code 등)"} + {barcodeFormat === "1d" && "1D 바코드 (Code 128, Code 39, EAN-13, UPC-A)"} + {barcodeFormat === "2d" && "2D 바코드 (QR Code, Data Matrix)"} +

+
+
+
+ + {/* 에러 메시지 */} + {error && ( +
+
+ +

{error}

+
+
+ )} +
+ + + + + {!isScanning && !scannedCode && hasPermission && ( + + )} + + {isScanning && ( + + )} + + {scannedCode && ( + + )} + + {scannedCode && !autoSubmit && ( + + )} + + +
+ ); +}; diff --git a/frontend/app/(main)/COMPANY_10/pop/_components/common/ConfirmModal.tsx b/frontend/app/(main)/COMPANY_10/pop/_components/common/ConfirmModal.tsx new file mode 100644 index 00000000..34bfff4b --- /dev/null +++ b/frontend/app/(main)/COMPANY_10/pop/_components/common/ConfirmModal.tsx @@ -0,0 +1,82 @@ +"use client"; + +import React from "react"; + +export interface ConfirmModalProps { + open: boolean; + title?: string; + message: string; + confirmText?: string; + cancelText?: string; + variant?: "primary" | "danger" | "success"; + onConfirm: () => void; + onCancel: () => void; +} + +/** + * POP 공용 확인 모달 (native confirm() 대체) + * 모바일 친화 디자인, bottom-sheet 스타일 + */ +export function ConfirmModal({ + open, + title, + message, + confirmText = "확인", + cancelText = "취소", + variant = "primary", + onConfirm, + onCancel, +}: ConfirmModalProps) { + if (!open) return null; + + const confirmBg = + variant === "danger" + ? "bg-gradient-to-b from-red-500 to-red-600 hover:from-red-600 hover:to-red-700" + : variant === "success" + ? "bg-gradient-to-b from-emerald-500 to-emerald-600 hover:from-emerald-600 hover:to-emerald-700" + : "bg-gradient-to-b from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700"; + + return ( +
+ {/* Overlay */} +
+ + {/* Center modal */} +
+
e.stopPropagation()} + > + {/* Body */} +
+ {title && ( +

{title}

+ )} +

+ {message} +

+
+ + {/* Buttons */} +
+ +
+ +
+
+
+
+ ); +} diff --git a/frontend/app/(main)/COMPANY_10/pop/_components/common/EquipmentModal.tsx b/frontend/app/(main)/COMPANY_10/pop/_components/common/EquipmentModal.tsx new file mode 100644 index 00000000..e42537ec --- /dev/null +++ b/frontend/app/(main)/COMPANY_10/pop/_components/common/EquipmentModal.tsx @@ -0,0 +1,195 @@ +"use client"; + +import React, { useState, useEffect, useMemo } from "react"; +import { getChosung } from "../inbound/SupplierModal"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +export interface EquipmentItem { + id: string; + equipment_code: string; + equipment_name: string; +} + +interface EquipmentModalProps { + open: boolean; + onClose: () => void; + onSelect: (equipment: EquipmentItem) => void; + items: EquipmentItem[]; + loading?: boolean; + title?: string; + searchPlaceholder?: string; +} + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +const AVATAR_COLORS = [ + "#3b82f6", "#22c55e", "#f59e0b", "#ef4444", "#8b5cf6", + "#06b6d4", "#ec4899", "#14b8a6", "#f97316", "#6366f1", + "#84cc16", "#e11d48", "#0ea5e9", "#a855f7", "#10b981", +]; + +function getAvatarColor(name: string): string { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + } + return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length]; +} + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export function EquipmentModal({ + open, + onClose, + onSelect, + items, + loading = false, + title = "설비 선택", + searchPlaceholder = "설비명 또는 코드 검색...", +}: EquipmentModalProps) { + const [search, setSearch] = useState(""); + const [sortMode, setSortMode] = useState<"korean" | "abc">("korean"); + + useEffect(() => { + if (open) setSearch(""); + }, [open]); + + const grouped = useMemo(() => { + const filtered = items.filter((e) => + e.equipment_name.toLowerCase().includes(search.toLowerCase()) || + e.equipment_code.toLowerCase().includes(search.toLowerCase()) + ); + + const sorted = [...filtered].sort((a, b) => { + if (sortMode === "abc") return a.equipment_name.localeCompare(b.equipment_name, "en"); + return a.equipment_name.localeCompare(b.equipment_name, "ko"); + }); + + const groups: { letter: string; items: EquipmentItem[] }[] = []; + const map = new Map(); + for (const e of sorted) { + const first = e.equipment_name.trim().charAt(0); + const letter = getChosung(first); + if (!map.has(letter)) map.set(letter, []); + map.get(letter)!.push(e); + } + for (const [letter, list] of map) { + groups.push({ letter, items: list }); + } + return groups; + }, [items, search, sortMode]); + + if (!open) return null; + + return ( +
+
+ +
+
+
+

{title}

+
+ + +
+
+ +
+ +
+
+ + + + setSearch(e.target.value)} + placeholder={searchPlaceholder} + className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-xl text-sm outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition-all" + /> +
+
+ +
+ {loading ? ( +
+ 불러오는 중... +
+ ) : grouped.length === 0 ? ( +
+ {search ? "검색 결과가 없습니다" : "등록된 설비가 없습니다"} +
+ ) : ( + grouped.map((group) => ( +
+
+ {group.letter} +
+
+
+ {group.items.map((equipment) => { + const displayName = equipment.equipment_name.trim(); + const initial = displayName.charAt(0); + const color = getAvatarColor(equipment.equipment_name); + + return ( + + ); + })} +
+
+ )) + )} +
+
+
+ ); +} diff --git a/frontend/app/(main)/COMPANY_10/pop/_components/common/PopButton.tsx b/frontend/app/(main)/COMPANY_10/pop/_components/common/PopButton.tsx new file mode 100644 index 00000000..317fd5fa --- /dev/null +++ b/frontend/app/(main)/COMPANY_10/pop/_components/common/PopButton.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { ButtonHTMLAttributes, forwardRef } from "react"; +import { COLOR_MAP, type PopColor } from "./theme"; + +type Size = "sm" | "md" | "lg"; + +interface Props extends ButtonHTMLAttributes { + color?: PopColor; + size?: Size; + icon?: React.ReactNode; +} + +const SIZE_CLASSES: Record = { + sm: "min-w-[96px] min-h-[40px] text-sm px-3", + md: "min-w-[144px] min-h-[48px] text-base px-4", + lg: "min-w-[200px] min-h-[56px] text-lg px-6", +}; + +const COMMON = + "rounded-xl font-semibold text-white shadow-md transition-all duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"; + +const PopButton = forwardRef( + ({ color = "blue", size = "md", icon, children, className, ...rest }, ref) => { + const tokens = COLOR_MAP[color]; + const classes = [ + COMMON, + SIZE_CLASSES[size], + tokens.buttonBg, + tokens.buttonBgHover, + tokens.ring, + className, + ] + .filter(Boolean) + .join(" "); + + return ( + + ); + } +); + +PopButton.displayName = "PopButton"; + +export default PopButton; diff --git a/frontend/app/(main)/COMPANY_10/pop/_components/common/PopCard.tsx b/frontend/app/(main)/COMPANY_10/pop/_components/common/PopCard.tsx new file mode 100644 index 00000000..f8f50817 --- /dev/null +++ b/frontend/app/(main)/COMPANY_10/pop/_components/common/PopCard.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { HTMLAttributes, forwardRef } from "react"; +import { COLOR_MAP, type PopColor } from "./theme"; + +interface Props extends HTMLAttributes { + selected?: boolean; + color?: PopColor; + interactive?: boolean; +} + +const BASE = + "w-full min-h-[180px] rounded-2xl bg-white border shadow-sm p-4 flex flex-col transition-all"; + +const PopCard = forwardRef( + ( + { selected = false, color = "blue", interactive = true, className, ...rest }, + ref + ) => { + const tokens = COLOR_MAP[color]; + + const classes = [ + BASE, + selected ? tokens.border : "border-gray-200", + interactive ? "hover:shadow-md hover:border-gray-300 cursor-pointer" : "", + selected ? tokens.ringSelected : "", + className, + ] + .filter(Boolean) + .join(" "); + + return
; + } +); + +PopCard.displayName = "PopCard"; + +export default PopCard; diff --git a/frontend/app/(main)/COMPANY_10/pop/_components/common/PopCardGrid.tsx b/frontend/app/(main)/COMPANY_10/pop/_components/common/PopCardGrid.tsx new file mode 100644 index 00000000..36d50ba1 --- /dev/null +++ b/frontend/app/(main)/COMPANY_10/pop/_components/common/PopCardGrid.tsx @@ -0,0 +1,77 @@ +import { HTMLAttributes } from "react"; + +type Cols = 1 | 2 | 3 | 4; + +interface ColProfile { + base?: Cols; + md?: Cols; + lg?: Cols; + xl?: Cols; + "2xl"?: Cols; +} + +interface Props extends HTMLAttributes { + cols?: ColProfile; + gap?: "sm" | "md" | "lg"; +} + +const BASE_COLS: Record = { + 1: "grid-cols-1", + 2: "grid-cols-2", + 3: "grid-cols-3", + 4: "grid-cols-4", +}; +const MD_COLS: Record = { + 1: "md:grid-cols-1", + 2: "md:grid-cols-2", + 3: "md:grid-cols-3", + 4: "md:grid-cols-4", +}; +const LG_COLS: Record = { + 1: "lg:grid-cols-1", + 2: "lg:grid-cols-2", + 3: "lg:grid-cols-3", + 4: "lg:grid-cols-4", +}; +const XL_COLS: Record = { + 1: "xl:grid-cols-1", + 2: "xl:grid-cols-2", + 3: "xl:grid-cols-3", + 4: "xl:grid-cols-4", +}; +const XXL_COLS: Record = { + 1: "2xl:grid-cols-1", + 2: "2xl:grid-cols-2", + 3: "2xl:grid-cols-3", + 4: "2xl:grid-cols-4", +}; + +const GAP: Record<"sm" | "md" | "lg", string> = { + sm: "gap-3", + md: "gap-4", + lg: "gap-6", +}; + +export default function PopCardGrid({ + cols = { base: 1, md: 2, xl: 3 }, + gap = "md", + className, + ...rest +}: Props) { + const { base = 1, md, lg, xl, "2xl": xxl } = cols; + + const classes = [ + "grid w-full", + GAP[gap], + BASE_COLS[base], + md != null ? MD_COLS[md] : "", + lg != null ? LG_COLS[lg] : "", + xl != null ? XL_COLS[xl] : "", + xxl != null ? XXL_COLS[xxl] : "", + className, + ] + .filter(Boolean) + .join(" "); + + return
; +} diff --git a/frontend/app/(main)/COMPANY_10/pop/_components/common/PopModal.tsx b/frontend/app/(main)/COMPANY_10/pop/_components/common/PopModal.tsx new file mode 100644 index 00000000..c72499dd --- /dev/null +++ b/frontend/app/(main)/COMPANY_10/pop/_components/common/PopModal.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { ReactNode, useEffect } from "react"; + +type Size = "sm" | "md" | "lg" | "xl"; + +interface Props { + open: boolean; + onClose: () => void; + size?: Size; + title?: string; + children: ReactNode; + footer?: ReactNode; + hideCloseButton?: boolean; +} + +const SIZE_CLASSES: Record = { + sm: "w-[min(90vw,420px)] max-h-[80vh]", + md: "w-[min(90vw,640px)] max-h-[85vh]", + lg: "w-[min(95vw,900px)] max-h-[90vh]", + xl: "w-[min(98vw,1200px)] max-h-[95vh]", +}; + +export default function PopModal({ + open, + onClose, + size = "md", + title, + children, + footer, + hideCloseButton = false, +}: Props) { + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [open, onClose]); + + useEffect(() => { + if (!open) return; + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = prev; + }; + }, [open]); + + if (!open) return null; + + return ( +
+
e.stopPropagation()} + > + {(title != null || !hideCloseButton) && ( +
+ {title != null && ( +

{title}

+ )} + {!hideCloseButton && ( + + )} +
+ )} +
{children}
+ {footer && ( +
+ {footer} +
+ )} +
+
+ ); +} diff --git a/frontend/app/(main)/COMPANY_10/pop/_components/common/SimpleKeypadModal.tsx b/frontend/app/(main)/COMPANY_10/pop/_components/common/SimpleKeypadModal.tsx new file mode 100644 index 00000000..228f72e5 --- /dev/null +++ b/frontend/app/(main)/COMPANY_10/pop/_components/common/SimpleKeypadModal.tsx @@ -0,0 +1,188 @@ +"use client"; + +import React, { useState, useCallback, useEffect } from "react"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +interface SimpleKeypadModalProps { + open: boolean; + onClose: () => void; + onConfirm: (qty: number) => void; + maxQty: number; + itemName: string; + initialQty?: number; +} + +/* ------------------------------------------------------------------ */ +/* 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 SimpleKeypadModal({ + open, + onClose, + onConfirm, + maxQty, + itemName, + initialQty, +}: SimpleKeypadModalProps) { + const [qty, setQty] = useState("0"); + + /* Reset on open */ + useEffect(() => { + if (open) { + setQty(initialQty !== undefined && initialQty > 0 ? String(initialQty) : "0"); + } + }, [open, initialQty]); + + const qtyNum = parseInt(qty, 10) || 0; + const isOverMax = qtyNum > maxQty; + + /* Numpad input handler */ + const handleInput = useCallback( + (key: string) => { + setQty((prev) => { + switch (key) { + case "backspace": + return prev.length <= 1 ? "0" : prev.slice(0, -1); + case "clear": + return "0"; + case "max": + return String(maxQty); + default: { + const next = prev === "0" ? key : prev + key; + const num = parseInt(next, 10); + if (isNaN(num)) return prev; + return next; + } + } + }); + }, + [maxQty], + ); + + const handleConfirm = () => { + if (qtyNum <= 0) return; + const finalQty = Math.min(qtyNum, maxQty); + onConfirm(finalQty); + onClose(); + }; + + if (!open) return null; + + return ( +
+ {/* Overlay */} +
+ + {/* Panel */} +
+ {/* Header - blue gradient */} +
+ + {maxQty.toLocaleString()} EA + + +
+ + {/* Body */} +
+

+ 수량 입력 +

+

+ {itemName} +

+ + {/* Display */} + + + {isOverMax && ( +

+ {maxQty.toLocaleString()}EA ({maxQty.toLocaleString()}EA) +

+ )} + + {/* Numpad grid: 4x3 + bottom row */} +
+ {KEYS.map((key) => ( + + ))} + + {/* Bottom row: 0 (span 2) + Confirm (span 2) */} + + +
+
+
+
+ ); +} diff --git a/frontend/app/(main)/COMPANY_10/pop/_components/common/theme.ts b/frontend/app/(main)/COMPANY_10/pop/_components/common/theme.ts new file mode 100644 index 00000000..a846f2d7 --- /dev/null +++ b/frontend/app/(main)/COMPANY_10/pop/_components/common/theme.ts @@ -0,0 +1,107 @@ +// POP 9-color palette. 키는 부위별 완성 리터럴 — JIT 스캔 대상. +// 동적 문자열 생성 금지(`bg-${x}` 등). 반드시 COLOR_MAP[color].부위 조회로 접근. + +export type PopColor = + | 'blue' + | 'purple' + | 'cyan' + | 'green' + | 'red' + | 'pink' + | 'teal' + | 'orange' + | 'amber'; + +export interface PopColorTokens { + buttonBg: string; + buttonBgHover: string; + ring: string; + ringSelected: string; + text: string; + bg50: string; + border: string; +} + +export const COLOR_MAP: Record = { + blue: { + buttonBg: 'bg-gradient-to-b from-blue-400 to-blue-700', + buttonBgHover: 'hover:from-blue-500 hover:to-blue-800', + ring: 'focus:ring-blue-500', + ringSelected: 'ring-2 ring-blue-500', + text: 'text-blue-600', + bg50: 'bg-blue-50', + border: 'border-blue-200', + }, + purple: { + buttonBg: 'bg-gradient-to-b from-purple-400 to-purple-700', + buttonBgHover: 'hover:from-purple-500 hover:to-purple-800', + ring: 'focus:ring-purple-500', + ringSelected: 'ring-2 ring-purple-500', + text: 'text-purple-600', + bg50: 'bg-purple-50', + border: 'border-purple-200', + }, + cyan: { + buttonBg: 'bg-gradient-to-b from-cyan-400 to-cyan-700', + buttonBgHover: 'hover:from-cyan-500 hover:to-cyan-800', + ring: 'focus:ring-cyan-500', + ringSelected: 'ring-2 ring-cyan-500', + text: 'text-cyan-600', + bg50: 'bg-cyan-50', + border: 'border-cyan-200', + }, + green: { + buttonBg: 'bg-gradient-to-b from-green-400 to-green-700', + buttonBgHover: 'hover:from-green-500 hover:to-green-800', + ring: 'focus:ring-green-500', + ringSelected: 'ring-2 ring-green-500', + text: 'text-green-600', + bg50: 'bg-green-50', + border: 'border-green-200', + }, + red: { + buttonBg: 'bg-gradient-to-b from-red-400 to-red-700', + buttonBgHover: 'hover:from-red-500 hover:to-red-800', + ring: 'focus:ring-red-500', + ringSelected: 'ring-2 ring-red-500', + text: 'text-red-600', + bg50: 'bg-red-50', + border: 'border-red-200', + }, + pink: { + buttonBg: 'bg-gradient-to-b from-pink-400 to-pink-700', + buttonBgHover: 'hover:from-pink-500 hover:to-pink-800', + ring: 'focus:ring-pink-500', + ringSelected: 'ring-2 ring-pink-500', + text: 'text-pink-600', + bg50: 'bg-pink-50', + border: 'border-pink-200', + }, + teal: { + buttonBg: 'bg-gradient-to-b from-teal-400 to-teal-700', + buttonBgHover: 'hover:from-teal-500 hover:to-teal-800', + ring: 'focus:ring-teal-500', + ringSelected: 'ring-2 ring-teal-500', + text: 'text-teal-600', + bg50: 'bg-teal-50', + border: 'border-teal-200', + }, + orange: { + buttonBg: 'bg-gradient-to-b from-orange-400 to-orange-700', + buttonBgHover: 'hover:from-orange-500 hover:to-orange-800', + ring: 'focus:ring-orange-500', + ringSelected: 'ring-2 ring-orange-500', + text: 'text-orange-600', + bg50: 'bg-orange-50', + border: 'border-orange-200', + }, + amber: { + buttonBg: 'bg-gradient-to-b from-amber-400 to-amber-700', + buttonBgHover: 'hover:from-amber-500 hover:to-amber-800', + ring: 'focus:ring-amber-500', + ringSelected: 'ring-2 ring-amber-500', + text: 'text-amber-600', + bg50: 'bg-amber-50', + border: 'border-amber-200', + }, +}; diff --git a/frontend/app/(main)/COMPANY_10/pop/_components/common/useCartSync.ts b/frontend/app/(main)/COMPANY_10/pop/_components/common/useCartSync.ts new file mode 100644 index 00000000..7dc8e0b4 --- /dev/null +++ b/frontend/app/(main)/COMPANY_10/pop/_components/common/useCartSync.ts @@ -0,0 +1,27 @@ +/** + * useCartSync - 장바구니 DB 동기화 훅 (hardcoded 컴포넌트용 re-export) + * + * 실제 구현은 @/hooks/pop/useCartSync 에 있고, + * 여기서는 hardcoded 입고 컴포넌트들이 쉽게 import할 수 있도록 re-export한다. + * + * 사용법: + * ```typescript + * import { useCartSync } from "../common/useCartSync"; + * const cart = useCartSync("inbound"); + * ``` + */ + +export { useCartSync } from "@/hooks/pop/useCartSync"; +export type { + UseCartSyncReturn, + CartChanges, + CartCategory, +} from "@/hooks/pop/useCartSync"; + +// 타입도 함께 re-export (hardcoded 컴포넌트에서 필요할 수 있음) +export type { + CartItem, + CartItemWithId, + CartSyncStatus, + CartItemStatus, +} from "@/lib/registry/pop-components/types"; diff --git a/frontend/app/(main)/COMPANY_10/pop/_components/inbound/ChangeInbound.tsx b/frontend/app/(main)/COMPANY_10/pop/_components/inbound/ChangeInbound.tsx new file mode 100644 index 00000000..b66cb420 --- /dev/null +++ b/frontend/app/(main)/COMPANY_10/pop/_components/inbound/ChangeInbound.tsx @@ -0,0 +1,598 @@ +"use client"; + +import React, { useState, useCallback, useEffect, useMemo, useRef } from "react"; +import { useRouter } from "next/navigation"; +import { usePopCompanyPath } from "@/hooks/usePopCompanyPath"; +import { SupplierModal, type Supplier, matchChosung } from "./SupplierModal"; +import { SimpleKeypadModal } from "../common/SimpleKeypadModal"; +import { BarcodeScanModal } from "../common/BarcodeScanModal"; +import type { CartItemWithId } from "../common/useCartSync"; +import { COLOR_MAP } from "../common/theme"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +interface ChangeOrder { + id: string; + purchase_no: string; + order_date: string; + supplier_code: string; + supplier_name: string; + item_code: string; + item_name: string; + spec: string; + material: string; + order_qty: number; + received_qty: number; + remain_qty: number; + unit_price: number; + status: string; + due_date: string; + source_table: string; + /** Inspection type: "self" = self inspection required, "request" = inspection request optional, null = none */ + inspection_type: "self" | "request" | null; + /** Item image URL from item_info.image (may be null) */ + image: string | null; +} + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +interface ChangeInboundProps { + /** useCartSync 훅 인스턴스 (page.tsx에서 생성하여 전달) */ + cart: import("../common/useCartSync").UseCartSyncReturn; + /** 장바구니 버튼 클릭 핸들러 (dirty 저장 후 카트 페이지로 이동) */ + onCartClick: () => void; + /** 카트 저장 중 상태 (버튼 스피너/비활성화용) */ + saving: boolean; + /** 입고 유형 — 카트 품목에 기록됨 */ + inboundType: string; + /** 소스 테이블명 — 카트 품목별 sourceTable */ + sourceTable: string; +} + +const STORAGE_KEY = "pop_supplier_change"; + +export function ChangeInbound({ cart, onCartClick, saving, inboundType, sourceTable }: ChangeInboundProps) { + const router = useRouter(); + const companyPath = usePopCompanyPath(); + + /* State */ + const [selectedSupplier, setSelectedSupplier] = useState(null); + const [supplierModalOpen, setSupplierModalOpen] = useState(false); + const [orders, setOrders] = useState([]); + const [loading, setLoading] = useState(false); + const [fetchError, setFetchError] = useState(null); + const [keyword, setKeyword] = useState(""); + + /* NumberPad state */ + const [numpadOpen, setNumpadOpen] = useState(false); + const [numpadTarget, setNumpadTarget] = useState(null); + + /* Barcode scan modal state */ + const [supplierScanOpen, setSupplierScanOpen] = useState(false); + const [itemScanOpen, setItemScanOpen] = useState(false); + + /* Inline supplier search state */ + const [supplierSearchText, setSupplierSearchText] = useState(""); + const [supplierDropdownOpen, setSupplierDropdownOpen] = useState(false); + const [allSuppliers, setAllSuppliers] = useState([]); + const supplierInputRef = useRef(null); + const supplierDropdownRef = useRef(null); + + /* Fetch all suppliers for inline search + * TODO: API 연결 — 교환입고용 거래처 조회 엔드포인트 확정 후 연동 + */ + const fetchAllSuppliers = useCallback(async () => { + setAllSuppliers([]); + }, []); + + useEffect(() => { fetchAllSuppliers(); }, [fetchAllSuppliers]); + + /* sessionStorage 복원 — 장바구니 갔다 돌아올 때 거래처 선택 유지 */ + useEffect(() => { + const saved = sessionStorage.getItem(STORAGE_KEY); + if (saved) { + try { + const parsed = JSON.parse(saved); + setSelectedSupplier(parsed); + } catch {} + } + }, []); + + /* 거래처 선택 래퍼 — sessionStorage에도 저장/제거 */ + const selectSupplier = (s: Supplier | null) => { + setSelectedSupplier(s); + if (s) { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s)); + } else { + sessionStorage.removeItem(STORAGE_KEY); + } + }; + + /* Filtered suppliers for inline dropdown */ + const filteredSuppliers = useMemo(() => { + if (!supplierSearchText.trim()) return []; + return allSuppliers.filter((s) => matchChosung(s.customer_name, supplierSearchText.trim())); + }, [allSuppliers, supplierSearchText]); + + /* Close dropdown on outside click */ + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if ( + supplierDropdownRef.current && + !supplierDropdownRef.current.contains(e.target as Node) && + supplierInputRef.current && + !supplierInputRef.current.contains(e.target as Node) + ) { + setSupplierDropdownOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + /* Fetch return orders + * TODO: API 연결 — 교환입고 대상 품목 조회 엔드포인트 확정 후 연동 + */ + const fetchOrders = useCallback(async (_searchKeyword?: string) => { + setLoading(true); + setFetchError(null); + try { + setOrders([]); + } finally { + setLoading(false); + } + }, []); + + /* Initial load */ + useEffect(() => { + fetchOrders(); + }, [fetchOrders]); + + /* Filter orders by selected supplier */ + const filteredOrders = selectedSupplier + ? orders.filter((o) => + o.supplier_code === selectedSupplier.customer_code || + o.supplier_name === selectedSupplier.customer_name + ) + : orders; + + /* Filter by keyword */ + const displayOrders = keyword + ? filteredOrders.filter((o) => + o.item_name.toLowerCase().includes(keyword.toLowerCase()) || + o.item_code.toLowerCase().includes(keyword.toLowerCase()) || + o.purchase_no.toLowerCase().includes(keyword.toLowerCase()) + ) + : filteredOrders; + + /* Open numpad for an order */ + const openNumpad = (order: ChangeOrder) => { + setNumpadTarget(order); + setNumpadOpen(true); + }; + + /* Add to cart with numpad result */ + const handleNumpadConfirm = (qty: number) => { + if (!numpadTarget) return; + const order = numpadTarget; + if (cart.isItemInCart(order.id)) return; + + // 공급사 검증: 카트에 이미 다른 공급사 품목이 있으면 차단 + if (cart.cartItems.length > 0) { + const existingSupplier = String(cart.cartItems[0].row.supplier_code || ""); + if (existingSupplier && existingSupplier !== order.supplier_code) { + alert("다른 거래처의 품목이 이미 장바구니에 있습니다.\n같은 거래처의 품목만 담을 수 있습니다."); + setNumpadTarget(null); + return; + } + } + + const finalQty = Math.min(qty, order.remain_qty); + + cart.addItem( + { + row: { + id: order.id, + item_code: order.item_code, + item_name: order.item_name, + supplier_code: order.supplier_code, + supplier_name: order.supplier_name, + purchase_no: order.purchase_no, + unit_price: order.unit_price || 0, + spec: order.spec || "", + material: order.material || "", + order_qty: order.order_qty, + remain_qty: order.remain_qty, + order_date: order.order_date || "", + inspection_type: order.inspection_type, + source_table: order.source_table, + image: order.image || null, + inbound_type: inboundType, + }, + quantity: finalQty, + }, + order.id, + sourceTable, + ); + setNumpadTarget(null); + setTimeout(() => cart.saveToDb().catch(() => {}), 300); + }; + + /* Remove from cart (cancel) */ + const handleRemoveFromCart = (id: string) => { + cart.removeItem(id); + setTimeout(() => cart.saveToDb().catch(() => {}), 300); + }; + + /* Search */ + const handleSearch = () => { + fetchOrders(keyword || undefined); + }; + + const isInCart = (id: string) => cart.isItemInCart(id); + const getCartItem = (id: string): CartItemWithId | undefined => cart.getCartItem(id); + + return ( +
+ {/* ===== Header ===== */} +
+
+ +
+

교환입고

+

교환 품목을 선택하여 입고하세요

+
+
+ + {/* Cart button — 교환입고 라인, 발주품목 위. 테마 teal */} + +
+ + {/* ===== Search area (2 columns on tablet+) ===== */} +
+ {/* Supplier search card */} +
+
+ 거래처 + {selectedSupplier && ( + + {selectedSupplier.customer_name} + + )} +
+
+ + {/* QR/Barcode scan button - glossy v3 */} + + {selectedSupplier && ( + + )} + + {/* Supplier dropdown removed — use modal instead */} +
+
+ + {/* Item search card */} +
+
+ 교환 품목 + + {selectedSupplier ? displayOrders.length : 0} + +
+
+ setKeyword(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }} + placeholder="품목명, 품목코드, 발주번호 검색..." + disabled={!selectedSupplier} + className={`flex-1 px-3 py-2.5 border border-gray-200 rounded-lg text-sm outline-none transition-all ${ + selectedSupplier + ? "focus:border-teal-400 focus:ring-2 focus:ring-teal-100" + : "bg-gray-50 text-gray-400 cursor-not-allowed" + }`} + /> + {/* QR/Barcode scan button - glossy v3 */} + +
+
+
+ + {/* ===== Order items ===== */} +
+
+ + 교환 품목 목록 + + + {selectedSupplier ? `${displayOrders.length}건` : "-"} + +
+ + {!selectedSupplier ? ( +
+ + + +

거래처를 먼저 선택하세요

+

거래처를 선택하면 해당 거래처의 교환 품목이 표시됩니다

+
+ ) : loading ? ( +
+ + + + + 불러오는 중... +
+ ) : displayOrders.length === 0 ? ( +
+ + + +

+ {fetchError ? fetchError : selectedSupplier ? "해당 거래처의 교환 품목이 없습니다" : "거래처를 선택하거나 품목을 검색하세요"} +

+ {fetchError && ( + + )} +
+ ) : ( +
+ {displayOrders.map((order) => { + const inCart = isInCart(order.id); + const cartItem = getCartItem(order.id); + + return ( +
+ {/* Green left bar for in-cart items */} + {inCart && ( +
+ )} + + {/* === Header row: item code + item name + inspection badge === */} +
+ {order.item_code} + {order.item_name} + {order.inspection_type === "self" && ( + + 검사 필수 + + )} + {order.inspection_type === "request" && ( + + 검사의뢰 선택 + + )} +
+ + {/* === Body row: image + info + action === */} +
+ {/* Product image */} +
+ {order.image ? ( + {order.item_name} + ) : ( + {"\uD83D\uDCE6"} + )} +
+ + {/* Info columns */} +
+
+ 발주일 + {order.order_date} +
+
+ 발주번호 + {order.purchase_no} +
+
+ 발주수량 + {order.order_qty.toLocaleString()} +
+
+ 미입고 + + {inCart + ? (order.remain_qty - (cartItem?.quantity ?? 0)).toLocaleString() + : order.remain_qty.toLocaleString() + } + +
+
+ + {/* Action column: qty display + add/cancel button */} +
+ {/* Qty display - clickable to open numpad */} + + + {/* Add / Cancel button */} + {inCart ? ( + + ) : ( + + )} +
+
+
+ ); + })} +
+ )} +
+ + {/* ===== Modals ===== */} + setSupplierModalOpen(false)} + onSelect={(s) => selectSupplier(s)} + /> + + { setNumpadOpen(false); setNumpadTarget(null); }} + onConfirm={handleNumpadConfirm} + maxQty={numpadTarget?.remain_qty ?? 0} + itemName={numpadTarget?.item_name ?? ""} + /> + + {/* Barcode scan modal for supplier */} + { + setSupplierScanOpen(false); + // 스캔 결과로 거래처 검색 (거래처명 또는 코드 매칭) + const match = allSuppliers.find( + (s) => + s.customer_code === barcode || + s.customer_name.includes(barcode) + ); + if (match) { + selectSupplier(match); + setSupplierSearchText(""); + } else { + // 매칭 안 되면 검색 텍스트에 넣어서 드롭다운 표시 + setSupplierSearchText(barcode); + setSupplierDropdownOpen(true); + } + }} + /> + + {/* Barcode scan modal for item */} + { + setItemScanOpen(false); + // 스캔 결과로 품목 필터 + setKeyword(barcode); + fetchOrders(barcode); + }} + /> +
+ ); +} diff --git a/frontend/app/(main)/COMPANY_10/pop/_components/inbound/ErrorInbound.tsx b/frontend/app/(main)/COMPANY_10/pop/_components/inbound/ErrorInbound.tsx new file mode 100644 index 00000000..2260f964 --- /dev/null +++ b/frontend/app/(main)/COMPANY_10/pop/_components/inbound/ErrorInbound.tsx @@ -0,0 +1,598 @@ +"use client"; + +import React, { useState, useCallback, useEffect, useMemo, useRef } from "react"; +import { useRouter } from "next/navigation"; +import { usePopCompanyPath } from "@/hooks/usePopCompanyPath"; +import { SupplierModal, type Supplier, matchChosung } from "./SupplierModal"; +import { SimpleKeypadModal } from "../common/SimpleKeypadModal"; +import { BarcodeScanModal } from "../common/BarcodeScanModal"; +import type { CartItemWithId } from "../common/useCartSync"; +import { COLOR_MAP } from "../common/theme"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +interface ErrorOrder { + id: string; + purchase_no: string; + order_date: string; + supplier_code: string; + supplier_name: string; + item_code: string; + item_name: string; + spec: string; + material: string; + order_qty: number; + received_qty: number; + remain_qty: number; + unit_price: number; + status: string; + due_date: string; + source_table: string; + /** Inspection type: "self" = self inspection required, "request" = inspection request optional, null = none */ + inspection_type: "self" | "request" | null; + /** Item image URL from item_info.image (may be null) */ + image: string | null; +} + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +interface ErrorInboundProps { + /** useCartSync 훅 인스턴스 (page.tsx에서 생성하여 전달) */ + cart: import("../common/useCartSync").UseCartSyncReturn; + /** 장바구니 버튼 클릭 핸들러 (dirty 저장 후 카트 페이지로 이동) */ + onCartClick: () => void; + /** 카트 저장 중 상태 (버튼 스피너/비활성화용) */ + saving: boolean; + /** 입고 유형 — 카트 품목에 기록됨 */ + inboundType: string; + /** 소스 테이블명 — 카트 품목별 sourceTable */ + sourceTable: string; +} + +const STORAGE_KEY = "pop_supplier_error"; + +export function ErrorInbound({ cart, onCartClick, saving, inboundType, sourceTable }: ErrorInboundProps) { + const router = useRouter(); + const companyPath = usePopCompanyPath(); + + /* State */ + const [selectedSupplier, setSelectedSupplier] = useState(null); + const [supplierModalOpen, setSupplierModalOpen] = useState(false); + const [orders, setOrders] = useState([]); + const [loading, setLoading] = useState(false); + const [fetchError, setFetchError] = useState(null); + const [keyword, setKeyword] = useState(""); + + /* NumberPad state */ + const [numpadOpen, setNumpadOpen] = useState(false); + const [numpadTarget, setNumpadTarget] = useState(null); + + /* Barcode scan modal state */ + const [supplierScanOpen, setSupplierScanOpen] = useState(false); + const [itemScanOpen, setItemScanOpen] = useState(false); + + /* Inline supplier search state */ + const [supplierSearchText, setSupplierSearchText] = useState(""); + const [supplierDropdownOpen, setSupplierDropdownOpen] = useState(false); + const [allSuppliers, setAllSuppliers] = useState([]); + const supplierInputRef = useRef(null); + const supplierDropdownRef = useRef(null); + + /* Fetch all suppliers for inline search + * TODO: API 연결 — 불량입고용 거래처 조회 엔드포인트 확정 후 연동 + */ + const fetchAllSuppliers = useCallback(async () => { + setAllSuppliers([]); + }, []); + + useEffect(() => { fetchAllSuppliers(); }, [fetchAllSuppliers]); + + /* sessionStorage 복원 — 장바구니 갔다 돌아올 때 거래처 선택 유지 */ + useEffect(() => { + const saved = sessionStorage.getItem(STORAGE_KEY); + if (saved) { + try { + const parsed = JSON.parse(saved); + setSelectedSupplier(parsed); + } catch {} + } + }, []); + + /* 거래처 선택 래퍼 — sessionStorage에도 저장/제거 */ + const selectSupplier = (s: Supplier | null) => { + setSelectedSupplier(s); + if (s) { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s)); + } else { + sessionStorage.removeItem(STORAGE_KEY); + } + }; + + /* Filtered suppliers for inline dropdown */ + const filteredSuppliers = useMemo(() => { + if (!supplierSearchText.trim()) return []; + return allSuppliers.filter((s) => matchChosung(s.customer_name, supplierSearchText.trim())); + }, [allSuppliers, supplierSearchText]); + + /* Close dropdown on outside click */ + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if ( + supplierDropdownRef.current && + !supplierDropdownRef.current.contains(e.target as Node) && + supplierInputRef.current && + !supplierInputRef.current.contains(e.target as Node) + ) { + setSupplierDropdownOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + /* Fetch return orders + * TODO: API 연결 — 불량입고 대상 품목 조회 엔드포인트 확정 후 연동 + */ + const fetchOrders = useCallback(async (_searchKeyword?: string) => { + setLoading(true); + setFetchError(null); + try { + setOrders([]); + } finally { + setLoading(false); + } + }, []); + + /* Initial load */ + useEffect(() => { + fetchOrders(); + }, [fetchOrders]); + + /* Filter orders by selected supplier */ + const filteredOrders = selectedSupplier + ? orders.filter((o) => + o.supplier_code === selectedSupplier.customer_code || + o.supplier_name === selectedSupplier.customer_name + ) + : orders; + + /* Filter by keyword */ + const displayOrders = keyword + ? filteredOrders.filter((o) => + o.item_name.toLowerCase().includes(keyword.toLowerCase()) || + o.item_code.toLowerCase().includes(keyword.toLowerCase()) || + o.purchase_no.toLowerCase().includes(keyword.toLowerCase()) + ) + : filteredOrders; + + /* Open numpad for an order */ + const openNumpad = (order: ErrorOrder) => { + setNumpadTarget(order); + setNumpadOpen(true); + }; + + /* Add to cart with numpad result */ + const handleNumpadConfirm = (qty: number) => { + if (!numpadTarget) return; + const order = numpadTarget; + if (cart.isItemInCart(order.id)) return; + + // 공급사 검증: 카트에 이미 다른 공급사 품목이 있으면 차단 + if (cart.cartItems.length > 0) { + const existingSupplier = String(cart.cartItems[0].row.supplier_code || ""); + if (existingSupplier && existingSupplier !== order.supplier_code) { + alert("다른 거래처의 품목이 이미 장바구니에 있습니다.\n같은 거래처의 품목만 담을 수 있습니다."); + setNumpadTarget(null); + return; + } + } + + const finalQty = Math.min(qty, order.remain_qty); + + cart.addItem( + { + row: { + id: order.id, + item_code: order.item_code, + item_name: order.item_name, + supplier_code: order.supplier_code, + supplier_name: order.supplier_name, + purchase_no: order.purchase_no, + unit_price: order.unit_price || 0, + spec: order.spec || "", + material: order.material || "", + order_qty: order.order_qty, + remain_qty: order.remain_qty, + order_date: order.order_date || "", + inspection_type: order.inspection_type, + source_table: order.source_table, + image: order.image || null, + inbound_type: inboundType, + }, + quantity: finalQty, + }, + order.id, + sourceTable, + ); + setNumpadTarget(null); + setTimeout(() => cart.saveToDb().catch(() => {}), 300); + }; + + /* Remove from cart (cancel) */ + const handleRemoveFromCart = (id: string) => { + cart.removeItem(id); + setTimeout(() => cart.saveToDb().catch(() => {}), 300); + }; + + /* Search */ + const handleSearch = () => { + fetchOrders(keyword || undefined); + }; + + const isInCart = (id: string) => cart.isItemInCart(id); + const getCartItem = (id: string): CartItemWithId | undefined => cart.getCartItem(id); + + return ( +
+ {/* ===== Header ===== */} +
+
+ +
+

불량입고

+

불량 품목을 선택하여 입고하세요

+
+
+ + {/* Cart button — 불량입고 라인, 발주품목 위. 테마 red */} + +
+ + {/* ===== Search area (2 columns on tablet+) ===== */} +
+ {/* Supplier search card */} +
+
+ 거래처 + {selectedSupplier && ( + + {selectedSupplier.customer_name} + + )} +
+
+ + {/* QR/Barcode scan button - glossy v3 */} + + {selectedSupplier && ( + + )} + + {/* Supplier dropdown removed — use modal instead */} +
+
+ + {/* Item search card */} +
+
+ 불량 품목 + + {selectedSupplier ? displayOrders.length : 0} + +
+
+ setKeyword(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }} + placeholder="품목명, 품목코드, 발주번호 검색..." + disabled={!selectedSupplier} + className={`flex-1 px-3 py-2.5 border border-gray-200 rounded-lg text-sm outline-none transition-all ${ + selectedSupplier + ? "focus:border-red-400 focus:ring-2 focus:ring-red-100" + : "bg-gray-50 text-gray-400 cursor-not-allowed" + }`} + /> + {/* QR/Barcode scan button - glossy v3 */} + +
+
+
+ + {/* ===== Order items ===== */} +
+
+ + 불량 품목 목록 + + + {selectedSupplier ? `${displayOrders.length}건` : "-"} + +
+ + {!selectedSupplier ? ( +
+ + + +

거래처를 먼저 선택하세요

+

거래처를 선택하면 해당 거래처의 불량 품목이 표시됩니다

+
+ ) : loading ? ( +
+ + + + + 불러오는 중... +
+ ) : displayOrders.length === 0 ? ( +
+ + + +

+ {fetchError ? fetchError : selectedSupplier ? "해당 거래처의 불량 품목이 없습니다" : "거래처를 선택하거나 품목을 검색하세요"} +

+ {fetchError && ( + + )} +
+ ) : ( +
+ {displayOrders.map((order) => { + const inCart = isInCart(order.id); + const cartItem = getCartItem(order.id); + + return ( +
+ {/* Green left bar for in-cart items */} + {inCart && ( +
+ )} + + {/* === Header row: item code + item name + inspection badge === */} +
+ {order.item_code} + {order.item_name} + {order.inspection_type === "self" && ( + + 검사 필수 + + )} + {order.inspection_type === "request" && ( + + 검사의뢰 선택 + + )} +
+ + {/* === Body row: image + info + action === */} +
+ {/* Product image */} +
+ {order.image ? ( + {order.item_name} + ) : ( + {"\uD83D\uDCE6"} + )} +
+ + {/* Info columns */} +
+
+ 발주일 + {order.order_date} +
+
+ 발주번호 + {order.purchase_no} +
+
+ 발주수량 + {order.order_qty.toLocaleString()} +
+
+ 미입고 + + {inCart + ? (order.remain_qty - (cartItem?.quantity ?? 0)).toLocaleString() + : order.remain_qty.toLocaleString() + } + +
+
+ + {/* Action column: qty display + add/cancel button */} +
+ {/* Qty display - clickable to open numpad */} + + + {/* Add / Cancel button */} + {inCart ? ( + + ) : ( + + )} +
+
+
+ ); + })} +
+ )} +
+ + {/* ===== Modals ===== */} + setSupplierModalOpen(false)} + onSelect={(s) => selectSupplier(s)} + /> + + { setNumpadOpen(false); setNumpadTarget(null); }} + onConfirm={handleNumpadConfirm} + maxQty={numpadTarget?.remain_qty ?? 0} + itemName={numpadTarget?.item_name ?? ""} + /> + + {/* Barcode scan modal for supplier */} + { + setSupplierScanOpen(false); + // 스캔 결과로 거래처 검색 (거래처명 또는 코드 매칭) + const match = allSuppliers.find( + (s) => + s.customer_code === barcode || + s.customer_name.includes(barcode) + ); + if (match) { + selectSupplier(match); + setSupplierSearchText(""); + } else { + // 매칭 안 되면 검색 텍스트에 넣어서 드롭다운 표시 + setSupplierSearchText(barcode); + setSupplierDropdownOpen(true); + } + }} + /> + + {/* Barcode scan modal for item */} + { + setItemScanOpen(false); + // 스캔 결과로 품목 필터 + setKeyword(barcode); + fetchOrders(barcode); + }} + /> +
+ ); +} diff --git a/frontend/app/(main)/COMPANY_10/pop/_components/inbound/InboundCart.tsx b/frontend/app/(main)/COMPANY_10/pop/_components/inbound/InboundCart.tsx new file mode 100644 index 00000000..0e4c5ad0 --- /dev/null +++ b/frontend/app/(main)/COMPANY_10/pop/_components/inbound/InboundCart.tsx @@ -0,0 +1,737 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { usePopCompanyPath } from "@/hooks/usePopCompanyPath"; +import React, { useCallback, useEffect, useState } from "react"; +import { apiClient } from "@/lib/api/client"; +import { InspectionModal, type InspectionResult } from "./InspectionModal"; +import type { PackageEntry } from "./NumberPadModal"; + +/* ------------------------------------------------------------------ */ +/* Warehouse type */ +/* ------------------------------------------------------------------ */ + +interface Warehouse { + warehouse_code: string; + warehouse_name: string; + warehouse_type?: string; +} + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +export interface CartItem { + id: string; + /** cart_items 테이블의 PK (UUID) — DB 삭제용 */ + dbId?: string; + /** purchase_detail or purchase_order_mng */ + source_table: string; + /** PK of the source row */ + source_id: string; + purchase_no: string; + item_code: string; + item_name: string; + spec: string; + material: string; + order_qty: number; + remain_qty: number; + /** User-entered quantity */ + inbound_qty: number; + unit_price: number; + supplier_code: string; + supplier_name: string; + order_date: string; + inspection_required?: boolean; + inspection_type?: "self" | "request" | null; + packages?: PackageEntry[]; + inspectionResult?: InspectionResult | null; +} + +interface InboundCartProps { + open: boolean; + onClose: () => void; + items: CartItem[]; + onUpdateQty: (id: string, qty: number) => void; + onRemove: (id: string) => void; + onClear: () => void; + supplierName?: string; + onUpdateItems?: (items: CartItem[]) => void; +} + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export function InboundCart({ + open, + onClose, + items, + onUpdateQty, + onRemove, + onClear, + supplierName, + onUpdateItems, +}: InboundCartProps) { + const router = useRouter(); + const companyPath = usePopCompanyPath(); + const [confirming, setConfirming] = useState(false); + const [resultMsg, setResultMsg] = useState(null); + const [selectedItems, setSelectedItems] = useState>(new Set()); + const [inspectionModalOpen, setInspectionModalOpen] = useState(false); + const [inspectionTarget, setInspectionTarget] = useState( + null, + ); + + /* Warehouse state */ + const [warehouses, setWarehouses] = useState([]); + const [selectedWarehouse, setSelectedWarehouse] = useState(""); + + /* Fetch warehouses on mount */ + const fetchWarehouses = useCallback(async () => { + try { + const res = await apiClient.get("/receiving/warehouses"); + const data: Warehouse[] = res.data?.data ?? []; + setWarehouses(data); + if (data.length > 0 && !selectedWarehouse) { + setSelectedWarehouse(data[0].warehouse_code); + } + } catch { + // Keep empty - user can still confirm without warehouse + } + }, [selectedWarehouse]); + + useEffect(() => { + if (open) { + fetchWarehouses(); + } + }, [open, fetchWarehouses]); + + const totalQty = items.reduce((s, i) => s + i.inbound_qty, 0); + const totalAmount = items.reduce( + (s, i) => s + i.inbound_qty * i.unit_price, + 0, + ); + + /* Toggle select */ + const toggleSelect = (id: string) => { + setSelectedItems((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const toggleSelectAll = () => { + if (selectedItems.size === items.length) { + setSelectedItems(new Set()); + } else { + setSelectedItems(new Set(items.map((i) => i.id))); + } + }; + + /* Open inspection modal */ + const openInspection = (item: CartItem) => { + setInspectionTarget(item); + setInspectionModalOpen(true); + }; + + /* Handle inspection complete */ + const handleInspectionComplete = (result: InspectionResult) => { + if (!inspectionTarget || !onUpdateItems) return; + const updated = items.map((item) => + item.id === inspectionTarget.id + ? { ...item, inspectionResult: result } + : item, + ); + onUpdateItems(updated); + setInspectionTarget(null); + }; + + /* Confirm inbound — PC receivingController.create 와 동일한 body 구조 */ + const handleConfirm = async () => { + if (items.length === 0) return; + if (!selectedWarehouse) { + setResultMsg("오류: 입고 창고를 선택해주세요."); + return; + } + setConfirming(true); + setResultMsg(null); + + try { + // 1. 입고번호 채번 (RCV-YYYY-XXXX) + let inboundNumber: string | undefined; + try { + const numRes = await apiClient.get("/receiving/generate-number"); + if (numRes.data?.success && numRes.data?.data) { + inboundNumber = numRes.data.data; + } + } catch { + // 채번 실패 시 백엔드가 처리 + } + + // 2. POST /api/receiving — PC create 와 동일한 payload + const payload = { + inbound_number: inboundNumber, + inbound_date: new Date().toISOString().slice(0, 10), + warehouse_code: selectedWarehouse, + inbound_type: "구매입고", + items: items.map((item, idx) => ({ + inbound_type: "구매입고", + item_number: item.item_code, + item_name: item.item_name, + spec: item.spec || "", + material: item.material || "", + unit: "EA", + inbound_qty: String(item.inbound_qty), + unit_price: String(item.unit_price || 0), + total_amount: String( + (item.inbound_qty || 0) * (item.unit_price || 0), + ), + reference_number: item.purchase_no, + supplier_code: item.supplier_code, + supplier_name: item.supplier_name, + inspection_status: item.inspectionResult?.completed + ? "검사완료" + : item.inspection_required + ? "검사대기" + : "합격", + source_table: item.source_table, + source_id: item.source_id || item.id, + seq_no: idx + 1, + })), + }; + + const res = await apiClient.post("/receiving", payload); + + if (res.data?.success) { + // 2-1. 검사 결과가 있는 항목 → inspection_result에 저장 + const insertedDetails: any[] = + res.data?.data?.details ?? res.data?.data?.items ?? []; + const inboundHeaderNo = + res.data?.data?.header?.inbound_number || inboundNumber || ""; + const inspectionPromises = items + .map((item, idx) => { + if (!item.inspectionResult?.completed) return null; + const matchedDetail = insertedDetails[idx] ?? {}; + const referenceId = + matchedDetail.id || + matchedDetail.detail_id || + `${inboundHeaderNo}-${idx + 1}`; + const goodQty = item.inspectionResult.goodQty || 0; + const badQty = item.inspectionResult.badQty || 0; + const totalQty = goodQty + badQty; + const overallJudgment = badQty === 0 ? "합격" : "불합격"; + return apiClient + .post("/pop/inspection-result", { + inspectionNumber: item.inspectionResult.inspectionNumber, // 카트에서 받은 검사번호 재사용 + referenceTable: "inbound_mng", + referenceId, + screenId: "pop_inbound_inspection", + itemId: item.item_id || null, + itemCode: item.item_code, + itemName: item.item_name, + inspectionType: "입고검사", + overallJudgment, + totalQty, + goodQty, + badQty, + defectDescription: badQty > 0 ? `불량 ${badQty}건` : "", + memo: item.inspectionResult.remark || "", + supplierCode: item.supplier_code || null, + supplierName: item.supplier_name || null, + isCompleted: true, + items: item.inspectionResult.items.map((insp: any) => ({ + inspectionInfoId: insp.id || null, + inspectionItemName: insp.inspection_item_name, + inspectionStandard: insp.inspection_standard, + passCriteria: insp.pass_criteria, + isRequired: insp.is_required || "Y", + measuredValue: insp.measured_value || "", + judgment: insp.result || null, + })), + }) + .catch((err) => { + console.error( + "[inspection_result 저장 실패]", + item.item_code, + err?.message, + ); + }); + }) + .filter(Boolean); + + if (inspectionPromises.length > 0) { + await Promise.allSettled(inspectionPromises); + } + + // 3. cart_items DB 정리 (백그라운드, 논블로킹) + // cart_items.row_key 로 삭제 (row_key = source_id 로 저장됨) + const rowKeys = items + .map((item) => item.source_id || item.id) + .filter(Boolean); + if (rowKeys.length > 0) { + apiClient + .post("/pop/execute-action", { + tasks: [{ type: "cart-save" }], + cartChanges: { + toDelete: rowKeys, + }, + }) + .catch(() => { + // cart cleanup 실패 시 무시 + }); + } + + const inboundNo = + res.data?.data?.header?.inbound_number || inboundNumber || ""; + setResultMsg(`${items.length}건 입고 등록 완료! (${inboundNo})`); + setTimeout(() => { + onClear(); + onClose(); + router.push(companyPath("/pop/inbound")); + }, 1500); + } else { + setResultMsg( + `오류: ${res.data?.message || "입고 등록에 실패했습니다."}`, + ); + } + } catch (err: unknown) { + const msg = + err instanceof Error ? err.message : "입고 등록에 실패했습니다."; + setResultMsg(`오류: ${msg}`); + } finally { + setConfirming(false); + } + }; + + if (!open) return null; + + return ( +
+ {/* Overlay */} +
+ + {/* Panel */} +
+ {/* Header */} +
+
+
+ + + +
+
+

입고 장바구니

+ {supplierName && ( +

{supplierName}

+ )} +
+
+ +
+ + {/* Select all bar */} + {items.length > 0 && ( +
+ + + 전체 선택 ({selectedItems.size}/{items.length}) + +
+ )} + + {/* Items */} +
+ {items.length === 0 ? ( +
+ + + +

담은 품목이 없습니다

+
+ ) : ( +
+ {items.map((item) => ( +
+ {/* Top row: checkbox + name + delete */} +
+ {/* Checkbox */} + + +
+

+ {item.item_name} +

+

+ {item.item_code} | {item.purchase_no} +

+
+ + {/* Delete button */} + +
+ + {/* Spec row */} + {(item.spec || item.material) && ( +

+ {[item.spec, item.material].filter(Boolean).join(" | ")} +

+ )} + + {/* Package info */} + {item.packages && item.packages.length > 0 && ( +
+
+ + 포장완료 + + + {"\uD83D\uDCE6"}{" "} + {item.packages + .map( + (p) => + `${p.count}${p.unit.label} x ${p.qtyPerUnit.toLocaleString()} = ${(p.count * p.qtyPerUnit).toLocaleString()}EA`, + ) + .join(", ")} + +
+
+ )} + + {/* Inspection row */} + {(item.inspection_type === "self" || + item.inspection_type === "request") && ( +
+ +
+ )} + + {/* Qty controls */} +
+
+ + 미입고: {item.remain_qty.toLocaleString()} + +
+
+ + { + const v = parseInt(e.target.value, 10); + if (!isNaN(v) && v >= 0) + onUpdateQty(item.id, Math.min(v, item.remain_qty)); + }} + className="w-16 h-8 text-center text-sm font-semibold border border-gray-200 rounded-lg outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-100" + style={{ fontVariantNumeric: "tabular-nums" }} + /> + +
+
+
+ ))} +
+ )} +
+ + {/* Footer summary + confirm */} + {items.length > 0 && ( +
+ {/* Result message */} + {resultMsg && ( +
+ {resultMsg} +
+ )} + + {/* Warehouse selection */} +
+ + +
+ + {/* Summary */} +
+ + 총{" "} + {items.length} + 건 + +
+ + 합계 수량:{" "} + + {totalQty.toLocaleString()} + + + {totalAmount > 0 && ( + + ({totalAmount.toLocaleString()}원) + + )} +
+
+ + {/* Buttons */} +
+ + +
+
+ )} +
+ + {/* Inspection Modal */} + {inspectionTarget && ( + { + setInspectionModalOpen(false); + setInspectionTarget(null); + }} + onComplete={handleInspectionComplete} + itemCode={inspectionTarget.item_code} + itemName={inspectionTarget.item_name} + totalQty={inspectionTarget.inbound_qty} + initialResult={inspectionTarget.inspectionResult} + /> + )} +
+ ); +} diff --git a/frontend/app/(main)/COMPANY_10/pop/_components/inbound/InboundCartPage.tsx b/frontend/app/(main)/COMPANY_10/pop/_components/inbound/InboundCartPage.tsx new file mode 100644 index 00000000..cc902d30 --- /dev/null +++ b/frontend/app/(main)/COMPANY_10/pop/_components/inbound/InboundCartPage.tsx @@ -0,0 +1,1475 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { usePopCompanyPath } from "@/hooks/usePopCompanyPath"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { apiClient } from "@/lib/api/client"; +import { type CartItemWithId, useCartSync } from "../common/useCartSync"; +import { InspectionModal, type InspectionResult } from "./InspectionModal"; +import { NumberPadModal, type PackageEntry } from "./NumberPadModal"; +import { LoadingUnitModal, type LoadingUnitSelection } from "./LoadingUnitModal"; +import { SimpleKeypadModal } from "../common/SimpleKeypadModal"; +import { COLOR_MAP } from "../common/theme"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +interface Warehouse { + warehouse_code: string; + warehouse_name: string; + warehouse_type?: string; +} + +/** CartItemWithId -> 화면 표시용 파싱 결과 */ +interface CartItemParsed { + id: string; + rowKey: string; + dbId: string; + source_table: string; + source_id: string; + purchase_no: string; + item_code: string; + item_name: string; + spec: string; + material: string; + order_qty: number; + remain_qty: number; + inbound_qty: number; + unit_price: number; + supplier_code: string; + supplier_name: string; + order_date?: string; + inspection_required?: boolean; + inspection_type?: "self" | "request" | null; + packages?: PackageEntry[]; + image?: string | null; + inbound_type: string; + loading_code?: string; + loading_name?: string; +} + +/* ------------------------------------------------------------------ */ +/* Helper: CartItemWithId -> CartItemParsed */ +/* ------------------------------------------------------------------ */ +function toCartItemParsed(item: CartItemWithId): CartItemParsed { + const data = item.row; + const inspType = + data.inspection_type === "self" + ? "self" + : data.inspection_type === "request" + ? "request" + : null; + + return { + id: item.rowKey || String(data.id ?? ""), + rowKey: item.rowKey, + dbId: item.cartId || "", + source_table: + item.sourceTable || String(data.source_table ?? ""), + source_id: item.rowKey || String(data.id ?? ""), + purchase_no: String(data.purchase_no ?? ""), + item_code: String(data.item_code ?? ""), + item_name: String(data.item_name ?? ""), + spec: String(data.spec ?? ""), + material: String(data.material ?? ""), + order_qty: Number(data.order_qty ?? 0), + remain_qty: Number(data.remain_qty ?? 0), + inbound_qty: item.quantity, + unit_price: Number(data.unit_price ?? 0), + supplier_code: String(data.supplier_code ?? ""), + supplier_name: String(data.supplier_name ?? ""), + order_date: data.order_date ? String(data.order_date) : undefined, + inspection_type: inspType, + inspection_required: inspType === "self", + // packageEntries의 실제 런타임 타입은 NumberPadModal의 PackageEntry[] + packages: item.packageEntries as unknown as PackageEntry[] | undefined, + image: data.image ? String(data.image) : null, + inbound_type: String(data.inbound_type ?? ""), + loading_code: data.loading_code ? String(data.loading_code) : undefined, + loading_name: data.loading_name ? String(data.loading_name) : undefined, + }; +} + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +interface InboundCartPageProps { + backUrl: string; +} + +export function InboundCartPage({ backUrl }: InboundCartPageProps) { + const router = useRouter(); + const companyPath = usePopCompanyPath(); + + /* Cart sync hook — 입고 카테고리 공통 */ + const cart = useCartSync("inbound"); + + /* Derived: parsed items from cart */ + const items = useMemo( + () => cart.cartItems.map(toCartItemParsed), + [cart.cartItems], + ); + + /* Inspection results (local overlay, keyed by rowKey) */ + const [inspectionResults, setInspectionResults] = useState< + Map + >(new Map()); + + /* Selection */ + const [selectedItems, setSelectedItems] = useState>(new Set()); + + /* Auto-select all when items change */ + useEffect(() => { + if (items.length > 0) { + setSelectedItems(new Set(items.map((i) => i.id))); + } + }, [items]); + + /* Sync inspectionResults with cart.row.inspectionResult + * 페이지 새로고침/재진입 시 cart_items에 저장된 inspectionResult를 Map으로 복원. + * 주의: delete는 명시적 검사 취소(handleCancel)에서만 처리. + * (cart.saveToDb 후 row JSON이 stale할 수 있어 delete 로직은 race condition 유발) */ + useEffect(() => { + setInspectionResults((prev) => { + const next = new Map(prev); + let changed = false; + cart.cartItems.forEach((c) => { + const stored = (c.row as Record)?.inspectionResult; + if (stored && typeof stored === "object") { + // 유효한 검사 결과 → Map에 추가 (덮어쓰지 않음, 로컬 우선) + if (!next.has(c.rowKey)) { + next.set(c.rowKey, stored as InspectionResult); + changed = true; + } + } + // null/undefined여도 Map에서 자동 제거하지 않음 — 명시적 cancel만 처리 + }); + // 카트에서 사라진 rowKey만 정리 (실제 카트 삭제 시) + const cartKeys = new Set(cart.cartItems.map((c) => c.rowKey)); + Array.from(next.keys()).forEach((k) => { + if (!cartKeys.has(k)) { + next.delete(k); + changed = true; + } + }); + return changed ? next : prev; + }); + }, [cart.cartItems]); + + /* Warehouse */ + const [warehouses, setWarehouses] = useState([]); + const [selectedWarehouse, setSelectedWarehouse] = useState(""); + const [warehousePickerOpen, setWarehousePickerOpen] = useState(false); + + /* Inbound number */ + const [inboundNumber, setInboundNumber] = useState(""); + + /* Confirm result modal */ + const [confirmResult, setConfirmResult] = useState<{ + inboundNumber: string; + items: CartItemParsed[]; + warehouse: string; + date: string; + } | null>(null); + + /* Inbound date */ + const [inboundDate, setInboundDate] = useState( + new Date().toISOString().slice(0, 10), + ); + + /* Confirm state */ + const [confirming, setConfirming] = useState(false); + const [resultMsg, setResultMsg] = useState(null); + + /* Inspection modal */ + const [inspectionModalOpen, setInspectionModalOpen] = useState(false); + const [inspectionTarget, setInspectionTarget] = + useState(null); + + /* Simple keypad modal (for qty edit) */ + const [numpadOpen, setNumpadOpen] = useState(false); + const [numpadTarget, setNumpadTarget] = useState(null); + + /* Packaging modal (for package unit registration) */ + const [packagingOpen, setPackagingOpen] = useState(false); + const [packagingTarget, setPackagingTarget] = useState(null); + + /* 거래처 필터 */ + const [selectedSupplierFilter, setSelectedSupplierFilter] = useState(""); + + /* 거래처 목록 추출 (중복 제거) */ + const supplierList = useMemo(() => { + const map = new Map(); + items.forEach((item) => { + if (item.supplier_code && !map.has(item.supplier_code)) { + map.set(item.supplier_code, item.supplier_name); + } + }); + return Array.from(map.entries()).map(([code, name]) => ({ code, name })); + }, [items]); + + /* 거래처 1개면 자동 선택 */ + useEffect(() => { + if (supplierList.length === 1) { + setSelectedSupplierFilter(supplierList[0].code); + } + }, [supplierList]); + + /* 렌더링용 filteredItems (선택/확정 로직은 전체 items 기반 유지) */ + const filteredItems = useMemo(() => { + if (!selectedSupplierFilter) return items; + return items.filter((item) => item.supplier_code === selectedSupplierFilter); + }, [items, selectedSupplierFilter]); + + /* ------------------------------------------------------------------ */ + /* Fetch warehouses */ + /* ------------------------------------------------------------------ */ + const fetchedRef = useRef(false); + + const fetchWarehouses = useCallback(async () => { + try { + const res = await apiClient.get("/receiving/warehouses"); + const data: Warehouse[] = res.data?.data ?? []; + setWarehouses(data); + if (data.length > 0) { + setSelectedWarehouse(data[0].warehouse_code); + } + } catch { + /* keep empty */ + } + }, []); + + useEffect(() => { + if (fetchedRef.current) return; + fetchedRef.current = true; + fetchWarehouses(); + }, [fetchWarehouses]); + + /* ------------------------------------------------------------------ */ + /* Selection */ + /* ------------------------------------------------------------------ */ + const toggleSelect = (id: string) => { + setSelectedItems((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const toggleSelectAll = () => { + if (selectedItems.size === items.length) { + setSelectedItems(new Set()); + } else { + setSelectedItems(new Set(items.map((i) => i.id))); + } + }; + + /* ------------------------------------------------------------------ */ + /* Qty edit via numpad */ + /* ------------------------------------------------------------------ */ + const openNumpad = (item: CartItemParsed) => { + setNumpadTarget(item); + setNumpadOpen(true); + }; + + const handleNumpadConfirm = (qty: number) => { + if (!numpadTarget) return; + const finalQty = Math.min(qty, numpadTarget.remain_qty); + + cart.updateItemQuantity(numpadTarget.rowKey, finalQty); + setNumpadTarget(null); + }; + + /* Packaging handlers */ + const openPackaging = (item: CartItemParsed) => { + setPackagingTarget(item); + setPackagingOpen(true); + }; + + const handlePackagingConfirm = (_qty: number, packages: PackageEntry[]) => { + if (!packagingTarget) return; + cart.updateItemQuantity( + packagingTarget.rowKey, + packagingTarget.inbound_qty, + undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + packages.length > 0 ? (packages as any) : undefined, + ); + setPackagingTarget(null); + }; + + + /* ------------------------------------------------------------------ */ + /* Remove item */ + /* ------------------------------------------------------------------ */ + const handleRemove = (rowKey: string) => { + cart.removeItem(rowKey); + setSelectedItems((prev) => { + const next = new Set(prev); + next.delete(rowKey); + return next; + }); + // Auto-save effect below will persist change to DB + }; + + /* Auto-save: persist dirty changes to DB after a short debounce */ + const autoSaveTimerRef = useRef | null>(null); + useEffect(() => { + if (!cart.isDirty || cart.syncStatus === "saving") return; + if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current); + autoSaveTimerRef.current = setTimeout(() => { + cart.saveToDb().catch(() => {}); + }, 500); + return () => { + if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current); + }; + }, [cart.isDirty, cart.syncStatus, cart]); + + /* ------------------------------------------------------------------ */ + /* Inspection */ + /* ------------------------------------------------------------------ */ + const openInspection = (item: CartItemParsed) => { + setInspectionTarget(item); + setInspectionModalOpen(true); + }; + + const handleInspectionComplete = (result: InspectionResult) => { + if (!inspectionTarget) return; + const targetRowKey = inspectionTarget.rowKey; + setInspectionResults((prev) => { + const next = new Map(prev); + next.set(targetRowKey, result); + return next; + }); + // cart_items.row_data에 검사 결과 저장 (페이지 새로고침해도 유지) + cart.updateItemRow(targetRowKey, { inspectionResult: result }); + setInspectionTarget(null); + // 즉시 DB 저장 (자동저장 디바운스를 기다리지 않음) + setTimeout(() => { + cart + .saveToDb() + .catch((err) => console.error("[검사 결과 저장 실패]", err)); + }, 100); + }; + + /* Pass inspection (non-required only) */ + const handlePassInspection = (rowKey: string) => { + const item = items.find((i) => i.rowKey === rowKey); + if (!item) return; + const result: InspectionResult = { + items: [], + goodQty: item.inbound_qty, + badQty: 0, + remark: "pass", + completed: true, + }; + setInspectionResults((prev) => { + const next = new Map(prev); + next.set(rowKey, result); + return next; + }); + cart.updateItemRow(rowKey, { inspectionResult: result }); + }; + + const getInspectionResult = (rowKey: string): InspectionResult | null => { + return inspectionResults.get(rowKey) || null; + }; + + /* ------------------------------------------------------------------ */ + /* Validation: required inspections */ + /* ------------------------------------------------------------------ */ + const selectedItemsList = items.filter((i) => selectedItems.has(i.id)); + + /* Loading unit modal — 선택된 품목 일괄 적재함 적용 */ + const [loadingModalOpen, setLoadingModalOpen] = useState(false); + + const openLoadingModal = () => { + setLoadingModalOpen(true); + }; + + const selectedPkgCodes = useMemo(() => { + return selectedItemsList + .filter((item) => item.packages && item.packages.length > 0) + .map((item) => item.packages![0].unit.value); + }, [selectedItemsList]); + + const handleLoadingSelect = (lu: LoadingUnitSelection) => { + for (const item of selectedItemsList) { + cart.updateItemRow(item.rowKey, { + loading_code: lu.loading_code, + loading_name: lu.loading_name, + }); + } + }; + + const handleLoadingClear = () => { + for (const item of selectedItemsList) { + cart.updateItemRow(item.rowKey, { + loading_code: null, + loading_name: null, + }); + } + }; + + // CEO 정책 (2026-04-09 시연 결정): 검사 필수 항목 미완료 시 확정 차단 + // 검사 빠진 입고가 검사관리에서 추적 안 되므로, 입력 시점에 막음 + const hasUnfinishedRequiredInspection = selectedItemsList.some( + (item) => + item.inspection_required && + item.inspection_type === "self" && + !getInspectionResult(item.rowKey)?.completed, + ); + + /* ------------------------------------------------------------------ */ + /* Confirm inbound */ + /* ------------------------------------------------------------------ */ + const handleConfirm = async () => { + if (selectedItemsList.length === 0) return; + + if (!selectedWarehouse) { + setResultMsg("오류: 입고 창고를 선택해주세요."); + return; + } + + // 검사 미완료여도 확정 가능. 단지 inspection_result에 안 들어가거나 "대기" 상태로 기록. + // (CEO 정책: 입고 자체는 진행, 검사 결과만 누락/대기 상태로 표시) + + setConfirming(true); + setResultMsg(null); + + try { + // 확정 시점에 채번 (동시접속 충돌 방지) + // numbering_rules 테이블에서 채번규칙 직접 조회 + let finalNumber = ""; + try { + const ruleRes: any = await apiClient + .get("/numbering-rules/by-column/inbound_mng/inbound_number") + .catch(() => null); + const ruleId = ruleRes?.data?.data?.ruleId; + const url = + ruleId + ? `/receiving/generate-number?ruleId=${encodeURIComponent(ruleId)}` + : "/receiving/generate-number"; + const numRes = await apiClient.get(url); + if (numRes.data?.success && numRes.data?.data) { + finalNumber = numRes.data.data; + setInboundNumber(finalNumber); + } + } catch { + /* backend will handle */ + } + + // POST /api/receiving -- same payload structure as PC + // 헤더 inbound_type: 단일이면 그대로, 혼합이면 "혼합입고" + const uniqueTypes = [...new Set(selectedItemsList.map((i) => i.inbound_type).filter(Boolean))]; + const headerInboundType = uniqueTypes.length === 1 ? uniqueTypes[0] : (uniqueTypes.length > 1 ? "혼합입고" : "입고"); + + const payload = { + inbound_number: finalNumber, + inbound_date: inboundDate, + warehouse_code: selectedWarehouse, + inbound_type: headerInboundType, + items: selectedItemsList.map((item, idx) => { + const inspResult = getInspectionResult(item.rowKey); + return { + inbound_type: item.inbound_type || headerInboundType, + item_number: item.item_code, + item_name: item.item_name, + spec: item.spec || "", + material: item.material || "", + unit: "EA", + inbound_qty: String(item.inbound_qty), + unit_price: String(item.unit_price || 0), + total_amount: String( + (item.inbound_qty || 0) * (item.unit_price || 0), + ), + reference_number: item.purchase_no, + supplier_code: item.supplier_code, + supplier_name: item.supplier_name, + inbound_status: "입고완료", + inspection_status: inspResult?.completed + ? "검사완료" + : item.inspection_required + ? "검사대기" + : "합격", + source_table: item.source_table, + source_id: item.source_id || item.id, + seq_no: idx + 1, + }; + }), + }; + + const res = await apiClient.post("/receiving", payload); + + if (res.data?.success) { + // 검사 결과를 inspection_result_mng + inspection_result에 저장 + const insertedDetails: Array> = + (res.data?.data?.details as Array>) ?? + (res.data?.data?.items as Array>) ?? + []; + const inboundHeaderNo: string = + (res.data?.data?.header as { inbound_number?: string } | undefined) + ?.inbound_number || + finalNumber || + ""; + const inspectionPromises = selectedItemsList + .map((item, idx) => { + const inspResult = getInspectionResult(item.rowKey); + if (!inspResult?.completed) return null; + const matchedDetail = insertedDetails[idx] ?? {}; + const referenceId = + (matchedDetail.id as string) || + (matchedDetail.detail_id as string) || + `${inboundHeaderNo}-${idx + 1}`; + const goodQty = inspResult.goodQty || 0; + const badQty = inspResult.badQty || 0; + const totalQty = goodQty + badQty; + const overallJudgment = badQty === 0 ? "합격" : "불합격"; + return apiClient + .post("/pop/inspection-result", { + inspectionNumber: inspResult.inspectionNumber, + referenceTable: "inbound_mng", + referenceId, + screenId: "pop_inbound_inspection", + itemId: item.item_id || null, + itemCode: item.item_code, + itemName: item.item_name, + inspectionType: "입고검사", + overallJudgment, + totalQty, + goodQty, + badQty, + defectDescription: badQty > 0 ? `불량 ${badQty}건` : "", + memo: inspResult.remark || "", + supplierCode: item.supplier_code || null, + supplierName: item.supplier_name || null, + isCompleted: true, + items: inspResult.items.map((insp) => ({ + inspectionInfoId: insp.id || null, + inspectionItemName: insp.inspection_item_name, + inspectionStandard: insp.inspection_standard, + passCriteria: insp.pass_criteria, + isRequired: insp.is_required || "Y", + measuredValue: insp.measured_value || "", + judgment: insp.result || null, + })), + }) + .catch((err: unknown) => { + const e = err as { message?: string }; + console.error( + "[inspection_result 저장 실패]", + item.item_code, + e?.message, + ); + }); + }) + .filter(Boolean); + if (inspectionPromises.length > 0) { + await Promise.all(inspectionPromises); + } + + // Remove confirmed items from cart - direct DB delete for reliability + const confirmedItems = [...selectedItemsList]; + const { dataApi } = await import("@/lib/api/data"); + const confirmPromises = confirmedItems + .filter((item) => item.dbId) + .map((item) => + dataApi + .updateRecord("cart_items", item.dbId, { status: "confirmed" }) + .catch(() => {}), + ); + await Promise.all(confirmPromises); + + // Also clean up local state via useCartSync + for (const item of confirmedItems) { + cart.removeItem(item.rowKey); + } + // Reload from DB to sync state + await cart.loadFromDb(); + + const inboundNo = + res.data?.data?.header?.inbound_number || finalNumber || ""; + + // 결과 모달 표시 (바로 이동하지 않음) + setConfirmResult({ + inboundNumber: inboundNo, + items: confirmedItems, + warehouse: + warehouses.find((w) => w.warehouse_code === selectedWarehouse) + ?.warehouse_name || selectedWarehouse, + date: inboundDate, + }); + setResultMsg(null); + } else { + setResultMsg( + `오류: ${res.data?.message || "입고 등록에 실패했습니다."}`, + ); + } + } catch (err: unknown) { + const msg = + err instanceof Error ? err.message : "입고 등록에 실패했습니다."; + setResultMsg(`오류: ${msg}`); + } finally { + setConfirming(false); + } + }; + + /* ------------------------------------------------------------------ */ + /* Helpers */ + /* ------------------------------------------------------------------ */ + const selectedWarehouseName = + warehouses.find((w) => w.warehouse_code === selectedWarehouse) + ?.warehouse_name || selectedWarehouse; + + const totalQty = selectedItemsList.reduce((s, i) => s + i.inbound_qty, 0); + + /* ------------------------------------------------------------------ */ + /* Render */ + /* ------------------------------------------------------------------ */ + return ( +
+ + {/* ===== Header ===== */} +
+
+ +
+

+ 입고 장바구니 +

+
+
+ + {/* Confirm button (header only) */} + +
+ + {/* ===== Info banner ===== */} +
+
+ {inboundDate} + {selectedWarehouseName && ( + + | {selectedWarehouseName} + + )} + + {inboundNumber || "확정 시 자동생성"} + +
+ + {/* Info fields: 4 columns (거래처 + 입고일자 + 창고 + 입고번호) */} +
+ {/* 거래처 드롭다운 */} +
+ + +
+ {/* Inbound date */} +
+ + setInboundDate(e.target.value)} + className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-100 bg-white" + /> +
+ + {/* Warehouse selector - card-style touch button */} +
+ + +
+ + {/* Inbound number (readonly -- 확정 시점에 채번) */} +
+ +
+ {inboundNumber ? ( + {inboundNumber} + ) : ( + 확정 시 자동생성 + )} +
+
+
+
+ + {/* ===== Select all bar (1번 변경: 선택해제 버튼을 좌측 버튼 형태로 이동) ===== */} + {items.length > 0 && ( +
+ + + 담은 품목 {items.length} + + +
+ +
+ )} + + {/* ===== Items list ===== */} + {cart.loading ? ( +
+ + + + + 불러오는 중... +
+ ) : items.length === 0 ? ( +
+ + + +

+ 담은 품목이 없습니다 +

+

+ 입고 화면에서 품목을 담아주세요 +

+ +
+ ) : ( +
+ {(() => { + /* 방식 A: 적재함 > 입고유형 그룹핑 */ + const loadingGroups = new Map(); + const ungrouped: typeof filteredItems = []; + + filteredItems.forEach((item) => { + if (item.loading_code) { + const existing = loadingGroups.get(item.loading_code); + if (existing) { + existing.items.push(item); + } else { + loadingGroups.set(item.loading_code, { + loading_name: item.loading_name || item.loading_code, + items: [item], + }); + } + } else { + ungrouped.push(item); + } + }); + + const renderTypeGroupedCards = (groupItems: typeof filteredItems) => { + const typeGroups = groupItems.reduce>((acc, it) => { + const type = it.inbound_type || "입고"; + if (!acc[type]) acc[type] = []; + acc[type].push(it); + return acc; + }, {}); + return Object.entries(typeGroups).map(([type, typeItems]) => ( +
+
+ {type} +
+
+
+ {typeItems.map((item) => { + const inspResult = getInspectionResult(item.rowKey); + return ( +
+ + {/* === Header row: checkbox + item code + item name === */} +
+ {/* Checkbox */} + + + {item.item_code} + + + {item.item_name} + + {/* Inspection button */} + {(item.inspection_type === "self" || item.inspection_type === "request") && ( + + )} +
+ + {/* === Body row: image + info + action === */} +
+ {/* Product image */} +
+ {item.image ? ( + {item.item_name} + ) : ( + + {"\uD83D\uDCE6"} + + )} +
+ + {/* Info columns */} +
+
+ + 발주일 + + + {item.order_date || "-"} + +
+
+ + 발주번호 + + + {item.purchase_no || "-"} + +
+
+ + 발주수량 + + + {item.order_qty.toLocaleString()} + +
+
+ + 미입고 + + + {item.remain_qty.toLocaleString()} + +
+
+ + {/* Action column: qty + inspection + delete */} +
+ {/* Qty display - clickable to open simple keypad */} + + + {/* Delete button */} + + +
+
+ + {/* Packaging button - full width (포장 미완료 시만 표시) */} + {!(item.packages && item.packages.length > 0) && ( +
+ +
+ )} + + {/* === Package info (포장 등록 시 — 클릭하면 모달 열림) === */} + {item.packages && item.packages.length > 0 && (() => { + const packagedQty = item.packages.reduce( + (s, p) => s + p.count * p.qtyPerUnit, + 0, + ); + const unpacked = Math.max(0, item.inbound_qty - packagedQty); + const isComplete = unpacked === 0; + return ( +
openPackaging(item)} + className={`mt-2.5 px-3 py-2 border rounded-lg cursor-pointer active:scale-95 transition-all ${ + isComplete + ? "bg-gradient-to-r from-green-50 to-emerald-50 border-green-200" + : "bg-gradient-to-r from-amber-50 to-orange-50 border-amber-200" + }`} + > +
+ + {isComplete ? "포장완료" : "부분포장"} + + + {packagedQty.toLocaleString()}{" "} + EA + +
+
+ {item.packages.map((pkg, idx) => ( +
+ {pkg.unit.icon} + + {pkg.count} + {pkg.unit.label} x {pkg.qtyPerUnit.toLocaleString()}EA + = {(pkg.count * pkg.qtyPerUnit).toLocaleString()}EA + +
+ ))} +
+ {!isComplete && ( +
+ 미포장 + + {unpacked.toLocaleString()} EA + +
+ )} +
+ ); + })()} +
+ ); + })} +
+
+ )); + }; + + return ( + <> + {Array.from(loadingGroups.entries()).map(([code, group]) => ( +
+
+ {"\uD83D\uDEA2"} + {group.loading_name} + {code} + {group.items.length}건 +
+ {renderTypeGroupedCards(group.items)} +
+ ))} + {ungrouped.length > 0 && renderTypeGroupedCards(ungrouped)} + + ); + })()} +
+ )} + + {/* ===== Result toast (only when message exists) ===== */} + {resultMsg && ( +
+
+ {resultMsg} +
+
+ )} + + {/* ===== Warehouse picker modal ===== */} + {warehousePickerOpen && ( +
+
setWarehousePickerOpen(false)} + /> +
+ {/* Header */} +
+

창고 선택

+ +
+ + {/* Warehouse list */} +
+ {warehouses.length === 0 ? ( +

+ 등록된 창고가 없습니다 +

+ ) : ( +
+ {warehouses.map((wh) => ( + + ))} +
+ )} +
+
+
+ )} + + {/* ===== Inspection Modal ===== */} + {inspectionTarget && ( + { + setInspectionModalOpen(false); + setInspectionTarget(null); + }} + onComplete={handleInspectionComplete} + onCancel={() => { + // 검사 결과 무효화 (완료 → 대기 풀림) + const targetRowKey = inspectionTarget.rowKey; + setInspectionResults((prev) => { + const next = new Map(prev); + next.delete(targetRowKey); + return next; + }); + cart.updateItemRow(targetRowKey, { inspectionResult: null }); + setTimeout(() => cart.saveToDb().catch(() => {}), 100); + }} + itemCode={inspectionTarget.item_code} + itemName={inspectionTarget.item_name} + totalQty={inspectionTarget.inbound_qty} + initialResult={getInspectionResult(inspectionTarget.rowKey)} + /> + )} + + {/* ===== SimpleKeypad Modal (qty edit) ===== */} + {numpadTarget && ( + { + setNumpadOpen(false); + setNumpadTarget(null); + }} + onConfirm={handleNumpadConfirm} + maxQty={numpadTarget.remain_qty} + itemName={numpadTarget.item_name} + initialQty={numpadTarget.inbound_qty} + /> + )} + + {/* ===== NumberPad Modal (packaging) ===== */} + {packagingTarget && ( + { + setPackagingOpen(false); + setPackagingTarget(null); + }} + onConfirm={handlePackagingConfirm} + maxQty={packagingTarget.inbound_qty} + itemName={packagingTarget.item_name} + itemNumber={packagingTarget.item_code} + initialPackages={packagingTarget.packages} + /> + )} + + {/* ===== Loading Unit Modal (적재함 일괄 선택) ===== */} + setLoadingModalOpen(false)} + onSelect={handleLoadingSelect} + onClear={handleLoadingClear} + pkgCodes={selectedPkgCodes} + currentLoading={null} + /> + + {/* ===== 입고 완료 결과 모달 ===== */} + {confirmResult && ( +
+
+
+ {/* 헤더 */} +
+
+ + + +
+

입고 처리 완료

+

+ {confirmResult.inboundNumber} +

+
+ + {/* 처리 내역 */} +
+
+ + 창고:{" "} + + {confirmResult.warehouse} + + + {confirmResult.date} +
+ +
+ 처리된 품목 ({confirmResult.items.length}건) +
+
+ {confirmResult.items.map((item) => ( +
+
+

+ {item.item_name} +

+

+ {item.item_code} +

+
+ + {item.inbound_qty?.toLocaleString()} EA + +
+ ))} +
+
+ + {/* 확인 버튼 */} +
+ +
+
+
+ )} +
+ ); +} diff --git a/frontend/app/(main)/COMPANY_10/pop/_components/inbound/InboundManage.tsx b/frontend/app/(main)/COMPANY_10/pop/_components/inbound/InboundManage.tsx new file mode 100644 index 00000000..4223b7cf --- /dev/null +++ b/frontend/app/(main)/COMPANY_10/pop/_components/inbound/InboundManage.tsx @@ -0,0 +1,890 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { usePopCompanyPath } from "@/hooks/usePopCompanyPath"; +import { SupplierModal, type Supplier } from "./SupplierModal"; +import { + getReceivingList, + updateReceiving, + deleteReceiving, + getReceivingWarehouses, + type InboundItem, + type WarehouseOption, +} from "@/lib/api/receiving"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +interface InboundRecord extends InboundItem { + detail_id?: string; + seq_no?: number; + detail_inbound_type?: string; + header_memo?: string; +} + +const STATUS_OPTIONS = ["입고완료", "부분입고", "대기"]; +const INSPECTION_OPTIONS = ["대기", "검사완료", "합격", "불합격"]; +const INBOUND_TYPE_OPTIONS = [ + { value: "all", label: "전체" }, + { value: "구매입고", label: "구매입고" }, + { value: "생산입고", label: "생산입고" }, + { value: "외주입고", label: "외주입고" }, + { value: "사급자재입고", label: "사급자재입고" }, + { value: "반품입고", label: "반품입고" }, + { value: "반납입고", label: "반납입고" }, + { value: "불량입고", label: "불량입고" }, + { value: "교환입고", label: "교환입고" }, + { value: "외주자재회수", label: "외주자재회수" }, + { value: "기타입고", label: "기타입고" }, +]; + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export function InboundManage() { + const router = useRouter(); + const companyPath = usePopCompanyPath(); + const today = new Date().toISOString().slice(0, 10); + + /* ── Filters ── */ + const [inboundDate, setInboundDate] = useState(today); + const [inboundType, setInboundType] = useState("all"); + const [keyword, setKeyword] = useState(""); + const [selectedSupplier, setSelectedSupplier] = useState( + null, + ); + const [supplierModalOpen, setSupplierModalOpen] = useState(false); + + /* ── Data ── */ + const [records, setRecords] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [warehouses, setWarehouses] = useState([]); + + /* ── Edit modal ── */ + const [editRecord, setEditRecord] = useState(null); + const [editForm, setEditForm] = useState>({}); + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); + + /* ── Helpers ── */ + const getRowKey = (r: InboundRecord) => r.detail_id || r.id; + + /* ---------------------------------------------------------------- */ + /* Fetch */ + /* ---------------------------------------------------------------- */ + + const fetchRecords = useCallback(async () => { + setLoading(true); + try { + const params: Record = {}; + if (inboundDate) { + params.date_from = inboundDate; + params.date_to = inboundDate; + } + if (inboundType !== "all") params.inbound_type = inboundType; + if (keyword.trim()) params.search_keyword = keyword.trim(); + + const res = await getReceivingList(params); + if (res.success) { + let data = res.data as unknown as InboundRecord[]; + if (selectedSupplier?.customer_code) { + data = data.filter( + (r) => r.supplier_code === selectedSupplier.customer_code, + ); + } + setRecords(data); + setSelectedIds(new Set()); + } + } catch (e) { + console.error("입고 목록 조회 실패", e); + } finally { + setLoading(false); + } + }, [inboundDate, inboundType, keyword, selectedSupplier]); + + useEffect(() => { + getReceivingWarehouses() + .then((res) => { + if (res.success) setWarehouses(res.data); + }) + .catch(() => {}); + }, []); + + useEffect(() => { + fetchRecords(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + /* ---------------------------------------------------------------- */ + /* Selection */ + /* ---------------------------------------------------------------- */ + + const toggleSelect = (key: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + const toggleSelectAll = () => { + if (selectedIds.size === records.length) setSelectedIds(new Set()); + else setSelectedIds(new Set(records.map(getRowKey))); + }; + + /* ---------------------------------------------------------------- */ + /* Delete */ + /* ---------------------------------------------------------------- */ + + const handleDelete = async () => { + if (selectedIds.size === 0) return; + + const headerIds = new Set(); + records.forEach((r) => { + if (selectedIds.has(getRowKey(r))) headerIds.add(r.id); + }); + + if ( + !confirm( + `선택한 ${headerIds.size}건의 입고를 삭제하시겠습니까?\n(재고가 롤백됩니다)`, + ) + ) + return; + + setDeleting(true); + try { + for (const hid of headerIds) { + await deleteReceiving(hid); + } + await fetchRecords(); + } catch (e: any) { + alert(`삭제 실패: ${e?.message || "알 수 없는 오류"}`); + } finally { + setDeleting(false); + } + }; + + /* ---------------------------------------------------------------- */ + /* Edit */ + /* ---------------------------------------------------------------- */ + + const openEdit = (record: InboundRecord) => { + setEditRecord(record); + setEditForm({ + inbound_date: record.inbound_date?.slice(0, 10) || today, + inbound_qty: record.inbound_qty ?? 0, + unit_price: record.unit_price ?? 0, + total_amount: record.total_amount ?? 0, + lot_number: record.lot_number || "", + warehouse_code: record.warehouse_code || "", + location_code: record.location_code || "", + inbound_status: record.inbound_status || "입고완료", + inspection_status: record.inspection_status || "대기", + inspector: record.inspector || "", + manager: record.manager || "", + memo: record.memo || "", + }); + }; + + const handleEditFromSelection = () => { + if (selectedIds.size !== 1) { + alert("수정할 항목을 1건만 선택해주세요."); + return; + } + const key = Array.from(selectedIds)[0]; + const rec = records.find((r) => getRowKey(r) === key); + if (rec) openEdit(rec); + }; + + const updateField = (key: string, value: any) => { + setEditForm((prev) => { + const next = { ...prev, [key]: value }; + if (key === "inbound_qty" || key === "unit_price") { + next.total_amount = + Math.round( + (Number(next.inbound_qty) || 0) * + (Number(next.unit_price) || 0) * + 100, + ) / 100; + } + return next; + }); + }; + + const handleSave = async () => { + if (!editRecord) return; + setSaving(true); + try { + const payload: Record = { ...editForm }; + if (editRecord.detail_id) payload.detail_id = editRecord.detail_id; + + await updateReceiving(editRecord.id, payload as Partial); + setEditRecord(null); + await fetchRecords(); + } catch (e: any) { + alert(`수정 실패: ${e?.message || "알 수 없는 오류"}`); + } finally { + setSaving(false); + } + }; + + /* ---------------------------------------------------------------- */ + /* Render */ + /* ---------------------------------------------------------------- */ + + return ( +
+ {/* ===== Header ===== */} +
+
+ +
+

+ 입고관리 +

+

+ 입고 내역을 조회, 수정, 삭제합니다 +

+
+
+
+ + +
+
+ + {/* ===== Search / Filter ===== */} +
+
+ {/* 입고일 */} +
+ + setInboundDate(e.target.value)} + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100" + /> +
+ {/* 입고유형 */} +
+ + +
+ {/* 거래처 */} +
+ + +
+ {/* 검색어 + 검색버튼 */} +
+ +
+ setKeyword(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") fetchRecords(); + }} + placeholder="입고번호, 품목명, 거래처명..." + className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100" + /> + +
+
+
+
+ + {/* ===== Record list ===== */} +
+
+
+ 0 && selectedIds.size === records.length + } + onChange={toggleSelectAll} + className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" + /> + + 입고 내역 + +
+ + {selectedIds.size > 0 + ? `${selectedIds.size}건 선택` + : `총 ${records.length}건`} + +
+ + {loading && records.length === 0 ? ( +
+ + + + +
+ ) : records.length === 0 ? ( +
+ + + +

+ 입고 내역이 없습니다 +

+

+ 조회 조건을 변경하거나 입고를 진행해 주세요 +

+
+ ) : ( +
+ {records.map((record) => { + const key = getRowKey(record); + return ( +
toggleSelect(key)} + > + {/* Card header */} +
+ toggleSelect(key)} + onClick={(e) => e.stopPropagation()} + className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" + /> + + {record.inbound_number} + + + {record.detail_inbound_type || record.inbound_type} + + + {record.inbound_status || "입고"} + + +
+ {/* Card body */} +
+
+ + 품목 + + + {record.item_name || "-"} + +
+
+ + 품번 + + + {record.item_number || "-"} + +
+
+ + 거래처 + + + {record.supplier_name || "-"} + +
+
+ + 수량 + + + {Number(record.inbound_qty).toLocaleString()}{" "} + {record.unit || "EA"} + +
+
+ + 입고일 + + + {record.inbound_date?.slice(0, 10) || "-"} + +
+ {(record as any).warehouse_name && ( +
+ + 창고 + + + {(record as any).warehouse_name} + +
+ )} +
+
+ ); + })} +
+ )} +
+ + {/* ===== Edit Modal ===== */} + {editRecord && ( +
setEditRecord(null)} + > +
e.stopPropagation()} + > + {/* Modal header */} +
+
+

입고 수정

+

+ {editRecord.inbound_number} | {editRecord.item_name} +

+
+ +
+ + {/* Modal body */} +
+ {/* 기본 정보 */} + + updateField("inbound_date", v)} + /> + updateField("inbound_status", v)} + options={STATUS_OPTIONS} + /> + + + {/* 수량/금액 */} + + updateField("inbound_qty", v)} + /> + updateField("unit_price", v)} + /> + updateField("total_amount", v)} + readOnly + /> + + + {/* 입고 상세 */} + + updateField("lot_number", v)} + /> + updateField("warehouse_code", v)} + options={warehouses.map((w) => ({ + value: w.warehouse_code, + label: w.warehouse_name, + }))} + emptyLabel="선택..." + /> + updateField("location_code", v)} + /> + updateField("inspection_status", v)} + options={INSPECTION_OPTIONS} + /> + updateField("inspector", v)} + /> + updateField("manager", v)} + /> + + + {/* 메모 */} +
+

+ 메모 +

+