diff --git a/backend-node/src/controllers/productionController.ts b/backend-node/src/controllers/productionController.ts index 582188d6..73aeb53f 100644 --- a/backend-node/src/controllers/productionController.ts +++ b/backend-node/src/controllers/productionController.ts @@ -40,6 +40,28 @@ export async function getStockShortage(req: AuthenticatedRequest, res: Response) } } +// ─── 생산계획 목록 조회 ─── + +export async function getPlans(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { productType, status, startDate, endDate, itemCode } = req.query; + + const data = await productionService.getPlans(companyCode, { + productType: productType as string, + status: status as string, + startDate: startDate as string, + endDate: endDate as string, + itemCode: itemCode as string, + }); + + return res.json({ success: true, data }); + } catch (error: any) { + logger.error("생산계획 목록 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + // ─── 생산계획 상세 조회 ─── export async function getPlanById(req: AuthenticatedRequest, res: Response) { diff --git a/backend-node/src/routes/productionRoutes.ts b/backend-node/src/routes/productionRoutes.ts index 572674aa..a7505913 100644 --- a/backend-node/src/routes/productionRoutes.ts +++ b/backend-node/src/routes/productionRoutes.ts @@ -16,6 +16,9 @@ router.get("/order-summary", productionController.getOrderSummary); // 안전재고 부족분 조회 router.get("/stock-shortage", productionController.getStockShortage); +// 생산계획 목록 조회 +router.get("/plans", productionController.getPlans); + // 생산계획 CRUD router.get("/plan/:id", productionController.getPlanById); router.put("/plan/:id", productionController.updatePlan); diff --git a/backend-node/src/services/productionPlanService.ts b/backend-node/src/services/productionPlanService.ts index f6b080a0..449218e8 100644 --- a/backend-node/src/services/productionPlanService.ts +++ b/backend-node/src/services/productionPlanService.ts @@ -155,6 +155,80 @@ export async function getStockShortage(companyCode: string) { return result.rows; } +// ─── 생산계획 목록 조회 ─── + +export async function getPlans( + companyCode: string, + options?: { + productType?: string; + status?: string; + startDate?: string; + endDate?: string; + itemCode?: string; + } +) { + const pool = getPool(); + const conditions: string[] = ["p.company_code = $1"]; + const params: any[] = [companyCode]; + let paramIdx = 2; + + if (companyCode !== "*") { + // 일반 회사: 자사 데이터만 + } else { + // 최고관리자: 전체 데이터 (company_code 조건 제거) + conditions.length = 0; + } + + if (options?.productType) { + conditions.push(`COALESCE(p.product_type, '완제품') = $${paramIdx}`); + params.push(options.productType); + paramIdx++; + } + if (options?.status && options.status !== "all") { + conditions.push(`p.status = $${paramIdx}`); + params.push(options.status); + paramIdx++; + } + if (options?.startDate) { + conditions.push(`p.end_date >= $${paramIdx}::date`); + params.push(options.startDate); + paramIdx++; + } + if (options?.endDate) { + conditions.push(`p.start_date <= $${paramIdx}::date`); + params.push(options.endDate); + paramIdx++; + } + if (options?.itemCode) { + conditions.push(`(p.item_code ILIKE $${paramIdx} OR p.item_name ILIKE $${paramIdx})`); + params.push(`%${options.itemCode}%`); + paramIdx++; + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const query = ` + SELECT + p.id, p.company_code, p.plan_no, p.plan_date, + p.item_code, p.item_name, p.product_type, + p.plan_qty, p.completed_qty, p.progress_rate, + p.start_date, p.end_date, p.due_date, + p.equipment_id, p.equipment_code, p.equipment_name, + p.status, p.priority, p.work_shift, + p.work_order_no, p.manager_name, + p.order_no, p.parent_plan_id, p.remarks, + p.hourly_capacity, p.daily_capacity, p.lead_time, + p.created_date, p.updated_date + FROM production_plan_mng p + ${whereClause} + ORDER BY p.start_date ASC, p.item_code ASC + `; + + const result = await pool.query(query, params); + logger.info("생산계획 목록 조회", { companyCode, count: result.rowCount }); + return result.rows; +} + // ─── 생산계획 CRUD ─── export async function getPlanById(companyCode: string, planId: number) { @@ -293,7 +367,18 @@ export async function previewSchedule( } const dailyCapacity = item.daily_capacity || 800; - const requiredQty = item.required_qty; + + let requiredQty = item.required_qty; + + // recalculate_unstarted가 true이면 기존 planned 삭제 후 재생성이므로, + // 프론트에서 이미 차감된 기존 계획 수량을 다시 더해줘야 정확한 필요 수량이 됨 + if (options.recalculate_unstarted) { + const deletedQtyForItem = deletedSchedules + .filter((d: any) => d.item_code === item.item_code) + .reduce((sum: number, d: any) => sum + (parseFloat(d.plan_qty) || 0), 0); + requiredQty += deletedQtyForItem; + } + if (requiredQty <= 0) continue; const productionDays = Math.ceil(requiredQty / dailyCapacity); @@ -343,7 +428,7 @@ export async function previewSchedule( }; logger.info("자동 스케줄 미리보기", { companyCode, summary }); - return { summary, previews, deletedSchedules, keptSchedules }; + return { summary, schedules: previews, deletedSchedules, keptSchedules }; } export async function generateSchedule( @@ -365,7 +450,21 @@ export async function generateSchedule( const newSchedules: any[] = []; for (const item of items) { - // 기존 미진행(planned) 스케줄 처리 + // 삭제 전에 기존 planned 수량 먼저 조회 + let deletedQtyForItem = 0; + if (options.recalculate_unstarted) { + const deletedQtyResult = await client.query( + `SELECT COALESCE(SUM(COALESCE(plan_qty::numeric, 0)), 0) AS deleted_qty + FROM production_plan_mng + WHERE company_code = $1 AND item_code = $2 + AND COALESCE(product_type, '완제품') = $3 + AND status = 'planned'`, + [companyCode, item.item_code, productType] + ); + deletedQtyForItem = parseFloat(deletedQtyResult.rows[0].deleted_qty) || 0; + } + + // 기존 미진행(planned) 스케줄 삭제 if (options.recalculate_unstarted) { const deleteResult = await client.query( `DELETE FROM production_plan_mng @@ -389,9 +488,9 @@ export async function generateSchedule( keptCount += parseInt(keptResult.rows[0].cnt, 10); } - // 생산일수 계산 + // 필요 수량 계산 (삭제된 planned 수량을 복원) const dailyCapacity = item.daily_capacity || 800; - const requiredQty = item.required_qty; + let requiredQty = item.required_qty + deletedQtyForItem; if (requiredQty <= 0) continue; const productionDays = Math.ceil(requiredQty / dailyCapacity); @@ -683,7 +782,7 @@ export async function previewSemiSchedule( parent_count: plansResult.rowCount, }; - return { summary, previews, deletedSchedules, keptSchedules }; + return { summary, schedules: previews, deletedSchedules, keptSchedules }; } // ─── 반제품 계획 자동 생성 ─── diff --git a/frontend/app/(main)/production/plan-management/page.tsx b/frontend/app/(main)/production/plan-management/page.tsx new file mode 100644 index 00000000..a5b66640 --- /dev/null +++ b/frontend/app/(main)/production/plan-management/page.tsx @@ -0,0 +1,1568 @@ +"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 { + Settings, + Upload, + Download, + RefreshCw, + ChevronRight, + Save, + Trash2, + Zap, + Package, + Wrench, + AlertTriangle, + ClipboardList, + Factory, + Calendar, + Scissors, + Loader2, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { + getOrderSummary, + getStockShortage, + getPlans, + getPlanById, + updatePlan, + deletePlan, + generateSchedule, + previewSchedule, + mergeSchedules, + generateSemiSchedule, + previewSemiSchedule, + splitSchedule, + type OrderSummaryItem, + type StockShortageItem, + type ProductionPlan, + type GenerateScheduleRequest, + type GenerateScheduleResponse, +} from "@/lib/api/production"; + +// ========== 메인 컴포넌트 ========== + +export default function ProductionPlanManagementPage() { + // 탭 상태 + const [leftTab, setLeftTab] = useState("order"); + const [rightTab, setRightTab] = useState("finished"); + + // 로딩 상태 + 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([]); + const [stockItems, setStockItems] = useState([]); + const [finishedPlans, setFinishedPlans] = useState([]); + const [semiPlans, setSemiPlans] = useState([]); + + // 선택/토글 상태 + const [expandedItems, setExpandedItems] = useState>(new Set()); + const [selectedItemGroups, setSelectedItemGroups] = useState>(new Set()); + const [filterUnplannedOrdersOnly, setFilterUnplannedOrdersOnly] = useState(false); + const [selectedStockItems, setSelectedStockItems] = useState>(new Set()); + const [filterUnplannedOnly, setFilterUnplannedOnly] = useState(false); + + // 검색 필터 + const [searchItemCode, setSearchItemCode] = useState(""); + const [searchStatus, setSearchStatus] = useState("all"); + const [searchStartDate, setSearchStartDate] = useState(""); + const [searchEndDate, setSearchEndDate] = useState(""); + + // 타임라인 옵션 + const [safetyLeadTime, setSafetyLeadTime] = useState(1); + const [displayWeeks, setDisplayWeeks] = useState(4); + const [recalculateUnstarted, setRecalculateUnstarted] = useState(true); + + // 반제품 옵션 + 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 [equipmentModalOpen, setEquipmentModalOpen] = useState(false); + const [changeConfirmModalOpen, setChangeConfirmModalOpen] = useState(false); + + // 모달 데이터 + const [selectedPlan, setSelectedPlan] = useState(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 [previewData, setPreviewData] = useState(null); + + // 불러오기 모드 + const [importMode, setImportMode] = useState<"add" | "new">("add"); + + // ========== 데이터 로드 ========== + + 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]); + + useEffect(() => { + fetchOrderSummary(); + fetchStockShortage(); + fetchPlans(); + }, []); + + // ========== 토글/선택 핸들러 ========== + + 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) => { + if (checked) { + setSelectedItemGroups(new Set(orderItems.map((i) => i.item_code))); + } else { + setSelectedItemGroups(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) => { + if (checked) { + setSelectedStockItems(new Set(stockItems.map((i) => i.item_code))); + } else { + setSelectedStockItems(new Set()); + } + }, + [stockItems] + ); + + // ========== 타임라인 날짜 ========== + + const timelineDates = useMemo(() => { + const dates: Date[] = []; + const today = new Date(); + today.setHours(0, 0, 0, 0); + for (let i = 0; i < displayWeeks * 7; i++) { + const d = new Date(today); + d.setDate(today.getDate() + i); + dates.push(d); + } + return dates; + }, [displayWeeks]); + + const isWeekend = (date: Date) => date.getDay() === 0 || date.getDay() === 6; + const isToday = (date: Date) => { + const today = new Date(); + return date.getFullYear() === today.getFullYear() && date.getMonth() === today.getMonth() && date.getDate() === today.getDate(); + }; + const formatDateLabel = (date: Date) => { + const dayNames = ["일", "월", "화", "수", "목", "금", "토"]; + return { day: date.getDate(), dayName: dayNames[date.getDay()] }; + }; + + // 생산 바의 위치/너비를 계산 + const getBarPosition = useCallback( + (startDate: string, endDate: string) => { + if (timelineDates.length === 0) return null; + const start = new Date(startDate); + const end = new Date(endDate); + const firstDate = timelineDates[0]; + const lastDate = timelineDates[timelineDates.length - 1]; + + if (end < firstDate || start > lastDate) return null; + + const totalDays = timelineDates.length; + const cellWidth = 100 / totalDays; + + const startIdx = Math.max(0, Math.floor((start.getTime() - firstDate.getTime()) / (1000 * 60 * 60 * 24))); + const endIdx = Math.min(totalDays - 1, Math.floor((end.getTime() - firstDate.getTime()) / (1000 * 60 * 60 * 24))); + + return { + left: `${startIdx * cellWidth}%`, + width: `${(endIdx - startIdx + 1) * cellWidth}%`, + }; + }, + [timelineDates] + ); + + const statusBarColor: Record = { + planned: "from-blue-500 to-blue-600", + "work-order": "from-amber-500 to-amber-600", + "in-progress": "from-emerald-500 to-emerald-600", + completed: "from-gray-500 to-gray-600 opacity-70", + }; + + // ========== 액션 핸들러 ========== + + const handleSearch = useCallback(() => { + fetchOrderSummary(); + fetchPlans(); + }, [fetchOrderSummary, fetchPlans]); + + 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], + })); + + setGenerating(true); + try { + const req: GenerateScheduleRequest = { + items, + options: { + safety_lead_time: safetyLeadTime, + 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, safetyLeadTime, 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], + })); + + setGenerating(true); + try { + const res = await generateSchedule({ + items, + options: { + safety_lead_time: safetyLeadTime, + 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, safetyLeadTime, 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; + } + try { + await Promise.all(plannedIds.map((id) => deletePlan(id))); + toast.success(`${plannedIds.length}건의 계획이 삭제되었습니다`); + fetchPlans(); + } catch (err: any) { + toast.error("삭제 실패: " + (err.message || "")); + } + }, [finishedPlans, fetchPlans]); + + 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(async (plan: ProductionPlan) => { + setSelectedPlan(plan); + setModalQuantity(Number(plan.plan_qty)); + setModalStartDate(plan.start_date?.split("T")[0] || ""); + setModalEndDate(plan.end_date?.split("T")[0] || ""); + setModalManager(plan.manager_name || ""); + setModalWorkOrderNo(plan.work_order_no || ""); + setModalRemarks(plan.remarks || ""); + 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, + }); + 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, fetchPlans]); + + const handleDeletePlan = useCallback(async () => { + if (!selectedPlan) return; + try { + await deletePlan(selectedPlan.id); + toast.success("삭제되었습니다"); + setScheduleModalOpen(false); + fetchPlans(); + } catch (err: any) { + toast.error("삭제 실패: " + (err.message || "")); + } + }, [selectedPlan, fetchPlans]); + + 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 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: safetyLeadTime, + 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, safetyLeadTime, 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: safetyLeadTime, + 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, safetyLeadTime, importMode, fetchPlans, fetchStockShortage]); + + // 숫자 포맷 + const formatNumber = (num: number | string) => Number(num).toLocaleString(); + + // 타임라인 렌더링을 위해 품목별 그룹핑 + const groupedPlans = useMemo(() => { + const map = new Map(); + for (const plan of finishedPlans) { + const key = plan.item_code; + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(plan); + } + return map; + }, [finishedPlans]); + + const groupedSemiPlans = useMemo(() => { + const map = new Map(); + for (const plan of semiPlans) { + const key = plan.item_code; + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(plan); + } + return map; + }, [semiPlans]); + + // ========== 렌더링 ========== + + const renderTimeline = (plans: Map) => { + if (plans.size === 0) return null; + + return ( +
+ {/* 날짜 헤더 */} +
+ {timelineDates.map((date, idx) => { + const { day, dayName } = formatDateLabel(date); + return ( +
+
{day}
+
{dayName}
+
+ ); + })} +
+ + {/* 품목별 행 */} + {Array.from(plans.entries()).map(([itemCode, itemPlans]) => ( +
+
+ {itemCode} + {itemPlans[0]?.item_name} +
+
+ {/* 그리드 배경 */} +
+ {timelineDates.map((date, idx) => ( +
+ ))} +
+ {/* 생산 바 */} + {itemPlans.map((plan) => { + const pos = getBarPosition(plan.start_date, plan.end_date); + if (!pos) return null; + const colorClass = statusBarColor[plan.status] || statusBarColor.planned; + return ( +
openScheduleDetail(plan)} + > + + {formatNumber(plan.plan_qty)} + +
+ ); + })} +
+
+ ))} +
+ ); + }; + + return ( +
+ {/* 검색 섹션 */} +
+
+
+
+ + setSearchStartDate(e.target.value)} + className="h-9 w-[150px] text-sm" + /> + ~ + setSearchEndDate(e.target.value)} + className="h-9 w-[150px] text-sm" + /> +
+
+ + setSearchItemCode(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSearch()} + className="h-9 w-[160px] text-sm" + /> +
+
+ + +
+ +
+
+ + + +
+
+
+ + {/* 데이터 섹션 */} + + {/* 왼쪽 패널 */} + +
+ +
+ + + + 수주데이터 + + + + 안전재고 부족분 + + +
+ + {/* 수주데이터 탭 */} + +
+ 수주 목록 +
+ + + +
+
+
+ {loadingOrders ? ( +
+ +
+ ) : orderItems.length === 0 ? ( +
+ +

수주 데이터가 없습니다

+
+ ) : ( +
+ + + + + 0} onCheckedChange={(c) => toggleAllItemGroups(!!c)} className="h-4 w-4" /> + + + 품목코드 + 품목명 + 총수주량 + 출고량 + 잔량 + 현재고 + 안전재고 + 기생산계획량 + 생산진행 + 필요생산계획 + + + + {orderItems.map((item) => ( + + + e.stopPropagation()}> + toggleItemGroupSelect(item.item_code)} className="h-4 w-4" /> + + toggleItemExpand(item.item_code)}> + + + toggleItemExpand(item.item_code)}>{item.item_code} + toggleItemExpand(item.item_code)}>{item.item_name} + toggleItemExpand(item.item_code)}>{formatNumber(item.total_order_qty)} + toggleItemExpand(item.item_code)}>{formatNumber(item.total_ship_qty)} + toggleItemExpand(item.item_code)}>{formatNumber(item.total_balance_qty)} + toggleItemExpand(item.item_code)}>{formatNumber(item.current_stock)} + toggleItemExpand(item.item_code)}>{formatNumber(item.safety_stock)} + toggleItemExpand(item.item_code)}>{formatNumber(item.existing_plan_qty)} + toggleItemExpand(item.item_code)}>{formatNumber(item.in_progress_qty)} + 0 ? "text-destructive" : "text-emerald-600 dark:text-emerald-400")} onClick={() => toggleItemExpand(item.item_code)}> + {formatNumber(item.required_plan_qty)} + + + + {expandedItems.has(item.item_code) && item.orders?.map((detail) => ( + + + + +
+ 수주번호: + {detail.order_no} + 거래처: + {detail.customer_name || "-"} + + {detail.status || "일반"} + +
+
+ {formatNumber(detail.order_qty)} + {formatNumber(detail.ship_qty)} + {formatNumber(detail.balance_qty)} + + 납기일: {detail.due_date || "-"} + +
+ ))} +
+ ))} +
+
+
+ )} +
+
+ + {/* 안전재고 부족분 탭 */} + +
+ 안전재고 부족 품목 +
+ + +
+
+
+ {loadingStock ? ( +
+ +
+ ) : stockItems.length === 0 ? ( +
+ +

안전재고 부족 품목이 없습니다

+
+ ) : ( +
+ + + + + 0} onCheckedChange={(c) => toggleAllStockItems(!!c)} className="h-4 w-4" /> + + 품목코드 + 품목명 + 현재고 + 안전재고 + 부족수량 + 권장생산량 + 최종입고일 + + + + {stockItems.map((stock) => ( + + + toggleStockItem(stock.item_code)} className="h-4 w-4" /> + + {stock.item_code} + {stock.item_name} + {formatNumber(stock.current_qty)} + {formatNumber(stock.safety_qty)} + {formatNumber(stock.shortage_qty)} + {formatNumber(stock.recommended_qty)} + {stock.last_in_date || "-"} + + ))} + +
+
+ )} +
+
+
+
+
+ + + + {/* 오른쪽 패널 */} + +
+ +
+ + + + 완제품 생산계획 + + + + 반제품 생산계획 + + +
+ + {/* 완제품 생산계획 */} + +
+ + + 완제품 생산 타임라인 + {finishedPlans.length}건 + +
+ + + + +
+
+
+
+ {/* 옵션 & 범례 */} +
+
+
+
+ + setSafetyLeadTime(Number(e.target.value))} className="h-8 w-[100px] text-xs" min={0} max={10} /> +
+
+ + setDisplayWeeks(Number(e.target.value))} className="h-8 w-[100px] text-xs" min={1} max={12} /> +
+
+ setRecalculateUnstarted(!!c)} className="h-4 w-4" /> + +
+
+
+ 상태: +
계획
+
지시
+
진행
+
완료
+
+
+
+ + {/* 타임라인 */} + {loadingPlans ? ( +
+ +
+ ) : ( +
+ {groupedPlans.size === 0 ? ( +
+ +

생산 스케줄이 없습니다

+

왼쪽에서 품목을 선택하고 “자동 스케줄 생성” 버튼을 클릭하세요

+
+ ) : ( + renderTimeline(groupedPlans) + )} +
+ )} +
+
+ + + {/* 반제품 생산계획 */} + +
+ + + 반제품 생산 타임라인 + {semiPlans.length}건 + +
+ + +
+
+
+
+
+

반제품 계획 생성 옵션

+
+ + + +
+
+
+

반제품 계획 안내

+
    +
  • 완제품 생산계획 기준으로 필요한 반제품 계획 자동 생성
  • +
  • 모품목 생산 시작일 고려하여 납기일 설정
  • +
  • BOM(자재명세서) 정보 기반 필요 수량 계산
  • +
+
+
+ + {loadingPlans ? ( +
+ +
+ ) : ( +
+ {groupedSemiPlans.size === 0 ? ( +
+ +

반제품 생산 스케줄이 없습니다

+

“반제품 계획 자동 생성” 버튼을 클릭하여 생성하세요

+
+ ) : ( + renderTimeline(groupedSemiPlans) + )} +
+ )} +
+
+ +
+ + + + {/* ========== 모달들 ========== */} + + {/* 스케줄 상세 모달 */} + + + + + + 생산 스케줄 상세 + + + 생산 스케줄의 상세 정보를 확인하고 수정할 수 있습니다 + + + + {selectedPlan && ( +
+
+

기본 정보

+
+
+ + +
+
+ + +
+
+ + +
+
+ + + {selectedPlan.status === "planned" ? "계획" : selectedPlan.status === "work-order" ? "작업지시" : selectedPlan.status === "in-progress" ? "진행중" : "완료"} + +
+
+
+ +
+

생산 정보

+
+
+ + setModalQuantity(Number(e.target.value))} className="h-8 text-xs" min={0} /> +
+
+ + +
+
+
+

계획 기간 설정

+
+
+ + setModalStartDate(e.target.value)} className="h-8 text-xs" /> +
+
+ + setModalEndDate(e.target.value)} className="h-8 text-xs" /> +
+
+ +
+ {modalStartDate && modalEndDate + ? `${Math.ceil((new Date(modalEndDate).getTime() - new Date(modalStartDate).getTime()) / (1000 * 60 * 60 * 24) + 1)}일` + : "-"} +
+
+
+
+
+ +
+
+

+ + 계획 분할 +

+ +
+

하나의 생산계획을 여러 개로 분할합니다.

+
+ +
+

추가 정보

+
+
+ + setModalManager(e.target.value)} className="h-8 text-xs" placeholder="담당자명" /> +
+
+ + setModalWorkOrderNo(e.target.value)} className="h-8 text-xs" placeholder="자동생성" /> +
+
+ + setModalRemarks(e.target.value)} className="h-8 text-xs" placeholder="비고사항 입력" /> +
+
+
+
+ )} + + + + + + +
+
+ + {/* 수주 불러오기 모달 */} + + + + 신규 수주 품목 불러오기 + 선택한 수주 품목을 생산계획에 추가합니다 + +
+ {/* 선택된 품목 카드 */} +
+

+ + 선택된 품목 +

+
+ {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 ( +
+
+ {item.item_code} - {item.item_name} + {hasExistingPlan && ( + 가계획완료 + )} +
+
+ 수주: {item.order_count}건 + | + 잔량: {formatNumber(item.total_balance_qty)} EA + | + 재고: {formatNumber(item.current_stock)} EA + | + 필요수량: 0 ? "text-destructive font-semibold" : ""}>{formatNumber(item.required_plan_qty)} EA +
+
+ ); + }) + :

선택된 품목이 없습니다.

} +
+
+ + {/* 주의사항 */} +
+

+ + 주의사항 +

+
    +
  • 기존 계획에 추가 시 새로운 수주의 납기를 기준으로 종료일이 조정됩니다.
  • +
  • 기존 계획이 없으면 새 계획이 자동 생성됩니다.
  • +
  • 수주정보(수주번호)가 있으면 관련 정보가 연결됩니다.
  • +
  • 수량이 0이거나 음수인 품목은 건너뜁니다.
  • +
+
+ + {/* 계획 추가 방식 선택 */} +
+

계획 추가 방식을 선택하세요

+
+ + +
+
+
+ + + + +
+
+ + {/* 안전재고 불러오기 모달 */} + + + + 안전재고 부족 품목 불러오기 + 선택한 안전재고 부족 품목을 생산계획에 추가합니다 + +
+ {/* 선택된 품목 카드 */} +
+

+ + 선택된 품목 +

+
+ {selectedStockItems.size > 0 + ? Array.from(selectedStockItems).map((code) => { + const stock = stockItems.find((s) => s.item_code === code); + if (!stock) return null; + return ( +
+
+ {stock.item_code} - {stock.item_name} + 재고부족 +
+
+ 현재고: {formatNumber(stock.current_stock)} EA + | + 안전재고: {formatNumber(stock.safety_stock)} EA + | + 권장생산: {formatNumber(stock.recommended_qty)} EA +
+
+ ); + }) + :

선택된 품목이 없습니다.

} +
+
+ + {/* 주의사항 */} +
+

+ + 주의사항 +

+
    +
  • 안전재고 기준으로 부족분만큼 생산계획을 생성합니다.
  • +
  • 현재 재고와 안전재고 차이를 기반으로 수량이 산정됩니다.
  • +
  • 기존 계획이 있는 품목은 추가 방식에 따라 합산 또는 별도 생성됩니다.
  • +
+
+ + {/* 계획 추가 방식 선택 */} +
+

계획 추가 방식을 선택하세요

+
+ + +
+
+
+ + + + +
+
+ + {/* 변경사항 확인 모달 */} + + + + 생산계획 변경사항 확인 + 아래 변경사항을 검토하신 후 확인 버튼을 클릭하면 생산계획이 업데이트됩니다 + + + {previewData && ( +
+
+
+

{previewData.summary?.total ?? 0}

+

총 계획

+
+
+

{previewData.summary?.new_count ?? 0}

+

신규 생성

+
+
+

{previewData.summary?.kept_count ?? 0}

+

유지됨

+
+
+

{previewData.summary?.deleted_count ?? 0}

+

삭제됨

+
+
+ + {(previewData.schedules?.length || 0) > 0 && ( +
+

+ + 신규 생성 ({previewData.schedules?.length || 0}건) +

+
+ + + + 품목코드 + 품목명 + 수량 + 시작일 + 종료일 + + + + {(previewData.schedules || []).map((s, idx) => ( + + {s.item_code} + {s.item_name} + {formatNumber(s.plan_qty || s.required_qty)} + {s.start_date?.split("T")[0]} + {s.end_date?.split("T")[0]} + + ))} + +
+
+
+ )} + + {(previewData.deletedSchedules?.length || 0) > 0 && ( +
+

+ + 삭제 예정 ({previewData.deletedSchedules?.length || 0}건) +

+
+ + + + 계획번호 + 품목코드 + 품목명 + 수량 + 시작일 + 종료일 + + + + {(previewData.deletedSchedules || []).map((s, idx) => ( + + {s.plan_no || "-"} + {s.item_code} + {s.item_name} + {formatNumber(s.plan_qty)} + {s.start_date?.split("T")[0]} + {s.end_date?.split("T")[0]} + + ))} + +
+
+
+ )} + + {(previewData.keptSchedules?.length || 0) > 0 && ( +
+

+ + 유지됨 ({previewData.keptSchedules?.length || 0}건) +

+
+ + + + 계획번호 + 품목코드 + 품목명 + 수량 + 상태 + + + + {(previewData.keptSchedules || []).map((s, idx) => ( + + {s.plan_no || "-"} + {s.item_code} + {s.item_name} + {formatNumber(s.plan_qty)} + + {s.status} + + + ))} + +
+
+
+ )} +
+ )} + + + + + +
+
+
+ ); +} diff --git a/frontend/app/(main)/production/work-instruction/page.tsx b/frontend/app/(main)/production/work-instruction/page.tsx index 307e4791..0986e3eb 100644 --- a/frontend/app/(main)/production/work-instruction/page.tsx +++ b/frontend/app/(main)/production/work-instruction/page.tsx @@ -733,15 +733,17 @@ export default function WorkInstructionPage() {
- 순번품목코드수량비고 + 순번품목코드품목명규격수량비고 {editItems.length === 0 ? ( - 품목이 없습니다 + 품목이 없습니다 ) : editItems.map((item, idx) => ( {idx + 1} {item.itemCode} + {item.itemName || "-"} + {item.spec || "-"} setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> diff --git a/frontend/lib/api/production.ts b/frontend/lib/api/production.ts index 244ef426..8095a05b 100644 --- a/frontend/lib/api/production.ts +++ b/frontend/lib/api/production.ts @@ -2,7 +2,7 @@ * 생산계획 API 클라이언트 */ -import apiClient from "./client"; +import { apiClient } from "./client"; // ─── 타입 정의 ─── @@ -94,10 +94,51 @@ export interface GenerateScheduleResponse { deleted_count: number; }; schedules: ProductionPlan[]; + deletedSchedules?: ProductionPlan[]; + keptSchedules?: ProductionPlan[]; } // ─── API 함수 ─── +/** 생산계획 목록 조회 */ +export async function getPlans(params?: { + productType?: string; + status?: string; + startDate?: string; + endDate?: string; + itemCode?: string; +}) { + const queryParams = new URLSearchParams(); + if (params?.productType) queryParams.set("productType", params.productType); + if (params?.status) queryParams.set("status", params.status); + if (params?.startDate) queryParams.set("startDate", params.startDate); + if (params?.endDate) queryParams.set("endDate", params.endDate); + if (params?.itemCode) queryParams.set("itemCode", params.itemCode); + + const qs = queryParams.toString(); + const url = `/production/plans${qs ? `?${qs}` : ""}`; + const response = await apiClient.get(url); + return response.data as { success: boolean; data: ProductionPlan[] }; +} + +/** 자동 스케줄 미리보기 (DB 변경 없이 예상 결과) */ +export async function previewSchedule(request: GenerateScheduleRequest) { + const response = await apiClient.post("/production/generate-schedule/preview", request); + return response.data as { success: boolean; data: GenerateScheduleResponse }; +} + +/** 반제품 계획 미리보기 */ +export async function previewSemiSchedule( + planIds: number[], + options?: { considerStock?: boolean; excludeUsed?: boolean } +) { + const response = await apiClient.post("/production/generate-semi-schedule/preview", { + plan_ids: planIds, + options: options || {}, + }); + return response.data as { success: boolean; data: { count: number; schedules: ProductionPlan[] } }; +} + /** 수주 데이터 조회 (품목별 그룹핑) */ export async function getOrderSummary(params?: { excludePlanned?: boolean; @@ -110,44 +151,44 @@ export async function getOrderSummary(params?: { if (params?.itemName) queryParams.set("itemName", params.itemName); const qs = queryParams.toString(); - const url = `/api/production/order-summary${qs ? `?${qs}` : ""}`; + const url = `/production/order-summary${qs ? `?${qs}` : ""}`; const response = await apiClient.get(url); return response.data as { success: boolean; data: OrderSummaryItem[] }; } /** 안전재고 부족분 조회 */ export async function getStockShortage() { - const response = await apiClient.get("/api/production/stock-shortage"); + const response = await apiClient.get("/production/stock-shortage"); return response.data as { success: boolean; data: StockShortageItem[] }; } /** 생산계획 상세 조회 */ export async function getPlanById(planId: number) { - const response = await apiClient.get(`/api/production/plan/${planId}`); + const response = await apiClient.get(`/production/plan/${planId}`); return response.data as { success: boolean; data: ProductionPlan }; } /** 생산계획 수정 */ export async function updatePlan(planId: number, data: Partial) { - const response = await apiClient.put(`/api/production/plan/${planId}`, data); + const response = await apiClient.put(`/production/plan/${planId}`, data); return response.data as { success: boolean; data: ProductionPlan }; } /** 생산계획 삭제 */ export async function deletePlan(planId: number) { - const response = await apiClient.delete(`/api/production/plan/${planId}`); + const response = await apiClient.delete(`/production/plan/${planId}`); return response.data as { success: boolean; message: string }; } /** 자동 스케줄 생성 */ export async function generateSchedule(request: GenerateScheduleRequest) { - const response = await apiClient.post("/api/production/generate-schedule", request); + const response = await apiClient.post("/production/generate-schedule", request); return response.data as { success: boolean; data: GenerateScheduleResponse }; } /** 스케줄 병합 */ export async function mergeSchedules(scheduleIds: number[], productType?: string) { - const response = await apiClient.post("/api/production/merge-schedules", { + const response = await apiClient.post("/production/merge-schedules", { schedule_ids: scheduleIds, product_type: productType || "완제품", }); @@ -159,7 +200,7 @@ export async function generateSemiSchedule( planIds: number[], options?: { considerStock?: boolean; excludeUsed?: boolean } ) { - const response = await apiClient.post("/api/production/generate-semi-schedule", { + const response = await apiClient.post("/production/generate-semi-schedule", { plan_ids: planIds, options: options || {}, }); @@ -168,7 +209,7 @@ export async function generateSemiSchedule( /** 스케줄 분할 */ export async function splitSchedule(planId: number, splitQty: number) { - const response = await apiClient.post(`/api/production/plan/${planId}/split`, { + const response = await apiClient.post(`/production/plan/${planId}/split`, { split_qty: splitQty, }); return response.data as {