Files
vexplor/frontend/components/common/ShippingPlanBatchModal.tsx
kjs 1c562fa854 Update item info and sales order pages with new components and functionality
- Updated the item information page to include category code to label conversion for better data representation.
- Enhanced the sales order page by integrating a fullscreen dialog for improved user experience during order registration and editing.
- Added dynamic loading of delivery options based on selected customers to streamline the order process.
- Introduced a new FullscreenDialog component for consistent fullscreen behavior across modals.
- Implemented validation utilities for form fields to ensure data integrity during user input.
2026-03-24 15:32:56 +09:00

383 lines
18 KiB
TypeScript

"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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className="h-7 w-full justify-start text-xs font-normal gap-1 px-2">
<Clock className="h-3 w-3 shrink-0 opacity-50" />
{value || "--:--"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div className="flex">
{/* 오전/오후 */}
<div className="border-r p-1 flex flex-col gap-0.5">
{(["오전", "오후"] as const).map((p) => (
<button key={p} onClick={() => setPeriod(p)}
className={cn("px-3 py-1.5 text-xs rounded", period === p ? "bg-primary text-primary-foreground" : "hover:bg-muted")}>
{p}
</button>
))}
</div>
{/* 시 */}
<div className="border-r p-1 max-h-48 overflow-auto flex flex-col gap-0.5">
{[12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((h) => (
<button key={h} onClick={() => select(period, h, displayMinute ?? 0)}
className={cn("px-3 py-1 text-xs rounded min-w-[32px]",
displayHour === h ? "bg-primary text-primary-foreground" : "hover:bg-muted")}>
{String(h).padStart(2, "0")}
</button>
))}
</div>
{/* 분 */}
<div className="p-1 max-h-48 overflow-auto flex flex-col gap-0.5">
{minutes.map((m) => (
<button key={m} onClick={() => select(period, displayHour ?? 12, m)}
className={cn("px-3 py-1 text-xs rounded min-w-[32px]",
displayMinute === m ? "bg-primary text-primary-foreground" : "hover:bg-muted")}>
{String(m).padStart(2, "0")}
</button>
))}
</div>
</div>
</PopoverContent>
</Popover>
);
}
// --- 타입 ---
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 (
<div className={cn("rounded-lg px-3 py-2.5 flex-1 min-w-0", color)}>
<div className="text-[10px] opacity-80 whitespace-nowrap">{label}</div>
<div className="text-lg font-bold">{value.toLocaleString()}</div>
</div>
);
}
// --- 메인 ---
export function ShippingPlanBatchModal({
open, onOpenChange, selectedDetailIds, onSuccess,
}: ShippingPlanBatchModalProps) {
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [aggregate, setAggregate] = useState<AggregateResponse>({});
const [newPlans, setNewPlans] = useState<Record<string, NewPlanRow[]>>({});
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<string, NewPlanRow[]> = {};
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 (
<FullscreenDialog
open={open}
onOpenChange={onOpenChange}
title={<><Truck className="h-5 w-5 inline mr-2" /> </>}
description={<> : <strong>{totalNewPlans}</strong></>}
defaultMaxWidth="max-w-[1200px]"
footer={
<div className="flex items-center justify-between w-full">
<span className="text-sm text-muted-foreground">💡 </span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}></Button>
<Button onClick={handleSave} disabled={saving || totalNewPlans === 0}>
{saving && <Loader2 className="w-4 h-4 mr-1.5 animate-spin" />}
</Button>
</div>
</div>
}
>
<div className="flex-1 overflow-auto space-y-6 py-2 px-1">
{loading ? (
<div className="flex items-center justify-center h-40"><Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /></div>
) : partCodes.length === 0 ? (
<div className="text-center text-muted-foreground py-10"> .</div>
) : partCodes.map((partCode) => {
const agg = aggregate[partCode];
const orders = agg.orders || [];
const existingPlans = agg.existingPlans || [];
const rows = newPlans[partCode] || [];
const firstOrder = orders[0];
return (
<div key={partCode} className="border rounded-xl overflow-hidden bg-card">
{/* 품목 헤더 */}
<div className="flex items-center justify-between px-5 py-3 bg-muted/30 border-b">
<div className="flex items-center gap-3">
<Package className="h-7 w-7 text-muted-foreground shrink-0" />
<div>
<div className="text-[10px] text-muted-foreground"></div>
<div className="font-bold">{partCode}</div>
</div>
</div>
<div className="text-right">
<div className="text-[10px] text-muted-foreground"></div>
<div className="font-bold">{firstOrder?.partName || "-"}</div>
</div>
</div>
{/* 통계 카드 (신규 입력량 반영) */}
{(() => {
const newQtySum = (newPlans[partCode] || []).reduce((sum, r) => sum + (Number(r.planQty) || 0), 0);
const totalPlanQty = agg.totalPlanQty + newQtySum;
const availableStock = agg.currentStock - totalPlanQty;
return (
<div className="flex gap-2 px-4 py-2.5">
<StatCard label="총수주잔량" value={agg.totalBalance} color="bg-violet-100 text-violet-900 dark:bg-violet-900/30 dark:text-violet-200" />
<StatCard label="총출하계획량" value={totalPlanQty} color="bg-blue-100 text-blue-900 dark:bg-blue-900/30 dark:text-blue-200" />
<StatCard label="현재고" value={agg.currentStock} color="bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-200" />
<StatCard label="가용재고" value={availableStock} color="bg-emerald-100 text-emerald-900 dark:bg-emerald-900/30 dark:text-emerald-200" />
<StatCard label="생산중수량" value={agg.inProductionQty} color="bg-cyan-100 text-cyan-900 dark:bg-cyan-900/30 dark:text-cyan-200" />
</div>
);
})()}
{/* 테이블 — overflow-x-auto로 겹침 방지 */}
<div className="px-4 pb-3">
<table className="w-full text-sm table-fixed">
<colgroup>
<col style={{ width: 50 }} />
<col style={{ width: "16%" }} />
<col style={{ width: "9%" }} />
<col style={{ width: "7%" }} />
<col style={{ width: "6%" }} />
<col style={{ width: "7%" }} />
<col style={{ width: "17%" }} />
<col style={{ width: "10%" }} />
<col style={{ width: "14%" }} />
<col style={{ width: 42 }} />
</colgroup>
<thead>
<tr className="border-b text-xs text-muted-foreground">
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-center"></th>
<th className="py-2 text-center"></th>
<th className="py-2 text-center"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-center"></th>
</tr>
</thead>
<tbody>
{/* 기존 계획 */}
{existingPlans.map((plan) => {
const order = orders.find((o) => o.sourceId === plan.sourceId);
return (
<tr key={`ex-${plan.id}`} className="border-b border-dashed">
<td className="py-2"><Badge variant="secondary" className="text-[10px]"></Badge></td>
<td className="py-2 text-xs truncate max-w-[150px]">{order?.orderNo || "-"}</td>
<td className="py-2 text-xs">{order?.partnerName || "-"}</td>
<td className="py-2 text-xs">{order?.dueDate?.split("T")[0] || "-"}</td>
<td className="py-2 text-right text-xs">{order?.balanceQty?.toLocaleString() || "-"}</td>
<td className="py-2 text-center text-xs">{plan.planQty.toLocaleString()}</td>
<td className="py-2 text-center text-xs">{plan.planDate?.split("T")[0] || "-"}</td>
<td className="py-2 text-center text-xs">-</td>
<td className="py-2 text-xs">{plan.shipmentPlanNo || "-"}</td>
<td></td>
</tr>
);
})}
{/* 신규 입력 행 */}
{rows.map((row, rowIdx) => {
const order = orders.find((o) => o.sourceId === row.sourceId) || orders[0];
return (
<tr key={row._id} className="border-b">
<td className="py-2.5"><Badge className="text-[10px] bg-primary"></Badge></td>
<td className="py-2.5 text-xs truncate">{order?.orderNo || "-"}</td>
<td className="py-2.5 text-xs">{order?.partnerName || "-"}</td>
<td className="py-2.5 text-xs">{order?.dueDate?.split("T")[0] || "-"}</td>
<td className="py-2.5 text-right text-xs">{order?.balanceQty?.toLocaleString() || "-"}</td>
<td className="py-2 px-1">
<Input value={row.planQty}
onChange={(e) => {
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" />
</td>
<td className="py-2 px-1">
<FormDatePicker value={row.planDate}
onChange={(v) => updateRow(partCode, row._id, "planDate", v)} placeholder="계획일" />
</td>
<td className="py-2 px-1">
<TimePicker value={row.planTime}
onChange={(v) => updateRow(partCode, row._id, "planTime", v)} />
</td>
<td className="py-2 px-1">
<Input value={row.shipInfo}
onChange={(e) => updateRow(partCode, row._id, "shipInfo", e.target.value)}
className="h-8 text-xs" placeholder="출하정보 입력" />
</td>
<td className="py-2 text-center">
{rowIdx === rows.length - 1 ? (
<Button variant="outline" size="sm" className="h-7 w-7 p-0 rounded-full"
onClick={() => addRow(partCode)}><Plus className="h-3.5 w-3.5" /></Button>
) : (
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-destructive"
onClick={() => removeRow(partCode, row._id)}><X className="h-3.5 w-3.5" /></Button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
})}
</div>
</FullscreenDialog>
);
}