"use client"; /** * ShippingPlanBatchModal — 출하계획 동시 등록 모달 */ import React, { useState, useEffect, useCallback } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Plus, X, Loader2, Package, Truck, Clock } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; import { FullscreenDialog } from "@/components/common/FullscreenDialog"; import { getShippingPlanAggregate, batchSaveShippingPlans, type AggregateResponse, type BatchSavePlan, } from "@/lib/api/shipping"; // --- 시간 선택 컴포넌트 (오전/오후 + 시 + 분) --- function TimePicker({ value, onChange }: { value: string; onChange: (v: string) => void }) { const [open, setOpen] = useState(false); // value: "HH:MM" or "" const parsed = value ? value.split(":") : ["", ""]; const hour24 = parsed[0] ? parseInt(parsed[0]) : -1; const minute = parsed[1] ? parseInt(parsed[1]) : -1; const isAM = hour24 >= 0 && hour24 < 12; const [period, setPeriod] = useState<"오전" | "오후">(isAM ? "오전" : "오후"); const hours = Array.from({ length: 12 }, (_, i) => i); // 0-11 const minutes = Array.from({ length: 12 }, (_, i) => i * 5); // 0,5,10...55 const displayHour = hour24 >= 0 ? (hour24 % 12 || 12) : null; const displayMinute = minute >= 0 ? minute : null; const select = (p: "오전" | "오후", h: number, m: number) => { const h24 = p === "오전" ? (h === 12 ? 0 : h) : (h === 12 ? 12 : h + 12); onChange(`${String(h24).padStart(2, "0")}:${String(m).padStart(2, "0")}`); }; return (
{/* 오전/오후 */}
{(["오전", "오후"] as const).map((p) => ( ))}
{/* 시 */}
{[12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((h) => ( ))}
{/* 분 */}
{minutes.map((m) => ( ))}
); } // --- 타입 --- interface NewPlanRow { _id: string; sourceId: string; partCode: string; planQty: string; planDate: string; planTime: string; shipInfo: string; } interface ShippingPlanBatchModalProps { open: boolean; onOpenChange: (open: boolean) => void; selectedDetailIds: string[]; onSuccess?: () => void; } function StatCard({ label, value, color }: { label: string; value: number; color: string }) { return (
{label}
{value.toLocaleString()}
); } // --- 메인 --- export function ShippingPlanBatchModal({ open, onOpenChange, selectedDetailIds, onSuccess, }: ShippingPlanBatchModalProps) { const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [aggregate, setAggregate] = useState({}); const [newPlans, setNewPlans] = useState>({}); useEffect(() => { if (!open || selectedDetailIds.length === 0) return; const load = async () => { setLoading(true); try { const result = await getShippingPlanAggregate(selectedDetailIds); if (result.success && result.data) { setAggregate(result.data); const plans: Record = {}; for (const partCode of Object.keys(result.data)) { plans[partCode] = [makeNewRow(partCode, result.data[partCode].orders[0]?.sourceId || "")]; } setNewPlans(plans); } } catch (err) { console.error("출하계획 집계 조회 실패:", err); toast.error("출하계획 정보를 불러오는데 실패했습니다."); } finally { setLoading(false); } }; load(); }, [open, selectedDetailIds]); const makeNewRow = (partCode: string, sourceId: string): NewPlanRow => ({ _id: `new_${Date.now()}_${Math.random()}`, sourceId, partCode, planQty: "", planDate: "", planTime: "", shipInfo: "", }); const addRow = (partCode: string) => { const sourceId = aggregate[partCode]?.orders[0]?.sourceId || ""; setNewPlans((prev) => ({ ...prev, [partCode]: [...(prev[partCode] || []), makeNewRow(partCode, sourceId)] })); }; const removeRow = (partCode: string, rowId: string) => { setNewPlans((prev) => ({ ...prev, [partCode]: (prev[partCode] || []).filter((r) => r._id !== rowId) })); }; const updateRow = (partCode: string, rowId: string, field: keyof NewPlanRow, value: string) => { // planQty 변경 시 총수주잔량 초과 검증 if (field === "planQty" && value) { const agg = aggregate[partCode]; if (agg) { const maxQty = agg.totalBalance - agg.totalPlanQty; // 기존 계획 제외한 잔여 가능량 const otherSum = (newPlans[partCode] || []) .filter((r) => r._id !== rowId) .reduce((sum, r) => sum + (Number(r.planQty) || 0), 0); const remaining = maxQty - otherSum; if (Number(value) > remaining) { toast.error(`출하계획량이 잔여 가능량(${remaining.toLocaleString()})을 초과할 수 없습니다.`); value = String(Math.max(0, remaining)); } } } setNewPlans((prev) => ({ ...prev, [partCode]: (prev[partCode] || []).map((r) => r._id === rowId ? { ...r, [field]: value } : r), })); }; const totalNewPlans = Object.values(newPlans).flat().filter((r) => r.planQty && Number(r.planQty) > 0).length; const handleSave = async () => { const plans: BatchSavePlan[] = []; for (const rows of Object.values(newPlans)) { for (const row of rows) { const qty = Number(row.planQty); if (qty <= 0) continue; plans.push({ sourceId: row.sourceId, planQty: qty, planDate: row.planDate || undefined }); } } if (plans.length === 0) { toast.error("출하계획 수량을 입력해주세요."); return; } setSaving(true); try { const result = await batchSaveShippingPlans(plans, "detail"); if (result.success) { toast.success(`출하계획 ${plans.length}건이 등록되었습니다.`); onSuccess?.(); onOpenChange(false); } else { toast.error(result.message || "등록 실패"); } } catch { toast.error("등록 실패"); } finally { setSaving(false); } }; const partCodes = Object.keys(aggregate); return ( 출하계획 동시 등록} description={<>출하계획 설정: {totalNewPlans}개} defaultMaxWidth="max-w-[1200px]" footer={
💡 수주 등 시 출하계획도 함께 저장됩니다
} >
{loading ? (
) : partCodes.length === 0 ? (
선택된 품목이 없습니다.
) : partCodes.map((partCode) => { const agg = aggregate[partCode]; const orders = agg.orders || []; const existingPlans = agg.existingPlans || []; const rows = newPlans[partCode] || []; const firstOrder = orders[0]; return (
{/* 품목 헤더 */}
품목코드
{partCode}
품명
{firstOrder?.partName || "-"}
{/* 통계 카드 (신규 입력량 반영) */} {(() => { const newQtySum = (newPlans[partCode] || []).reduce((sum, r) => sum + (Number(r.planQty) || 0), 0); const totalPlanQty = agg.totalPlanQty + newQtySum; const availableStock = agg.currentStock - totalPlanQty; return (
); })()} {/* 테이블 — overflow-x-auto로 겹침 방지 */}
{/* 기존 계획 */} {existingPlans.map((plan) => { const order = orders.find((o) => o.sourceId === plan.sourceId); return ( ); })} {/* 신규 입력 행 */} {rows.map((row, rowIdx) => { const order = orders.find((o) => o.sourceId === row.sourceId) || orders[0]; return ( ); })}
구분 수주번호 거래처 납기일 미출하 출하계획량 출하계획일 시간 출하정보 분할
기존 {order?.orderNo || "-"} {order?.partnerName || "-"} {order?.dueDate?.split("T")[0] || "-"} {order?.balanceQty?.toLocaleString() || "-"} {plan.planQty.toLocaleString()} {plan.planDate?.split("T")[0] || "-"} - {plan.shipmentPlanNo || "-"}
신규 {order?.orderNo || "-"} {order?.partnerName || "-"} {order?.dueDate?.split("T")[0] || "-"} {order?.balanceQty?.toLocaleString() || "-"} { const raw = e.target.value.replace(/[^0-9]/g, ""); updateRow(partCode, row._id, "planQty", raw ? String(Number(raw)) : ""); }} className="h-8 text-xs text-center" placeholder="0" /> updateRow(partCode, row._id, "planDate", v)} placeholder="계획일" /> updateRow(partCode, row._id, "planTime", v)} /> updateRow(partCode, row._id, "shipInfo", e.target.value)} className="h-8 text-xs" placeholder="출하정보 입력" /> {rowIdx === rows.length - 1 ? ( ) : ( )}
); })}
); }