Files
pipeline/frontend/app/(main)/production/plan-management/page.tsx
T
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

1646 lines
79 KiB
TypeScript

"use client";
import React, { useState, useCallback, useEffect, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import {
Upload,
Download,
RefreshCw,
ChevronRight,
Save,
Trash2,
Zap,
Package,
Wrench,
AlertTriangle,
ClipboardList,
Calendar,
Scissors,
Loader2,
Maximize2,
Minimize2,
Merge,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import {
getOrderSummary,
getStockShortage,
getPlans,
updatePlan,
deletePlan,
generateSchedule,
previewSchedule,
mergeSchedules,
generateSemiSchedule,
splitSchedule,
type OrderSummaryItem,
type StockShortageItem,
type ProductionPlan,
type GenerateScheduleRequest,
type GenerateScheduleResponse,
} from "@/lib/api/production";
import TimelineScheduler, {
type TimelineResource,
type TimelineEvent,
type ZoomLevel,
type StatusColor,
} from "@/components/common/TimelineScheduler";
import { DynamicSearchFilter, type FilterValue } from "@/components/common/DynamicSearchFilter";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
import { apiClient } from "@/lib/api/client";
// ─── 상수 ───
const STATUS_COLORS: StatusColor[] = [
{ key: "planned", label: "계획", bgClass: "from-blue-500 to-blue-600" },
{ key: "work-order", label: "지시", bgClass: "from-amber-500 to-amber-600" },
{ key: "in-progress", label: "진행", bgClass: "from-emerald-500 to-emerald-600" },
{ key: "completed", label: "완료", bgClass: "from-gray-400 to-gray-500" },
];
const STATUS_LABEL: Record<string, string> = {
planned: "계획",
"work-order": "작업지시",
"in-progress": "진행중",
completed: "완료",
};
// ========== 메인 컴포넌트 ==========
export default function ProductionPlanManagementPage() {
// 탭 상태
const [leftTab, setLeftTab] = useState("order");
const [rightTab, setRightTab] = useState("finished");
// 전체화면 토글
const [isFullscreen, setIsFullscreen] = useState(false);
// 로딩 상태
const [loadingOrders, setLoadingOrders] = useState(false);
const [loadingStock, setLoadingStock] = useState(false);
const [loadingPlans, setLoadingPlans] = useState(false);
const [generating, setGenerating] = useState(false);
const [saving, setSaving] = useState(false);
// 데이터 상태
const [orderItems, setOrderItems] = useState<OrderSummaryItem[]>([]);
const [stockItems, setStockItems] = useState<StockShortageItem[]>([]);
const [finishedPlans, setFinishedPlans] = useState<ProductionPlan[]>([]);
const [semiPlans, setSemiPlans] = useState<ProductionPlan[]>([]);
const [equipmentList, setEquipmentList] = useState<{ equipment_id: string; equipment_name: string }[]>([]);
// 선택/토글 상태
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
const [selectedItemGroups, setSelectedItemGroups] = useState<Set<string>>(new Set());
const [filterUnplannedOrdersOnly, setFilterUnplannedOrdersOnly] = useState(false);
const [selectedStockItems, setSelectedStockItems] = useState<Set<string>>(new Set());
// 검색 필터 (DynamicSearchFilter에서 사용)
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [searchItemCode, setSearchItemCode] = useState("");
const [searchStatus, setSearchStatus] = useState("all");
const [searchStartDate, setSearchStartDate] = useState("");
const [searchEndDate, setSearchEndDate] = useState("");
// 타임라인 옵션
const [recalculateUnstarted, setRecalculateUnstarted] = useState(true);
const [finishedZoom, setFinishedZoom] = useState<ZoomLevel>("week");
const [semiZoom, setSemiZoom] = useState<ZoomLevel>("week");
// 반제품 옵션
const [semiConsiderStock, setSemiConsiderStock] = useState(true);
const [semiRecalculate, setSemiRecalculate] = useState(false);
const [semiExcludeUsed, setSemiExcludeUsed] = useState(true);
// 모달 상태
const [scheduleModalOpen, setScheduleModalOpen] = useState(false);
const [orderImportModalOpen, setOrderImportModalOpen] = useState(false);
const [stockImportModalOpen, setStockImportModalOpen] = useState(false);
const [changeConfirmModalOpen, setChangeConfirmModalOpen] = useState(false);
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 모달 데이터
const [selectedPlan, setSelectedPlan] = useState<ProductionPlan | null>(null);
const [modalQuantity, setModalQuantity] = useState(0);
const [modalStartDate, setModalStartDate] = useState("");
const [modalEndDate, setModalEndDate] = useState("");
const [modalManager, setModalManager] = useState("");
const [modalWorkOrderNo, setModalWorkOrderNo] = useState("");
const [modalRemarks, setModalRemarks] = useState("");
const [modalEquipmentId, setModalEquipmentId] = useState("");
// 미리보기 데이터
const [previewData, setPreviewData] = useState<GenerateScheduleResponse | null>(null);
// 불러오기 모드
const [importMode, setImportMode] = useState<"add" | "new">("add");
// 병합: 타임라인에서 선택된 계획 ID
const [selectedPlanIds, setSelectedPlanIds] = useState<Set<number>>(new Set());
// useConfirmDialog
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// ========== 데이터 로드 ==========
const fetchOrderSummary = useCallback(async () => {
setLoadingOrders(true);
try {
const res = await getOrderSummary({
excludePlanned: filterUnplannedOrdersOnly,
itemCode: searchItemCode || undefined,
});
if (res.success) setOrderItems(res.data || []);
} catch (err: any) {
toast.error("수주 데이터 조회 실패: " + (err.message || ""));
} finally {
setLoadingOrders(false);
}
}, [filterUnplannedOrdersOnly, searchItemCode]);
const fetchStockShortage = useCallback(async () => {
setLoadingStock(true);
try {
const res = await getStockShortage();
if (res.success) setStockItems(res.data || []);
} catch (err: any) {
toast.error("안전재고 부족분 조회 실패: " + (err.message || ""));
} finally {
setLoadingStock(false);
}
}, []);
const fetchPlans = useCallback(async () => {
setLoadingPlans(true);
try {
const [finRes, semiRes] = await Promise.all([
getPlans({
productType: "완제품",
status: searchStatus !== "all" ? searchStatus : undefined,
startDate: searchStartDate || undefined,
endDate: searchEndDate || undefined,
itemCode: searchItemCode || undefined,
}),
getPlans({
productType: "반제품",
status: searchStatus !== "all" ? searchStatus : undefined,
startDate: searchStartDate || undefined,
endDate: searchEndDate || undefined,
}),
]);
if (finRes.success) setFinishedPlans(finRes.data || []);
if (semiRes.success) setSemiPlans(semiRes.data || []);
} catch (err: any) {
toast.error("생산계획 조회 실패: " + (err.message || ""));
} finally {
setLoadingPlans(false);
}
}, [searchStatus, searchStartDate, searchEndDate, searchItemCode]);
const fetchEquipmentList = useCallback(async () => {
try {
const res = await apiClient.get("/process-info/equipments");
if (res.data?.success) setEquipmentList(res.data.data || []);
} catch {
// 설비 목록 없어도 정상 동작
}
}, []);
useEffect(() => {
fetchOrderSummary();
fetchStockShortage();
fetchPlans();
fetchEquipmentList();
}, []);
// ========== DynamicSearchFilter 콜백 ==========
const handleSearchFilterChange = useCallback(
(filters: FilterValue[]) => {
setSearchFilters(filters);
// 필터에서 주요 값 추출
let itemCode = "";
let status = "all";
let startDate = "";
let endDate = "";
for (const f of filters) {
if (f.columnName === "item_code" && f.value) itemCode = f.value;
if (f.columnName === "status" && f.value) status = f.value;
if (f.columnName === "start_date" && f.value) {
const [s, e] = f.value.split(",");
if (s) startDate = s;
if (e) endDate = e;
}
}
setSearchItemCode(itemCode);
setSearchStatus(status);
setSearchStartDate(startDate);
setSearchEndDate(endDate);
},
[]
);
// ========== 토글/선택 핸들러 ==========
const toggleItemExpand = useCallback((itemCode: string) => {
setExpandedItems((prev) => {
const next = new Set(prev);
if (next.has(itemCode)) next.delete(itemCode);
else next.add(itemCode);
return next;
});
}, []);
const toggleItemGroupSelect = useCallback((itemCode: string) => {
setSelectedItemGroups((prev) => {
const next = new Set(prev);
if (next.has(itemCode)) next.delete(itemCode);
else next.add(itemCode);
return next;
});
}, []);
const toggleAllItemGroups = useCallback(
(checked: boolean) => {
setSelectedItemGroups(checked ? new Set(orderItems.map((i) => i.item_code)) : new Set());
},
[orderItems]
);
const toggleStockItem = useCallback((itemCode: string) => {
setSelectedStockItems((prev) => {
const next = new Set(prev);
if (next.has(itemCode)) next.delete(itemCode);
else next.add(itemCode);
return next;
});
}, []);
const toggleAllStockItems = useCallback(
(checked: boolean) => {
setSelectedStockItems(checked ? new Set(stockItems.map((i) => i.item_code)) : new Set());
},
[stockItems]
);
// ========== 타임라인 리소스 & 이벤트 ==========
const finishedResources: TimelineResource[] = useMemo(() => {
const map = new Map<string, TimelineResource>();
for (const p of finishedPlans) {
if (!map.has(p.item_code)) {
map.set(p.item_code, {
id: p.item_code,
label: p.item_code,
subLabel: p.item_name,
});
}
}
return Array.from(map.values());
}, [finishedPlans]);
const finishedEvents: TimelineEvent[] = useMemo(() => {
return finishedPlans.map((p) => ({
id: p.id,
resourceId: p.item_code,
startDate: p.start_date?.split("T")[0] || "",
endDate: p.end_date?.split("T")[0] || "",
label: `${Number(p.plan_qty).toLocaleString()}`,
status: p.status,
progress: Number(p.progress_rate) || 0,
data: p,
}));
}, [finishedPlans]);
const semiResources: TimelineResource[] = useMemo(() => {
const map = new Map<string, TimelineResource>();
for (const p of semiPlans) {
if (!map.has(p.item_code)) {
map.set(p.item_code, {
id: p.item_code,
label: p.item_code,
subLabel: p.item_name,
});
}
}
return Array.from(map.values());
}, [semiPlans]);
const semiEvents: TimelineEvent[] = useMemo(() => {
return semiPlans.map((p) => ({
id: p.id,
resourceId: p.item_code,
startDate: p.start_date?.split("T")[0] || "",
endDate: p.end_date?.split("T")[0] || "",
label: `${Number(p.plan_qty).toLocaleString()}`,
status: p.status,
progress: Number(p.progress_rate) || 0,
data: p,
}));
}, [semiPlans]);
// ========== 액션 핸들러 ==========
const handleSearch = useCallback(() => {
fetchOrderSummary();
fetchPlans();
}, [fetchOrderSummary, fetchPlans]);
// 자동 스케줄 생성 (preview → confirm → apply)
const handleGenerateSchedule = useCallback(async () => {
if (selectedItemGroups.size === 0) {
toast.error("품목을 선택해주세요");
return;
}
const items = orderItems
.filter((item) => selectedItemGroups.has(item.item_code))
.map((item) => ({
item_code: item.item_code,
item_name: item.item_name,
required_qty: Number(item.required_plan_qty),
earliest_due_date: item.earliest_due_date || new Date().toISOString().split("T")[0],
lead_time: Number(item.lead_time) || 0,
}));
setGenerating(true);
try {
const req: GenerateScheduleRequest = {
items,
options: {
safety_lead_time: 0,
recalculate_unstarted: recalculateUnstarted,
product_type: "완제품",
},
};
const previewRes = await previewSchedule(req);
if (previewRes.success) {
setPreviewData(previewRes.data);
setChangeConfirmModalOpen(true);
}
} catch (err: any) {
toast.error("스케줄 미리보기 실패: " + (err.message || ""));
} finally {
setGenerating(false);
}
}, [selectedItemGroups, orderItems, recalculateUnstarted]);
const handleApplySchedule = useCallback(async () => {
if (selectedItemGroups.size === 0) return;
const items = orderItems
.filter((item) => selectedItemGroups.has(item.item_code))
.map((item) => ({
item_code: item.item_code,
item_name: item.item_name,
required_qty: Number(item.required_plan_qty),
earliest_due_date: item.earliest_due_date || new Date().toISOString().split("T")[0],
lead_time: Number(item.lead_time) || 0,
}));
setGenerating(true);
try {
const res = await generateSchedule({
items,
options: {
safety_lead_time: 0,
recalculate_unstarted: recalculateUnstarted,
product_type: "완제품",
},
});
if (res.success) {
toast.success(`스케줄이 생성되었습니다 (${res.data.summary.total}건)`);
setChangeConfirmModalOpen(false);
setPreviewData(null);
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("스케줄 생성 실패: " + (err.message || ""));
} finally {
setGenerating(false);
}
}, [selectedItemGroups, orderItems, recalculateUnstarted, fetchPlans, fetchOrderSummary]);
// 타임라인 초기화 (계획 상태만 삭제)
const handleClearTimeline = useCallback(async () => {
if (finishedPlans.length === 0) {
toast.info("삭제할 계획이 없습니다");
return;
}
const plannedIds = finishedPlans.filter((p) => p.status === "planned").map((p) => p.id);
if (plannedIds.length === 0) {
toast.info("삭제 가능한 계획이 없습니다 (계획 상태만 삭제 가능)");
return;
}
const ok = await confirm(`${plannedIds.length}건의 계획을 삭제하시겠습니까?`, {
description: "삭제된 계획은 복구할 수 없습니다.",
variant: "destructive",
confirmText: "삭제",
});
if (!ok) return;
try {
await Promise.all(plannedIds.map((id) => deletePlan(id)));
toast.success(`${plannedIds.length}건의 계획이 삭제되었습니다`);
fetchPlans();
} catch (err: any) {
toast.error("삭제 실패: " + (err.message || ""));
}
}, [finishedPlans, fetchPlans, confirm]);
// 반제품 자동 생성
const handleGenerateSemiSchedule = useCallback(async () => {
const planIds = finishedPlans.map((p) => p.id);
if (planIds.length === 0) {
toast.error("완제품 생산계획이 없습니다");
return;
}
setGenerating(true);
try {
const res = await generateSemiSchedule(planIds, {
considerStock: semiConsiderStock,
excludeUsed: semiExcludeUsed,
});
if (res.success) {
toast.success(`반제품 계획 ${res.data.count}건이 생성되었습니다`);
fetchPlans();
}
} catch (err: any) {
toast.error("반제품 계획 생성 실패: " + (err.message || ""));
} finally {
setGenerating(false);
}
}, [finishedPlans, semiConsiderStock, semiExcludeUsed, fetchPlans]);
// 스케줄 상세 모달 열기
const openScheduleDetail = useCallback((event: TimelineEvent) => {
const plan = event.data as ProductionPlan;
if (!plan) return;
setSelectedPlan(plan);
setModalQuantity(Number(plan.plan_qty));
setModalStartDate(plan.start_date?.split("T")[0] || "");
setModalEndDate(plan.end_date?.split("T")[0] || "");
setModalManager((plan as any).manager_name || "");
setModalWorkOrderNo((plan as any).work_order_no || "");
setModalRemarks(plan.remarks || "");
setModalEquipmentId(plan.equipment_id ? String(plan.equipment_id) : "");
setScheduleModalOpen(true);
}, []);
const handleSavePlan = useCallback(async () => {
if (!selectedPlan) return;
setSaving(true);
try {
const res = await updatePlan(selectedPlan.id, {
plan_qty: modalQuantity,
start_date: modalStartDate,
end_date: modalEndDate,
manager_name: modalManager,
work_order_no: modalWorkOrderNo,
remarks: modalRemarks,
equipment_id: modalEquipmentId ? Number(modalEquipmentId) : null,
} as any);
if (res.success) {
toast.success("생산계획이 수정되었습니다");
setScheduleModalOpen(false);
fetchPlans();
}
} catch (err: any) {
toast.error("수정 실패: " + (err.message || ""));
} finally {
setSaving(false);
}
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalManager, modalWorkOrderNo, modalRemarks, modalEquipmentId, fetchPlans]);
const handleDeletePlan = useCallback(async () => {
if (!selectedPlan) return;
const ok = await confirm("이 생산계획을 삭제하시겠습니까?", {
variant: "destructive",
confirmText: "삭제",
});
if (!ok) return;
try {
await deletePlan(selectedPlan.id);
toast.success("삭제되었습니다");
setScheduleModalOpen(false);
fetchPlans();
} catch (err: any) {
toast.error("삭제 실패: " + (err.message || ""));
}
}, [selectedPlan, fetchPlans, confirm]);
const handleSplitSchedule = useCallback(async (splitQty: number) => {
if (!selectedPlan || splitQty <= 0) return;
try {
const res = await splitSchedule(selectedPlan.id, splitQty);
if (res.success) {
toast.success("계획이 분할되었습니다");
setScheduleModalOpen(false);
fetchPlans();
}
} catch (err: any) {
toast.error("분할 실패: " + (err.message || ""));
}
}, [selectedPlan, fetchPlans]);
// 병합 핸들러
const handleMergeSchedules = useCallback(async () => {
if (selectedPlanIds.size < 2) {
toast.error("2개 이상의 계획을 선택해주세요");
return;
}
const ok = await confirm(`${selectedPlanIds.size}건의 계획을 병합하시겠습니까?`, {
description: "동일 품목의 계획이 하나로 병합됩니다.",
confirmText: "병합",
});
if (!ok) return;
try {
const ids = Array.from(selectedPlanIds);
const productType = rightTab === "finished" ? "완제품" : "반제품";
const res = await mergeSchedules(ids, productType);
if (res.success) {
toast.success("계획이 병합되었습니다");
setSelectedPlanIds(new Set());
fetchPlans();
}
} catch (err: any) {
toast.error("병합 실패: " + (err.message || ""));
}
}, [selectedPlanIds, rightTab, fetchPlans, confirm]);
// 타임라인 이벤트 드래그 이동
const handleEventMove = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
try {
const res = await updatePlan(Number(eventId), {
start_date: newStart,
end_date: newEnd,
} as any);
if (res.success) {
toast.success("일정이 변경되었습니다");
fetchPlans();
}
} catch (err: any) {
toast.error("일정 변경 실패: " + (err.message || ""));
}
}, [fetchPlans]);
// 타임라인 이벤트 리사이즈
const handleEventResize = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
try {
const res = await updatePlan(Number(eventId), {
start_date: newStart,
end_date: newEnd,
} as any);
if (res.success) {
toast.success("기간이 변경되었습니다");
fetchPlans();
}
} catch (err: any) {
toast.error("기간 변경 실패: " + (err.message || ""));
}
}, [fetchPlans]);
// 불러오기 처리
const handleImportOrderItems = useCallback(async () => {
if (selectedItemGroups.size === 0) {
toast.error("품목을 선택해주세요");
return;
}
const items = orderItems
.filter((item) => selectedItemGroups.has(item.item_code))
.map((item) => ({
item_code: item.item_code,
item_name: item.item_name,
required_qty: Number(item.required_plan_qty),
earliest_due_date: item.earliest_due_date || new Date().toISOString().split("T")[0],
}));
setGenerating(true);
try {
const res = await generateSchedule({
items,
options: {
safety_lead_time: 0,
recalculate_unstarted: importMode === "new" ? false : recalculateUnstarted,
product_type: "완제품",
},
});
if (res.success) {
toast.success("품목을 불러왔습니다");
setOrderImportModalOpen(false);
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("불러오기 실패: " + (err.message || ""));
} finally {
setGenerating(false);
}
}, [selectedItemGroups, orderItems, importMode, recalculateUnstarted, fetchPlans, fetchOrderSummary]);
const handleImportStockItems = useCallback(async () => {
if (selectedStockItems.size === 0) {
toast.error("품목을 선택해주세요");
return;
}
const items = stockItems
.filter((item) => selectedStockItems.has(item.item_code))
.map((item) => ({
item_code: item.item_code,
item_name: item.item_name,
required_qty: Number(item.recommended_qty),
earliest_due_date: new Date().toISOString().split("T")[0],
}));
setGenerating(true);
try {
const res = await generateSchedule({
items,
options: {
safety_lead_time: 0,
recalculate_unstarted: importMode === "new" ? false : true,
product_type: "완제품",
},
});
if (res.success) {
toast.success("안전재고 부족 품목을 불러왔습니다");
setStockImportModalOpen(false);
fetchPlans();
fetchStockShortage();
}
} catch (err: any) {
toast.error("불러오기 실패: " + (err.message || ""));
} finally {
setGenerating(false);
}
}, [selectedStockItems, stockItems, importMode, fetchPlans, fetchStockShortage]);
// 엑셀 다운로드
const handleExcelDownload = useCallback(async () => {
const plans = rightTab === "finished" ? finishedPlans : semiPlans;
if (plans.length === 0) {
toast.error("다운로드할 데이터가 없습니다");
return;
}
const data = plans.map((p) => ({
계획번호: p.plan_no || "",
품목코드: p.item_code,
품목명: p.item_name,
제품구분: p.product_type,
수량: Number(p.plan_qty),
완료수량: Number(p.completed_qty),
: `${p.progress_rate || 0}%`,
시작일: p.start_date?.split("T")[0] || "",
종료일: p.end_date?.split("T")[0] || "",
납기일: p.due_date?.split("T")[0] || "",
상태: STATUS_LABEL[p.status] || p.status,
설비: p.equipment_name || "",
비고: p.remarks || "",
}));
const type = rightTab === "finished" ? "완제품" : "반제품";
await exportToExcel(data, `생산계획_${type}.xlsx`, `${type} 생산계획`);
toast.success("엑셀 다운로드 완료");
}, [rightTab, finishedPlans, semiPlans]);
// 좌측 테이블 엑셀 다운로드
const handleLeftExcelDownload = useCallback(async () => {
if (leftTab === "order") {
if (orderItems.length === 0) { toast.error("데이터가 없습니다"); return; }
const data = orderItems.map((i) => ({
품목코드: i.item_code,
품목명: i.item_name,
총수주량: Number(i.total_order_qty),
출고량: Number(i.total_ship_qty),
잔량: Number(i.total_balance_qty),
현재고: Number(i.current_stock),
안전재고: Number(i.safety_stock),
기생산계획량: Number(i.existing_plan_qty),
생산진행: Number(i.in_progress_qty),
필요생산계획: Number(i.required_plan_qty),
"리드타임(일)": Number(i.lead_time) || 0,
}));
await exportToExcel(data, "수주데이터.xlsx", "수주데이터");
} else {
if (stockItems.length === 0) { toast.error("데이터가 없습니다"); return; }
const data = stockItems.map((s) => ({
품목코드: s.item_code,
품목명: s.item_name,
현재고: Number(s.current_qty),
안전재고: Number(s.safety_qty),
부족수량: Number(s.shortage_qty),
권장생산량: Number(s.recommended_qty),
최종입고일: s.last_in_date || "",
}));
await exportToExcel(data, "안전재고부족분.xlsx", "안전재고 부족분");
}
toast.success("엑셀 다운로드 완료");
}, [leftTab, orderItems, stockItems]);
// 숫자 포맷
const formatNumber = (num: number | string) => Number(num).toLocaleString();
return (
<div className={cn("flex flex-col bg-muted/30 gap-4", isFullscreen ? "fixed inset-0 z-50 p-4" : "h-[calc(100vh-4rem)] p-5")}>
{/* 상단 바 */}
<div className="shrink-0 rounded-lg border bg-background p-4 shadow-sm">
<div className="flex items-center justify-between gap-3">
<DynamicSearchFilter
tableName="production_plan_mng"
filterId="production-plan"
onFilterChange={handleSearchFilterChange}
dataCount={finishedPlans.length + semiPlans.length}
extraActions={
<Button size="sm" onClick={handleSearch}>
<RefreshCw className="mr-1.5 h-4 w-4" />
</Button>
}
/>
<div className="flex items-center gap-2 shrink-0">
<Button variant="outline" size="sm" className="text-emerald-600 dark:text-emerald-400 border-emerald-500/30 hover:bg-emerald-500/10" onClick={() => setExcelUploadOpen(true)}>
<Upload className="mr-1.5 h-4 w-4" />
</Button>
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
<Download className="mr-1.5 h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => setIsFullscreen((p) => !p)}>
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</Button>
</div>
</div>
</div>
{/* 데이터 섹션 */}
<ResizablePanelGroup direction="horizontal" className="flex-1 overflow-hidden rounded-lg">
{/* 왼쪽 패널 */}
<ResizablePanel defaultSize={45} minSize={25}>
<div className="flex h-full flex-col rounded-lg border bg-background shadow-sm">
<Tabs value={leftTab} onValueChange={setLeftTab} className="flex h-full flex-col">
<div className="shrink-0 border-b bg-muted/30 px-5">
<TabsList className="h-11 bg-transparent gap-1">
<TabsTrigger value="order" className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4 text-sm">
<ClipboardList className="mr-1.5 h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="stock" className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4 text-sm">
<AlertTriangle className="mr-1.5 h-4 w-4" />
</TabsTrigger>
</TabsList>
</div>
{/* 수주데이터 탭 */}
<TabsContent value="order" className="flex-1 overflow-hidden mt-0 flex flex-col">
<div className="flex items-center justify-between border-b bg-muted/20 px-5 py-3">
<span className="text-base font-semibold"> </span>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1.5 rounded-md border border-primary/20 bg-primary/5 px-3 py-1.5 text-xs cursor-pointer">
<Checkbox
checked={filterUnplannedOrdersOnly}
onCheckedChange={(c) => {
setFilterUnplannedOrdersOnly(!!c);
setTimeout(fetchOrderSummary, 0);
}}
className="h-4 w-4"
/>
<span className="font-medium text-foreground"> </span>
</label>
<Button size="sm" variant="outline" className="text-emerald-600 dark:text-emerald-400 border-emerald-500/30 hover:bg-emerald-500/10" onClick={() => setOrderImportModalOpen(true)}>
<Download className="mr-1 h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="outline" onClick={handleLeftExcelDownload}>
<Download className="mr-1 h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="outline" onClick={fetchOrderSummary} disabled={loadingOrders}>
{loadingOrders ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="mr-1 h-3.5 w-3.5" />}
</Button>
</div>
</div>
<div className="flex-1 overflow-auto p-4">
{loadingOrders ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : orderItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<ClipboardList className="h-12 w-12 mb-3 opacity-50" />
<p className="text-base font-medium mb-2"> </p>
</div>
) : (
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="w-[30px]">
<Checkbox checked={selectedItemGroups.size === orderItems.length && orderItems.length > 0} onCheckedChange={(c) => toggleAllItemGroups(!!c)} className="h-4 w-4" />
</TableHead>
<TableHead className="w-[40px]" />
<TableHead className="text-xs font-semibold whitespace-nowrap"></TableHead>
<TableHead className="text-xs font-semibold whitespace-nowrap"></TableHead>
<TableHead className="text-xs font-semibold whitespace-nowrap text-right"></TableHead>
<TableHead className="text-xs font-semibold whitespace-nowrap text-right"></TableHead>
<TableHead className="text-xs font-semibold whitespace-nowrap text-right"></TableHead>
<TableHead className="text-xs font-semibold whitespace-nowrap text-right"></TableHead>
<TableHead className="text-xs font-semibold whitespace-nowrap text-right"></TableHead>
<TableHead className="text-xs font-semibold whitespace-nowrap text-right"></TableHead>
<TableHead className="text-xs font-semibold whitespace-nowrap text-right"></TableHead>
<TableHead className="text-xs font-semibold whitespace-nowrap text-right"></TableHead>
<TableHead className="text-xs font-semibold whitespace-nowrap text-right">()</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{orderItems.map((item) => (
<React.Fragment key={item.item_code}>
<TableRow className={cn("cursor-pointer border-t-2 border-t-primary/30 bg-primary/5 font-semibold hover:bg-primary/10", selectedItemGroups.has(item.item_code) && "bg-primary/10")}>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={selectedItemGroups.has(item.item_code)} onCheckedChange={() => toggleItemGroupSelect(item.item_code)} className="h-4 w-4" />
</TableCell>
<TableCell className="text-center" onClick={() => toggleItemExpand(item.item_code)}>
<ChevronRight className={cn("h-4 w-4 transition-transform duration-200", expandedItems.has(item.item_code) && "rotate-90")} />
</TableCell>
<TableCell className="text-xs text-primary" onClick={() => toggleItemExpand(item.item_code)}>{item.item_code}</TableCell>
<TableCell className="text-xs text-primary" onClick={() => toggleItemExpand(item.item_code)}>{item.item_name}</TableCell>
<TableCell className="text-xs text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.total_order_qty)}</TableCell>
<TableCell className="text-xs text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.total_ship_qty)}</TableCell>
<TableCell className="text-xs text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.total_balance_qty)}</TableCell>
<TableCell className="text-xs text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.current_stock)}</TableCell>
<TableCell className="text-xs text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.safety_stock)}</TableCell>
<TableCell className="text-xs text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.existing_plan_qty)}</TableCell>
<TableCell className="text-xs text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.in_progress_qty)}</TableCell>
<TableCell className={cn("text-xs text-right font-bold", Number(item.required_plan_qty) > 0 ? "text-destructive" : "text-emerald-600 dark:text-emerald-400")} onClick={() => toggleItemExpand(item.item_code)}>
{formatNumber(item.required_plan_qty)}
</TableCell>
<TableCell className="text-xs text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>
{Number(item.lead_time) > 0 ? `${item.lead_time}` : "-"}
</TableCell>
</TableRow>
{expandedItems.has(item.item_code) && item.orders?.map((detail) => (
<TableRow key={detail.id || detail.order_no} className="cursor-pointer hover:bg-muted/50">
<TableCell />
<TableCell />
<TableCell colSpan={2} className="pl-10">
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{detail.order_no}</span>
<span className="text-muted-foreground">:</span>
<span>{detail.customer_name || "-"}</span>
<Badge variant="secondary" className="text-[10px] px-2 py-0">
{detail.status || "일반"}
</Badge>
</div>
</TableCell>
<TableCell className="text-xs text-right">{formatNumber(detail.order_qty)}</TableCell>
<TableCell className="text-xs text-right">{formatNumber(detail.ship_qty)}</TableCell>
<TableCell className="text-xs text-right">{formatNumber(detail.balance_qty)}</TableCell>
<TableCell colSpan={6} className="text-xs text-muted-foreground">
: {detail.due_date || "-"}
</TableCell>
</TableRow>
))}
</React.Fragment>
))}
</TableBody>
</Table>
</div>
)}
</div>
</TabsContent>
{/* 안전재고 부족분 탭 */}
<TabsContent value="stock" className="flex-1 overflow-hidden mt-0 flex flex-col">
<div className="flex items-center justify-between border-b bg-muted/20 px-5 py-3">
<span className="text-base font-semibold"> </span>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" className="text-emerald-600 dark:text-emerald-400 border-emerald-500/30 hover:bg-emerald-500/10" onClick={() => setStockImportModalOpen(true)}>
<Download className="mr-1 h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="outline" onClick={handleLeftExcelDownload}>
<Download className="mr-1 h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="outline" onClick={fetchStockShortage} disabled={loadingStock}>
{loadingStock ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="mr-1 h-3.5 w-3.5" />}
</Button>
</div>
</div>
<div className="flex-1 overflow-auto p-4">
{loadingStock ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : stockItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<AlertTriangle className="h-12 w-12 mb-3 opacity-50" />
<p className="text-base font-medium mb-2"> </p>
</div>
) : (
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="w-[30px]">
<Checkbox checked={selectedStockItems.size === stockItems.length && stockItems.length > 0} onCheckedChange={(c) => toggleAllStockItems(!!c)} className="h-4 w-4" />
</TableHead>
<TableHead className="text-xs font-semibold"></TableHead>
<TableHead className="text-xs font-semibold"></TableHead>
<TableHead className="text-xs font-semibold text-right"></TableHead>
<TableHead className="text-xs font-semibold text-right"></TableHead>
<TableHead className="text-xs font-semibold text-right"></TableHead>
<TableHead className="text-xs font-semibold text-right"></TableHead>
<TableHead className="text-xs font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stockItems.map((stock) => (
<TableRow key={stock.item_code} className="cursor-pointer hover:bg-muted/50">
<TableCell>
<Checkbox checked={selectedStockItems.has(stock.item_code)} onCheckedChange={() => toggleStockItem(stock.item_code)} className="h-4 w-4" />
</TableCell>
<TableCell className="text-xs">{stock.item_code}</TableCell>
<TableCell className="text-xs">{stock.item_name}</TableCell>
<TableCell className="text-xs text-right">{formatNumber(stock.current_qty)}</TableCell>
<TableCell className="text-xs text-right">{formatNumber(stock.safety_qty)}</TableCell>
<TableCell className="text-xs text-right font-semibold text-destructive">{formatNumber(stock.shortage_qty)}</TableCell>
<TableCell className="text-xs text-right font-semibold text-emerald-600 dark:text-emerald-400">{formatNumber(stock.recommended_qty)}</TableCell>
<TableCell className="text-xs">{stock.last_in_date || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</TabsContent>
</Tabs>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 오른쪽 패널 — 타임라인 */}
<ResizablePanel defaultSize={55} minSize={30}>
<div className="flex h-full flex-col rounded-lg border bg-background shadow-sm">
<Tabs value={rightTab} onValueChange={setRightTab} className="flex h-full flex-col">
<div className="shrink-0 border-b bg-muted/30 px-5">
<TabsList className="h-11 bg-transparent gap-1">
<TabsTrigger value="finished" className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4 text-sm">
<Package className="mr-1.5 h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="semi" className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4 text-sm">
<Wrench className="mr-1.5 h-4 w-4" />
</TabsTrigger>
</TabsList>
</div>
{/* 완제품 생산계획 */}
<TabsContent value="finished" className="flex-1 overflow-hidden mt-0 flex flex-col">
<div className="flex items-center justify-between border-b bg-muted/20 px-5 py-3">
<span className="text-base font-semibold flex items-center gap-1.5">
<Calendar className="h-4 w-4" />
<Badge variant="outline" className="ml-2">{finishedPlans.length}</Badge>
</span>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={fetchPlans} disabled={loadingPlans}>
{loadingPlans ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="mr-1 h-3.5 w-3.5" />}
</Button>
{selectedPlanIds.size >= 2 && (
<Button size="sm" variant="outline" onClick={handleMergeSchedules}>
<Merge className="mr-1 h-3.5 w-3.5" />
({selectedPlanIds.size})
</Button>
)}
<Button size="sm" onClick={handleGenerateSchedule} disabled={generating || selectedItemGroups.size === 0}>
{generating ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <Zap className="mr-1 h-3.5 w-3.5" />}
</Button>
<Button size="sm" variant="ghost" className="text-destructive" onClick={handleClearTimeline}>
<Trash2 className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="flex-1 overflow-auto p-4">
{/* 옵션 */}
<div className="rounded-lg border bg-muted/30 p-3 mb-4">
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-1.5">
<Checkbox checked={recalculateUnstarted} onCheckedChange={(c) => setRecalculateUnstarted(!!c)} className="h-4 w-4" />
<Label className="text-xs cursor-pointer"> </Label>
</div>
</div>
</div>
{/* 타임라인 */}
<TimelineScheduler
resources={finishedResources}
events={finishedEvents}
zoomLevel={finishedZoom}
onZoomChange={setFinishedZoom}
statusColors={STATUS_COLORS}
showProgress
showMilestones
showTodayLine
showLegend
conflictDetection
loading={loadingPlans}
emptyMessage="생산 스케줄이 없습니다"
emptyIcon={<Package className="h-12 w-12 mb-3 opacity-50" />}
onEventClick={openScheduleDetail}
onEventMove={handleEventMove}
onEventResize={handleEventResize}
/>
</div>
</TabsContent>
{/* 반제품 생산계획 */}
<TabsContent value="semi" className="flex-1 overflow-hidden mt-0 flex flex-col">
<div className="flex items-center justify-between border-b bg-muted/20 px-5 py-3">
<span className="text-base font-semibold flex items-center gap-1.5">
<Wrench className="h-4 w-4" />
<Badge variant="outline" className="ml-2">{semiPlans.length}</Badge>
</span>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={fetchPlans} disabled={loadingPlans}>
{loadingPlans ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="mr-1 h-3.5 w-3.5" />}
</Button>
<Button size="sm" onClick={handleGenerateSemiSchedule} disabled={generating}>
{generating ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <Zap className="mr-1 h-3.5 w-3.5" />}
</Button>
</div>
</div>
<div className="flex-1 overflow-auto p-4">
<div className="flex gap-3 mb-4">
<div className="flex-1 rounded-lg border border-primary/20 bg-primary/5 p-4">
<p className="font-semibold text-foreground text-sm mb-3"> </p>
<div className="flex flex-col gap-2">
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={semiConsiderStock} onCheckedChange={(c) => setSemiConsiderStock(!!c)} className="h-4 w-4" />
<span className="font-medium"> </span>
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={semiRecalculate} onCheckedChange={(c) => setSemiRecalculate(!!c)} className="h-4 w-4" />
<span className="font-medium"> </span>
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={semiExcludeUsed} onCheckedChange={(c) => setSemiExcludeUsed(!!c)} className="h-4 w-4" />
<span className="font-medium"> </span>
</label>
</div>
</div>
<div className="flex-1 rounded-lg border border-yellow-500/20 bg-yellow-500/5 p-4">
<p className="font-semibold text-foreground text-sm mb-2"> </p>
<ul className="text-xs text-foreground space-y-1 leading-relaxed">
<li> </li>
<li> </li>
<li>BOM() </li>
</ul>
</div>
</div>
{/* 반제품 타임라인 */}
<TimelineScheduler
resources={semiResources}
events={semiEvents}
zoomLevel={semiZoom}
onZoomChange={setSemiZoom}
statusColors={STATUS_COLORS}
showProgress
showTodayLine
showLegend
conflictDetection
loading={loadingPlans}
emptyMessage="반제품 생산 스케줄이 없습니다"
emptyIcon={<Wrench className="h-12 w-12 mb-3 opacity-50" />}
onEventClick={openScheduleDetail}
onEventMove={handleEventMove}
onEventResize={handleEventResize}
/>
</div>
</TabsContent>
</Tabs>
</div>
</ResizablePanel>
</ResizablePanelGroup>
{/* ========== 모달들 ========== */}
{/* 스케줄 상세/편집 모달 */}
<Dialog open={scheduleModalOpen} onOpenChange={setScheduleModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg flex items-center gap-2">
<ClipboardList className="h-5 w-5" />
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
</DialogDescription>
</DialogHeader>
{selectedPlan && (
<div className="space-y-4">
<div>
<p className="text-sm font-semibold mb-3 pb-2 border-b"> </p>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs"></Label>
<Input value={selectedPlan.item_code} readOnly className="h-8 text-xs bg-muted/50" />
</div>
<div>
<Label className="text-xs"></Label>
<Input value={selectedPlan.item_name || ""} readOnly className="h-8 text-xs bg-muted/50" />
</div>
<div>
<Label className="text-xs"></Label>
<Input value={selectedPlan.plan_no || ""} readOnly className="h-8 text-xs bg-muted/50" />
</div>
<div>
<Label className="text-xs"></Label>
<Badge variant="secondary" className="mt-1">
{STATUS_LABEL[selectedPlan.status] || selectedPlan.status}
</Badge>
</div>
</div>
</div>
<div>
<p className="text-sm font-semibold mb-3 pb-2 border-b"> </p>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs"> </Label>
<Input type="number" value={modalQuantity} onChange={(e) => setModalQuantity(Number(e.target.value))} className="h-8 text-xs" min={0} />
</div>
<div>
<Label className="text-xs"></Label>
<Select value={modalEquipmentId} onValueChange={setModalEquipmentId}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="설비 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{equipmentList.map((eq) => (
<SelectItem key={eq.equipment_id} value={String(eq.equipment_id)}>
{eq.equipment_name} ({eq.equipment_id})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="mt-3 rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-3">
<p className="text-xs font-semibold text-foreground mb-2"> </p>
<div className="grid grid-cols-3 gap-3">
<div>
<Label className="text-[11px] text-muted-foreground"></Label>
<FormDatePicker value={modalStartDate} onChange={setModalStartDate} placeholder="시작일" />
</div>
<div>
<Label className="text-[11px] text-muted-foreground"></Label>
<FormDatePicker value={modalEndDate} onChange={setModalEndDate} placeholder="종료일" />
</div>
<div>
<Label className="text-[11px] text-muted-foreground"> </Label>
<div className="flex h-8 items-center justify-center rounded-md border bg-background text-xs font-semibold text-primary">
{modalStartDate && modalEndDate
? `${Math.ceil((new Date(modalEndDate).getTime() - new Date(modalStartDate).getTime()) / (1000 * 60 * 60 * 24) + 1)}`
: "-"}
</div>
</div>
</div>
</div>
</div>
<div className="rounded-lg border border-yellow-500/20 bg-yellow-500/5 p-4">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-semibold flex items-center gap-1.5">
<Scissors className="h-4 w-4" />
</p>
<Button
size="sm"
className="h-7 text-xs bg-gradient-to-br from-amber-500 to-amber-600 hover:from-amber-600 hover:to-amber-700"
onClick={() => {
const qty = Math.floor(modalQuantity / 2);
if (qty > 0) handleSplitSchedule(qty);
}}
>
2
</Button>
</div>
<p className="text-xs text-foreground"> .</p>
</div>
<div>
<p className="text-sm font-semibold mb-3 pb-2 border-b"> </p>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs"></Label>
<Input value={modalManager} onChange={(e) => setModalManager(e.target.value)} className="h-8 text-xs" placeholder="담당자명" />
</div>
<div>
<Label className="text-xs"></Label>
<Input value={modalWorkOrderNo} onChange={(e) => setModalWorkOrderNo(e.target.value)} className="h-8 text-xs" placeholder="자동생성" />
</div>
<div className="col-span-2">
<Label className="text-xs"></Label>
<Input value={modalRemarks} onChange={(e) => setModalRemarks(e.target.value)} className="h-8 text-xs" placeholder="비고사항 입력" />
</div>
</div>
</div>
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="destructive" onClick={handleDeletePlan} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
<Trash2 className="mr-1 h-3.5 w-3.5" />
</Button>
<Button variant="outline" onClick={() => setScheduleModalOpen(false)} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
<Button onClick={handleSavePlan} disabled={saving} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
{saving ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <Save className="mr-1 h-3.5 w-3.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 수주 불러오기 모달 */}
<Dialog open={orderImportModalOpen} onOpenChange={setOrderImportModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm"> </DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<p className="font-semibold text-sm mb-2 flex items-center gap-1.5">
<ClipboardList className="h-4 w-4" />
</p>
<div className="space-y-2 max-h-[200px] overflow-y-auto">
{selectedItemGroups.size > 0
? Array.from(selectedItemGroups).map((code) => {
const item = orderItems.find((i) => i.item_code === code);
if (!item) return null;
const hasExistingPlan = Number(item.existing_plan_qty) > 0;
return (
<div key={code} className="rounded-lg border bg-muted/30 p-3">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-semibold">{item.item_code} - {item.item_name}</span>
{hasExistingPlan && <Badge variant="secondary" className="text-[10px]"></Badge>}
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>: {item.order_count}</span>
<span className="text-border">|</span>
<span>: {formatNumber(item.total_balance_qty)} EA</span>
<span className="text-border">|</span>
<span>: {formatNumber(item.current_stock)} EA</span>
<span className="text-border">|</span>
<span>: <span className={Number(item.required_plan_qty) > 0 ? "text-destructive font-semibold" : ""}>{formatNumber(item.required_plan_qty)} EA</span></span>
</div>
</div>
);
})
: <p className="text-xs text-muted-foreground p-3 text-center"> .</p>}
</div>
</div>
<div className="rounded-lg border border-yellow-500/20 bg-yellow-500/5 p-3">
<p className="font-semibold text-sm mb-2 flex items-center gap-1.5">
<AlertTriangle className="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
</p>
<ul className="text-xs text-muted-foreground space-y-1 list-disc list-inside leading-relaxed">
<li> .</li>
<li> .</li>
<li>() .</li>
<li> 0 .</li>
</ul>
</div>
<div>
<p className="font-semibold text-sm mb-2"> </p>
<div className="grid gap-3">
<label className={cn("flex items-center rounded-lg border-2 p-4 cursor-pointer transition-all", importMode === "add" ? "border-primary bg-primary/5" : "border-border hover:border-primary/50")} onClick={() => setImportMode("add")}>
<input type="radio" name="importMode" value="add" checked={importMode === "add"} onChange={() => setImportMode("add")} className="mr-3 h-4 w-4" />
<div>
<p className="font-semibold text-sm">+ </p>
<p className="text-xs text-muted-foreground"> .</p>
</div>
</label>
<label className={cn("flex items-center rounded-lg border-2 p-4 cursor-pointer transition-all", importMode === "new" ? "border-primary bg-primary/5" : "border-border hover:border-primary/50")} onClick={() => setImportMode("new")}>
<input type="radio" name="importMode" value="new" checked={importMode === "new"} onChange={() => setImportMode("new")} className="mr-3 h-4 w-4" />
<div>
<p className="font-semibold text-sm"> </p>
<p className="text-xs text-muted-foreground"> / .</p>
</div>
</label>
</div>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setOrderImportModalOpen(false)} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"></Button>
<Button onClick={handleImportOrderItems} disabled={generating || selectedItemGroups.size === 0} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
{generating ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <Download className="mr-1 h-3.5 w-3.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 안전재고 불러오기 모달 */}
<Dialog open={stockImportModalOpen} onOpenChange={setStockImportModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm"> </DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<p className="font-semibold text-sm mb-2 flex items-center gap-1.5">
<AlertTriangle className="h-4 w-4" />
</p>
<div className="space-y-2 max-h-[200px] overflow-y-auto">
{selectedStockItems.size > 0
? Array.from(selectedStockItems).map((code) => {
const stock = stockItems.find((s) => s.item_code === code);
if (!stock) return null;
return (
<div key={code} className="rounded-lg border bg-muted/30 p-3">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-semibold">{stock.item_code} - {stock.item_name}</span>
<Badge variant="destructive" className="text-[10px]"></Badge>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>: {formatNumber(stock.current_qty)} EA</span>
<span className="text-border">|</span>
<span>: {formatNumber(stock.safety_qty)} EA</span>
<span className="text-border">|</span>
<span>: <span className="text-destructive font-semibold">{formatNumber(stock.recommended_qty)} EA</span></span>
</div>
</div>
);
})
: <p className="text-xs text-muted-foreground p-3 text-center"> .</p>}
</div>
</div>
<div className="rounded-lg border border-yellow-500/20 bg-yellow-500/5 p-3">
<p className="font-semibold text-sm mb-2 flex items-center gap-1.5">
<AlertTriangle className="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
</p>
<ul className="text-xs text-muted-foreground space-y-1 list-disc list-inside leading-relaxed">
<li> .</li>
<li> .</li>
<li> .</li>
</ul>
</div>
<div>
<p className="font-semibold text-sm mb-2"> </p>
<div className="grid gap-3">
<label className={cn("flex items-center rounded-lg border-2 p-4 cursor-pointer transition-all", importMode === "add" ? "border-primary bg-primary/5" : "border-border hover:border-primary/50")} onClick={() => setImportMode("add")}>
<input type="radio" name="stockImportMode" value="add" checked={importMode === "add"} onChange={() => setImportMode("add")} className="mr-3 h-4 w-4" />
<div>
<p className="font-semibold text-sm">+ </p>
<p className="text-xs text-muted-foreground"> .</p>
</div>
</label>
<label className={cn("flex items-center rounded-lg border-2 p-4 cursor-pointer transition-all", importMode === "new" ? "border-primary bg-primary/5" : "border-border hover:border-primary/50")} onClick={() => setImportMode("new")}>
<input type="radio" name="stockImportMode" value="new" checked={importMode === "new"} onChange={() => setImportMode("new")} className="mr-3 h-4 w-4" />
<div>
<p className="font-semibold text-sm"> </p>
<p className="text-xs text-muted-foreground"> / .</p>
</div>
</label>
</div>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setStockImportModalOpen(false)} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"></Button>
<Button onClick={handleImportStockItems} disabled={generating || selectedStockItems.size === 0} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
{generating ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <Download className="mr-1 h-3.5 w-3.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 변경사항 확인(미리보기) 모달 */}
<Dialog open={changeConfirmModalOpen} onOpenChange={setChangeConfirmModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm"> </DialogDescription>
</DialogHeader>
{previewData && (
<div className="space-y-4">
<div className="grid grid-cols-4 gap-3">
<div className="rounded-lg border bg-primary/10 p-3 text-center">
<p className="text-2xl font-bold text-foreground">{previewData.summary?.total ?? 0}</p>
<p className="text-xs text-muted-foreground mt-1"> </p>
</div>
<div className="rounded-lg border bg-emerald-500/10 p-3 text-center">
<p className="text-2xl font-bold text-foreground">{previewData.summary?.new_count ?? 0}</p>
<p className="text-xs text-muted-foreground mt-1"> </p>
</div>
<div className="rounded-lg border bg-yellow-500/10 p-3 text-center">
<p className="text-2xl font-bold text-foreground">{previewData.summary?.kept_count ?? 0}</p>
<p className="text-xs text-muted-foreground mt-1"></p>
</div>
<div className="rounded-lg border bg-destructive/10 p-3 text-center">
<p className="text-2xl font-bold text-foreground">{previewData.summary?.deleted_count ?? 0}</p>
<p className="text-xs text-muted-foreground mt-1"></p>
</div>
</div>
{(previewData.schedules?.length || 0) > 0 && (
<div>
<p className="text-sm font-semibold mb-2 text-emerald-600 dark:text-emerald-400 flex items-center gap-1.5">
<Zap className="h-4 w-4" />
({previewData.schedules?.length || 0})
</p>
<div className="rounded-md border border-emerald-500/20 overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-emerald-500/5">
<TableHead className="text-xs font-semibold"></TableHead>
<TableHead className="text-xs font-semibold"></TableHead>
<TableHead className="text-xs font-semibold text-right"></TableHead>
<TableHead className="text-xs font-semibold"></TableHead>
<TableHead className="text-xs font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(previewData.schedules || []).map((s: any, idx: number) => (
<TableRow key={`new-${idx}`}>
<TableCell className="text-xs">{s.item_code}</TableCell>
<TableCell className="text-xs">{s.item_name}</TableCell>
<TableCell className="text-xs text-right">{formatNumber(s.plan_qty || s.required_qty)}</TableCell>
<TableCell className="text-xs">{s.start_date?.split("T")[0]}</TableCell>
<TableCell className="text-xs">{s.end_date?.split("T")[0]}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
{(previewData.deletedSchedules?.length || 0) > 0 && (
<div>
<p className="text-sm font-semibold mb-2 text-destructive flex items-center gap-1.5">
<Trash2 className="h-4 w-4" />
({previewData.deletedSchedules?.length || 0})
</p>
<div className="rounded-md border border-destructive/20 overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-destructive/5">
<TableHead className="text-xs font-semibold"></TableHead>
<TableHead className="text-xs font-semibold"></TableHead>
<TableHead className="text-xs font-semibold"></TableHead>
<TableHead className="text-xs font-semibold text-right"></TableHead>
<TableHead className="text-xs font-semibold"></TableHead>
<TableHead className="text-xs font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(previewData.deletedSchedules || []).map((s: any, idx: number) => (
<TableRow key={`del-${idx}`} className="text-destructive/80">
<TableCell className="text-xs">{s.plan_no || "-"}</TableCell>
<TableCell className="text-xs">{s.item_code}</TableCell>
<TableCell className="text-xs">{s.item_name}</TableCell>
<TableCell className="text-xs text-right">{formatNumber(s.plan_qty)}</TableCell>
<TableCell className="text-xs">{s.start_date?.split("T")[0]}</TableCell>
<TableCell className="text-xs">{s.end_date?.split("T")[0]}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
{(previewData.keptSchedules?.length || 0) > 0 && (
<div>
<p className="text-sm font-semibold mb-2 text-yellow-600 dark:text-yellow-400 flex items-center gap-1.5">
<Package className="h-4 w-4" />
({previewData.keptSchedules?.length || 0})
</p>
<div className="rounded-md border border-yellow-500/20 overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-yellow-500/5">
<TableHead className="text-xs font-semibold"></TableHead>
<TableHead className="text-xs font-semibold"></TableHead>
<TableHead className="text-xs font-semibold"></TableHead>
<TableHead className="text-xs font-semibold text-right"></TableHead>
<TableHead className="text-xs font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(previewData.keptSchedules || []).map((s: any, idx: number) => (
<TableRow key={`kept-${idx}`}>
<TableCell className="text-xs">{s.plan_no || "-"}</TableCell>
<TableCell className="text-xs">{s.item_code}</TableCell>
<TableCell className="text-xs">{s.item_name}</TableCell>
<TableCell className="text-xs text-right">{formatNumber(s.plan_qty)}</TableCell>
<TableCell className="text-xs">
<Badge variant="outline" className="text-[10px]">{STATUS_LABEL[s.status] || s.status}</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setChangeConfirmModalOpen(false)} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"></Button>
<Button onClick={handleApplySchedule} disabled={generating} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
{generating ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : null}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 엑셀 업로드 모달 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName="production_plan_mng"
onSuccess={() => {
fetchPlans();
fetchOrderSummary();
}}
/>
{/* ConfirmDialog 렌더 */}
{ConfirmDialogComponent}
</div>
);
}