"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 (
{/* 품목 헤더 */}
품명
{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 (
| 기존 |
{order?.orderNo || "-"} |
{order?.partnerName || "-"} |
{order?.dueDate?.split("T")[0] || "-"} |
{order?.balanceQty?.toLocaleString() || "-"} |
{plan.planQty.toLocaleString()} |
{plan.planDate?.split("T")[0] || "-"} |
- |
{plan.shipmentPlanNo || "-"} |
|
);
})}
{/* 신규 입력 행 */}
{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() || "-"} |
{
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 ? (
) : (
)}
|
);
})}
);
})}
);
}