From 33dfa6b47530f11e12a023801f0c761f96e3ee31 Mon Sep 17 00:00:00 2001 From: kmh Date: Wed, 18 Mar 2026 14:27:19 +0900 Subject: [PATCH 01/14] fix: update ItemRoutingComponent dialog width for better responsiveness - Changed the maximum width of the add item dialog to be more flexible, allowing it to utilize the full width of the viewport while respecting the configured maximum width. - This adjustment aims to enhance the user experience by ensuring the dialog is appropriately sized across different screen sizes. --- .../components/v2-item-routing/ItemRoutingComponent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/lib/registry/components/v2-item-routing/ItemRoutingComponent.tsx b/frontend/lib/registry/components/v2-item-routing/ItemRoutingComponent.tsx index 53717003..901fa2ad 100644 --- a/frontend/lib/registry/components/v2-item-routing/ItemRoutingComponent.tsx +++ b/frontend/lib/registry/components/v2-item-routing/ItemRoutingComponent.tsx @@ -458,7 +458,7 @@ export function ItemRoutingComponent({ {/* ════ 품목 추가 다이얼로그 (테이블 형태 + 검색) ════ */} - + 품목 추가 -- 2.43.0 From 08ad2abdd1b41466cd21a2e3bd6241bf60c429c9 Mon Sep 17 00:00:00 2001 From: kmh Date: Wed, 18 Mar 2026 16:25:13 +0900 Subject: [PATCH 02/14] refactor: improve SplitPanelLayoutComponent for better responsiveness and layout handling - Removed unnecessary div wrappers to streamline the layout structure. - Adjusted table minimum width calculations to ensure better responsiveness across different screen sizes. - Enhanced column width handling to provide a more consistent and flexible user experience. These changes aim to refine the overall layout and usability of the SplitPanelLayoutComponent, ensuring it adapts more effectively to varying content and screen dimensions. --- .../SplitPanelLayoutComponent.tsx | 24 +++++++------------ 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index aa293ce6..254255af 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -3896,13 +3896,13 @@ export const SplitPanelLayoutComponent: React.FC const canDragLeftGroupedColumns = !isDesignMode && columnsToShow.length > 1; if (groupedLeftData.length > 0) { return ( -
+ <> {groupedLeftData.map((group, groupIdx) => (
{group.groupKey} ({group.count}개)
- +
{columnsToShow.map((col, idx) => { @@ -4016,7 +4016,7 @@ export const SplitPanelLayoutComponent: React.FC
))} -
+ ); } @@ -4027,8 +4027,7 @@ export const SplitPanelLayoutComponent: React.FC componentConfig.leftPanel?.showDelete !== false); const canDragLeftColumns = !isDesignMode && columnsToShow.length > 1; return ( -
- +
{columnsToShow.map((col, idx) => { @@ -4135,7 +4134,6 @@ export const SplitPanelLayoutComponent: React.FC })}
-
); })() )} @@ -5189,19 +5187,13 @@ export const SplitPanelLayoutComponent: React.FC columnsToShow = [...keyCols, ...applyRuntimeOrder(dataCols, "main")]; } - // 컬럼 너비 합계 계산 (작업 컬럼 제외, 100% 초과 시 스크롤) - const rightTotalColWidth = columnsToShow.reduce((sum, col) => { - const w = col.width && col.width <= 100 ? col.width : 0; - return sum + w; - }, 0); - const rightConfigColumnStart = columnsToShow.filter((c: any) => c._isKeyColumn).length; const canDragRightColumns = displayColumns.length > 0; return (
- +
{columnsToShow.map((col, idx) => { @@ -5221,7 +5213,7 @@ export const SplitPanelLayoutComponent: React.FC isDragging && "opacity-50", )} style={{ - width: col.width && col.width <= 100 ? `${col.width}%` : "auto", + minWidth: col.width ? `${col.width}px` : "80px", textAlign: col.align || "left", }} draggable={isDraggable} @@ -5387,14 +5379,14 @@ export const SplitPanelLayoutComponent: React.FC return filteredData.length > 0 ? (
-
+
{columnsToDisplay.map((col) => ( -- 2.43.0 From aa48d400487421bd207d687f57f3a90ca6d75885 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 23 Mar 2026 11:11:44 +0900 Subject: [PATCH 03/14] Implement production plan listing feature with API and frontend integration --- .../src/controllers/productionController.ts | 22 + backend-node/src/routes/productionRoutes.ts | 3 + .../src/services/productionPlanService.ts | 111 +- .../production/plan-management/page.tsx | 1568 +++++++++++++++++ .../production/work-instruction/page.tsx | 6 +- frontend/lib/api/production.ts | 61 +- 6 files changed, 1753 insertions(+), 18 deletions(-) create mode 100644 frontend/app/(main)/production/plan-management/page.tsx 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 ? ( +
+ +

수주 데이터가 없습니다

+
+ ) : ( +
+
{col.label}
+ + + + 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 { -- 2.43.0 From cab0342081837d49930f4fbc74ea1bb3f84bd13a Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 23 Mar 2026 15:22:16 +0900 Subject: [PATCH 04/14] Refactor AlertDialog and Dialog components to improve tab management and visibility handling. Updated effectiveOpen logic and adjusted display styles based on tab activity. --- frontend/components/ui/alert-dialog.tsx | 9 +++++++-- frontend/components/ui/dialog.tsx | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/frontend/components/ui/alert-dialog.tsx b/frontend/components/ui/alert-dialog.tsx index f75c75ee..821c7c31 100644 --- a/frontend/components/ui/alert-dialog.tsx +++ b/frontend/components/ui/alert-dialog.tsx @@ -31,7 +31,7 @@ const AlertDialog: React.FC { @@ -94,6 +94,11 @@ const AlertDialogContent = React.forwardRef< const container = explicitContainer !== undefined ? explicitContainer : autoContainer; const scoped = React.useContext(ScopedAlertCtx); + // 탭 비활성 시 content를 언마운트하지 않고 CSS로 숨김 (자식 컴포넌트 상태 보존) + const tabId = useTabId(); + const activeTabId = useTabStore((s) => s[s.mode].activeTabId); + const isTabActive = !tabId || tabId === activeTabId; + const adjustedStyle = scoped && style ? { ...style, maxHeight: undefined, maxWidth: undefined } : style; @@ -117,7 +122,7 @@ const AlertDialogContent = React.forwardRef<
> = ({ isTabActiveRef.current = isTabActive; const effectiveModal = modal !== undefined ? modal : !scoped ? undefined : false; - const effectiveOpen = open != null ? open && isTabActive : undefined; + const effectiveOpen = open != null ? open : undefined; // 비활성 탭에서 발생하는 onOpenChange(false) 차단 // (탭 전환 시 content unmount → focus 이동 → Radix가 onOpenChange(false)를 호출하는 것을 방지) @@ -83,6 +83,11 @@ const DialogContent = React.forwardRef< const container = explicitContainer !== undefined ? explicitContainer : autoContainer; const scoped = !!container; + // 탭 비활성 시 content를 언마운트하지 않고 CSS로 숨김 (자식 컴포넌트 상태 보존) + const tabId = useTabId(); + const activeTabId = useTabStore((s) => s[s.mode].activeTabId); + const isTabActive = !tabId || tabId === activeTabId; + // state 기반 ref: DialogPrimitive.Content 마운트/언마운트 시 useEffect 재실행 보장 const [contentNode, setContentNode] = React.useState(null); const mergedRef = React.useCallback( @@ -130,7 +135,7 @@ const DialogContent = React.forwardRef<
{scoped ? (
-- 2.43.0 From 074626426b5b14fa2db19279238b1549a884e0f7 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 23 Mar 2026 20:39:07 +0900 Subject: [PATCH 05/14] Enhance production plan service by adding lead time handling. Implemented checks for lead time column in item_info and adjusted scheduling logic accordingly. Updated frontend to reflect lead time in production plan management and shipping order pages, including Excel upload functionality for batch processing. --- .../src/services/productionPlanService.ts | 117 ++++++++++++++---- .../production/plan-management/page.tsx | 30 ++--- frontend/app/(main)/sales/claim/page.tsx | 78 ++++-------- .../app/(main)/sales/shipping-order/page.tsx | 19 ++- .../app/(main)/screens/[screenId]/page.tsx | 64 +++++++++- .../components/common/ExcelUploadModal.tsx | 51 +++++++- frontend/lib/api/production.ts | 1 + 7 files changed, 264 insertions(+), 96 deletions(-) diff --git a/backend-node/src/services/productionPlanService.ts b/backend-node/src/services/productionPlanService.ts index 449218e8..6b334a61 100644 --- a/backend-node/src/services/productionPlanService.ts +++ b/backend-node/src/services/productionPlanService.ts @@ -35,6 +35,33 @@ export async function getOrderSummary( const whereClause = conditions.join(" AND "); + // item_info에 lead_time 컬럼이 존재하는지 확인 + const leadTimeColCheck = await pool.query(` + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'item_info' AND column_name = 'lead_time' + ) AS has_lead_time + `); + const hasLeadTime = leadTimeColCheck.rows[0]?.has_lead_time === true; + + const itemLeadTimeCte = hasLeadTime + ? `item_lead_time AS ( + SELECT + item_number, + id AS item_id, + COALESCE(lead_time, 0) AS lead_time + FROM item_info + WHERE company_code = $1 + ),` + : `item_lead_time AS ( + SELECT + item_number, + id AS item_id, + 0 AS lead_time + FROM item_info + WHERE company_code = $1 + ),`; + const query = ` WITH order_summary AS ( SELECT @@ -49,6 +76,7 @@ export async function getOrderSummary( WHERE ${whereClause} GROUP BY so.part_code, so.part_name ), + ${itemLeadTimeCte} stock_info AS ( SELECT item_code, @@ -85,10 +113,12 @@ export async function getOrderSummary( os.total_balance_qty + COALESCE(si.safety_stock, 0) - COALESCE(si.current_stock, 0) - COALESCE(pi.existing_plan_qty, 0) - COALESCE(pi.in_progress_qty, 0), 0 - ) AS required_plan_qty + ) AS required_plan_qty, + COALESCE(ilt.lead_time, 0) AS lead_time FROM order_summary os LEFT JOIN stock_info si ON os.item_code = si.item_code LEFT JOIN plan_info pi ON os.item_code = pi.item_code + LEFT JOIN item_lead_time ilt ON (os.item_code = ilt.item_number OR os.item_code = ilt.item_id) ${options?.excludePlanned ? "WHERE COALESCE(pi.existing_plan_qty, 0) = 0" : ""} ORDER BY os.item_code; `; @@ -367,6 +397,7 @@ export async function previewSchedule( } const dailyCapacity = item.daily_capacity || 800; + const itemLeadTime = item.lead_time || 0; let requiredQty = item.required_qty; @@ -381,20 +412,32 @@ export async function previewSchedule( if (requiredQty <= 0) continue; - const productionDays = Math.ceil(requiredQty / dailyCapacity); - + // 리드타임 기반 날짜 계산: 납기일 기준으로 리드타임만큼 역산 const dueDate = new Date(item.earliest_due_date); - const endDate = new Date(dueDate); - endDate.setDate(endDate.getDate() - safetyLeadTime); - const startDate = new Date(endDate); - startDate.setDate(startDate.getDate() - productionDays); + let startDate: Date; + let endDate: Date; + + if (itemLeadTime > 0) { + // 리드타임이 있으면: 종료일 = 납기일, 시작일 = 납기일 - 리드타임 + endDate = new Date(dueDate); + startDate = new Date(dueDate); + startDate.setDate(startDate.getDate() - itemLeadTime); + } else { + // 리드타임이 없으면 기존 로직 (생산능력 기반) + const productionDays = Math.ceil(requiredQty / dailyCapacity); + endDate = new Date(dueDate); + endDate.setDate(endDate.getDate() - safetyLeadTime); + startDate = new Date(endDate); + startDate.setDate(startDate.getDate() - productionDays); + } const today = new Date(); today.setHours(0, 0, 0, 0); if (startDate < today) { + const duration = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)); startDate.setTime(today.getTime()); endDate.setTime(startDate.getTime()); - endDate.setDate(endDate.getDate() + productionDays); + endDate.setDate(endDate.getDate() + duration); } // 해당 품목의 수주 건수 확인 @@ -411,10 +454,11 @@ export async function previewSchedule( required_qty: requiredQty, daily_capacity: dailyCapacity, hourly_capacity: item.hourly_capacity || 100, - production_days: productionDays, + production_days: itemLeadTime > 0 ? itemLeadTime : Math.ceil(requiredQty / dailyCapacity), start_date: startDate.toISOString().split("T")[0], end_date: endDate.toISOString().split("T")[0], due_date: item.earliest_due_date, + lead_time: itemLeadTime, order_count: orderCount, status: "planned", }); @@ -490,25 +534,37 @@ export async function generateSchedule( // 필요 수량 계산 (삭제된 planned 수량을 복원) const dailyCapacity = item.daily_capacity || 800; + const itemLeadTime = item.lead_time || 0; let requiredQty = item.required_qty + deletedQtyForItem; if (requiredQty <= 0) continue; - const productionDays = Math.ceil(requiredQty / dailyCapacity); - - // 시작일 = 납기일 - 생산일수 - 안전리드타임 + // 리드타임 기반 날짜 계산: 납기일 기준으로 리드타임만큼 역산 const dueDate = new Date(item.earliest_due_date); - const endDate = new Date(dueDate); - endDate.setDate(endDate.getDate() - safetyLeadTime); - const startDate = new Date(endDate); - startDate.setDate(startDate.getDate() - productionDays); + let startDate: Date; + let endDate: Date; + + if (itemLeadTime > 0) { + // 리드타임이 있으면: 종료일 = 납기일, 시작일 = 납기일 - 리드타임 + endDate = new Date(dueDate); + startDate = new Date(dueDate); + startDate.setDate(startDate.getDate() - itemLeadTime); + } else { + // 리드타임이 없으면 기존 로직 (생산능력 기반) + const productionDays = Math.ceil(requiredQty / dailyCapacity); + endDate = new Date(dueDate); + endDate.setDate(endDate.getDate() - safetyLeadTime); + startDate = new Date(endDate); + startDate.setDate(startDate.getDate() - productionDays); + } // 시작일이 오늘보다 이전이면 오늘로 조정 const today = new Date(); today.setHours(0, 0, 0, 0); if (startDate < today) { + const duration = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)); startDate.setTime(today.getTime()); endDate.setTime(startDate.getTime()); - endDate.setDate(endDate.getDate() + productionDays); + endDate.setDate(endDate.getDate() + duration); } // 계획번호 생성 (YYYYMMDD-NNNN 형식) @@ -675,13 +731,24 @@ async function getBomChildItems( companyCode: string, itemCode: string ) { + // item_info에 lead_time 컬럼 존재 여부 확인 + const colCheck = await client.query(` + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'item_info' AND column_name = 'lead_time' + ) AS has_lead_time + `); + const hasLeadTime = colCheck.rows[0]?.has_lead_time === true; + const leadTimeCol = hasLeadTime ? "COALESCE(ii.lead_time, 0)" : "0"; + const bomQuery = ` SELECT bd.child_item_id, ii.item_name AS child_item_name, ii.item_number AS child_item_code, bd.quantity AS bom_qty, - bd.unit + bd.unit, + ${leadTimeCol} AS child_lead_time FROM bom b JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code LEFT JOIN item_info ii ON bd.child_item_id = ii.id AND bd.company_code = ii.company_code @@ -740,9 +807,12 @@ export async function previewSemiSchedule( if (requiredQty <= 0) continue; + // 반제품: 완제품 시작일 기준으로 해당 반제품의 리드타임만큼 역산 + const childLeadTime = parseInt(bomItem.child_lead_time) || 1; const semiDueDate = plan.start_date; + const semiEndDate = new Date(plan.start_date); const semiStartDate = new Date(plan.start_date); - semiStartDate.setDate(semiStartDate.getDate() - (parseInt(plan.lead_time) || 1)); + semiStartDate.setDate(semiStartDate.getDate() - childLeadTime); previews.push({ parent_plan_id: plan.id, @@ -752,13 +822,14 @@ export async function previewSemiSchedule( item_name: bomItem.child_item_name || bomItem.child_item_id, plan_qty: requiredQty, bom_qty: parseFloat(bomItem.bom_qty) || 1, + lead_time: childLeadTime, start_date: semiStartDate.toISOString().split("T")[0], end_date: typeof semiDueDate === "string" ? semiDueDate.split("T")[0] - : new Date(semiDueDate).toISOString().split("T")[0], + : semiEndDate.toISOString().split("T")[0], due_date: typeof semiDueDate === "string" ? semiDueDate.split("T")[0] - : new Date(semiDueDate).toISOString().split("T")[0], + : semiEndDate.toISOString().split("T")[0], product_type: "반제품", status: "planned", }); @@ -839,10 +910,12 @@ export async function generateSemiSchedule( if (requiredQty <= 0) continue; + // 반제품: 완제품 시작일 기준으로 해당 반제품의 리드타임만큼 역산 + const childLeadTime = parseInt(bomItem.child_lead_time) || 1; const semiDueDate = plan.start_date; const semiEndDate = plan.start_date; const semiStartDate = new Date(plan.start_date); - semiStartDate.setDate(semiStartDate.getDate() - (parseInt(plan.lead_time) || 1)); + semiStartDate.setDate(semiStartDate.getDate() - childLeadTime); // plan_no 생성 (PP-YYYYMMDD-SXXX 형식, S = 반제품) const planNoResult = await client.query( diff --git a/frontend/app/(main)/production/plan-management/page.tsx b/frontend/app/(main)/production/plan-management/page.tsx index a5b66640..6e1f70b3 100644 --- a/frontend/app/(main)/production/plan-management/page.tsx +++ b/frontend/app/(main)/production/plan-management/page.tsx @@ -109,7 +109,7 @@ export default function ProductionPlanManagementPage() { const [searchEndDate, setSearchEndDate] = useState(""); // 타임라인 옵션 - const [safetyLeadTime, setSafetyLeadTime] = useState(1); + // 리드타임은 품목정보(item_info)에서 관리 const [displayWeeks, setDisplayWeeks] = useState(4); const [recalculateUnstarted, setRecalculateUnstarted] = useState(true); @@ -333,6 +333,7 @@ export default function ProductionPlanManagementPage() { 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); @@ -340,7 +341,7 @@ export default function ProductionPlanManagementPage() { const req: GenerateScheduleRequest = { items, options: { - safety_lead_time: safetyLeadTime, + safety_lead_time: 0, recalculate_unstarted: recalculateUnstarted, product_type: "완제품", }, @@ -357,7 +358,7 @@ export default function ProductionPlanManagementPage() { } finally { setGenerating(false); } - }, [selectedItemGroups, orderItems, safetyLeadTime, recalculateUnstarted]); + }, [selectedItemGroups, orderItems, recalculateUnstarted]); const handleApplySchedule = useCallback(async () => { if (selectedItemGroups.size === 0) return; @@ -369,6 +370,7 @@ export default function ProductionPlanManagementPage() { 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); @@ -376,7 +378,7 @@ export default function ProductionPlanManagementPage() { const res = await generateSchedule({ items, options: { - safety_lead_time: safetyLeadTime, + safety_lead_time: 0, recalculate_unstarted: recalculateUnstarted, product_type: "완제품", }, @@ -393,7 +395,7 @@ export default function ProductionPlanManagementPage() { } finally { setGenerating(false); } - }, [selectedItemGroups, orderItems, safetyLeadTime, recalculateUnstarted, fetchPlans, fetchOrderSummary]); + }, [selectedItemGroups, orderItems, recalculateUnstarted, fetchPlans, fetchOrderSummary]); const handleClearTimeline = useCallback(async () => { if (finishedPlans.length === 0) { @@ -519,7 +521,7 @@ export default function ProductionPlanManagementPage() { const res = await generateSchedule({ items, options: { - safety_lead_time: safetyLeadTime, + safety_lead_time: 0, recalculate_unstarted: importMode === "new" ? false : recalculateUnstarted, product_type: "완제품", }, @@ -535,7 +537,7 @@ export default function ProductionPlanManagementPage() { } finally { setGenerating(false); } - }, [selectedItemGroups, orderItems, safetyLeadTime, importMode, recalculateUnstarted, fetchPlans, fetchOrderSummary]); + }, [selectedItemGroups, orderItems, importMode, recalculateUnstarted, fetchPlans, fetchOrderSummary]); const handleImportStockItems = useCallback(async () => { if (selectedStockItems.size === 0) { @@ -556,7 +558,7 @@ export default function ProductionPlanManagementPage() { const res = await generateSchedule({ items, options: { - safety_lead_time: safetyLeadTime, + safety_lead_time: 0, recalculate_unstarted: importMode === "new" ? false : true, product_type: "완제품", }, @@ -572,7 +574,7 @@ export default function ProductionPlanManagementPage() { } finally { setGenerating(false); } - }, [selectedStockItems, stockItems, safetyLeadTime, importMode, fetchPlans, fetchStockShortage]); + }, [selectedStockItems, stockItems, importMode, fetchPlans, fetchStockShortage]); // 숫자 포맷 const formatNumber = (num: number | string) => Number(num).toLocaleString(); @@ -822,6 +824,7 @@ export default function ProductionPlanManagementPage() { 기생산계획량 생산진행 필요생산계획 + 리드타임(일) @@ -846,6 +849,9 @@ export default function ProductionPlanManagementPage() { 0 ? "text-destructive" : "text-emerald-600 dark:text-emerald-400")} onClick={() => toggleItemExpand(item.item_code)}> {formatNumber(item.required_plan_qty)} + toggleItemExpand(item.item_code)}> + {Number(item.lead_time) > 0 ? `${item.lead_time}일` : "-"} + {expandedItems.has(item.item_code) && item.orders?.map((detail) => ( @@ -866,7 +872,7 @@ export default function ProductionPlanManagementPage() { {formatNumber(detail.order_qty)} {formatNumber(detail.ship_qty)} {formatNumber(detail.balance_qty)} - + 납기일: {detail.due_date || "-"} @@ -999,10 +1005,6 @@ export default function ProductionPlanManagementPage() {
-
- - 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} /> diff --git a/frontend/app/(main)/sales/claim/page.tsx b/frontend/app/(main)/sales/claim/page.tsx index 12d37472..333e8fc6 100644 --- a/frontend/app/(main)/sales/claim/page.tsx +++ b/frontend/app/(main)/sales/claim/page.tsx @@ -62,9 +62,11 @@ import { Check, ChevronsUpDown, Loader2, + FileSpreadsheet, } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; +import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; // --- Types --- type ClaimType = "불량" | "교환" | "반품" | "배송지연" | "기타"; @@ -94,57 +96,7 @@ interface SalesOrderOption { status: string; } -// --- Sample Data --- -const initialData: Claim[] = [ - { - claimNo: "CLM-2025-004", - claimDate: "2025-11-09", - claimType: "불량", - claimStatus: "접수", - customerCode: "CUST-0001", - customerName: "주식회사 코아스포트", - managerName: "김철수", - orderNo: "SO-2025-0102", - claimContent: "제품 표면에 스크래치가 발견되었습니다.", - processContent: "", - }, - { - claimNo: "CLM-2025-001", - claimDate: "2025-01-05", - claimType: "불량", - claimStatus: "접수", - customerCode: "CUST-0002", - customerName: "(주)현상산업", - managerName: "김철수", - orderNo: "SO-2025-0102", - claimContent: "제품 불량", - processContent: "", - }, - { - claimNo: "CLM-2025-002", - claimDate: "2025-01-04", - claimType: "교환", - claimStatus: "처리중", - customerCode: "CUST-0003", - customerName: "대한전섬", - managerName: "이영희", - orderNo: "SO-2025-0095", - claimContent: "규격 불일치", - processContent: "교환 진행 중", - }, - { - claimNo: "CLM-2025-003", - claimDate: "2025-01-03", - claimType: "반품", - claimStatus: "완료", - customerCode: "CUST-0004", - customerName: "삼성전자", - managerName: "박민수", - orderNo: "SO-2024-1285", - claimContent: "수량 초과 납품", - processContent: "반품 완료", - }, -]; +const initialData: Claim[] = []; const getClaimTypeStyle = (type: ClaimType) => { switch (type) { @@ -193,6 +145,9 @@ export default function ClaimManagementPage() { const [searchCustomer, setSearchCustomer] = useState(""); const [searchClaimNo, setSearchClaimNo] = useState(""); + // 엑셀 업로드 + const [excelUploadOpen, setExcelUploadOpen] = useState(false); + // 모달 상태 const [isModalOpen, setIsModalOpen] = useState(false); const [isEditMode, setIsEditMode] = useState(false); @@ -563,9 +518,14 @@ export default function ClaimManagementPage() { {filteredData.length}건
- +
+ + +
@@ -1122,6 +1082,16 @@ export default function ClaimManagementPage() { + + {/* 엑셀 업로드 모달 */} + { + // TODO: 클레임 테이블 API 연동 후 데이터 새로고침 + }} + />
); } diff --git a/frontend/app/(main)/sales/shipping-order/page.tsx b/frontend/app/(main)/sales/shipping-order/page.tsx index ff05ffac..8b2c0dea 100644 --- a/frontend/app/(main)/sales/shipping-order/page.tsx +++ b/frontend/app/(main)/sales/shipping-order/page.tsx @@ -12,7 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogD import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; -import { Plus, Trash2, RotateCcw, Save, X, ChevronDown, ChevronRight, ChevronLeft, Truck, Search, Loader2 } from "lucide-react"; +import { Plus, Trash2, RotateCcw, Save, X, ChevronDown, ChevronRight, ChevronLeft, Truck, Search, Loader2, FileSpreadsheet } from "lucide-react"; import { cn } from "@/lib/utils"; import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; import { @@ -24,6 +24,7 @@ import { getSalesOrderSource, getItemSource, } from "@/lib/api/shipping"; +import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; type DataSourceType = "shipmentPlan" | "salesOrder" | "itemInfo"; @@ -84,6 +85,9 @@ export default function ShippingOrderPage() { const [searchDateFrom, setSearchDateFrom] = useState(""); const [searchDateTo, setSearchDateTo] = useState(""); + // 엑셀 업로드 + const [excelUploadOpen, setExcelUploadOpen] = useState(false); + // 모달 const [isModalOpen, setIsModalOpen] = useState(false); const [isEditMode, setIsEditMode] = useState(false); @@ -467,6 +471,9 @@ export default function ShippingOrderPage() { {orders.length}건
+ @@ -821,6 +828,16 @@ export default function ShippingOrderPage() { + + {/* 엑셀 업로드 모달 */} + { + fetchOrders(); + }} + />
); } diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index efcef2a4..706703da 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -26,6 +26,9 @@ import { useScheduleGenerator, ScheduleConfirmDialog } from "@/lib/v2-core/servi import { ResponsiveGridRenderer } from "@/components/screen/ResponsiveGridRenderer"; import { useTabId } from "@/contexts/TabIdContext"; import { useTabStore } from "@/stores/tabStore"; +import { FileSpreadsheet, Loader2 as ExcelLoader } from "lucide-react"; +import { MultiTableExcelUploadModal } from "@/components/common/MultiTableExcelUploadModal"; +import { autoDetectMultiTableConfig, TableChainConfig } from "@/lib/api/multiTableExcel"; export interface ScreenViewPageProps { screenIdProp?: number; @@ -96,6 +99,11 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = { // 데이터 전달에 의해 강제 활성화된 레이어 ID 목록 const [forceActivatedLayerIds, setForceActivatedLayerIds] = useState([]); + // 엑셀 업로드 모달 상태 + const [excelUploadOpen, setExcelUploadOpen] = useState(false); + const [excelChainConfig, setExcelChainConfig] = useState(null); + const [excelDetecting, setExcelDetecting] = useState(false); + // 편집 모달 상태 const [editModalOpen, setEditModalOpen] = useState(false); const [editModalConfig, setEditModalConfig] = useState<{ @@ -650,8 +658,46 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
+ {/* 엑셀 업로드 버튼 (테이블이 있는 화면에서만 표시) */} + {!isPreviewMode && screen?.tableName && ( +
+ +
+ )} + {/* 레이아웃 준비 중 로딩 표시 */} {!layoutReady && (
@@ -801,6 +847,22 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = { }} /> + {/* 엑셀 업로드 모달 (멀티테이블 자동감지) */} + {excelChainConfig && ( + { + setExcelUploadOpen(open); + if (!open) setExcelChainConfig(null); + }} + config={excelChainConfig} + onSuccess={() => { + window.dispatchEvent(new CustomEvent("refreshTable")); + setTableRefreshKey((prev) => prev + 1); + }} + /> + )} + {/* 스케줄 생성 확인 다이얼로그 */} = ({ return true; }; + // 템플릿 다운로드: 테이블 스키마 기반으로 빈 엑셀 파일 생성 + const handleDownloadTemplate = async () => { + try { + const { exportToExcel } = await import("@/lib/utils/excelExport"); + const response = await getTableSchema(tableName); + if (!response.success || !response.data) { + toast.error("테이블 정보를 가져올 수 없습니다."); + return; + } + + const AUTO_COLS = ["id", "created_date", "updated_date", "writer", "company_code"]; + const columns = response.data.columns.filter( + (col) => !AUTO_COLS.includes(col.name.toLowerCase()) + ); + + // 필수 컬럼에 * 표시 + const headerRow: Record = {}; + for (const col of columns) { + const label = col.label || col.name; + const isRequired = !col.nullable; + headerRow[isRequired ? `${label} *` : label] = ""; + } + + await exportToExcel([headerRow], `${tableName}_템플릿.xlsx`, "Sheet1"); + toast.success("템플릿 파일이 다운로드되었습니다."); + } catch (error) { + console.error("템플릿 다운로드 실패:", error); + toast.error("템플릿 다운로드에 실패했습니다."); + } + }; + // 다음 단계 const handleNext = async () => { if (currentStep === 1 && !file) { @@ -1607,11 +1638,23 @@ export const ExcelUploadModal: React.FC = ({
)} - {/* 파일 선택 영역 */} + {/* 템플릿 다운로드 + 파일 선택 영역 */}
- +
+ + +
Date: Mon, 23 Mar 2026 22:32:49 +0900 Subject: [PATCH 06/14] Add item information and sales order pages with dynamic search filter component - Introduced new pages for item information and sales orders, enhancing the master data management capabilities. - Implemented a dynamic search filter component to allow users to customize their search criteria, including text, select, and date filters. - Integrated category loading for filter options and ensured real-time filtering functionality. - Enhanced user experience with modals for item registration and editing, along with Excel upload capabilities for batch processing. --- .../app/(main)/master-data/item-info/page.tsx | 505 +++++++++++ frontend/app/(main)/sales/order/page.tsx | 802 ++++++++++++++++++ .../components/common/DynamicSearchFilter.tsx | 460 ++++++++++ 3 files changed, 1767 insertions(+) create mode 100644 frontend/app/(main)/master-data/item-info/page.tsx create mode 100644 frontend/app/(main)/sales/order/page.tsx create mode 100644 frontend/components/common/DynamicSearchFilter.tsx diff --git a/frontend/app/(main)/master-data/item-info/page.tsx b/frontend/app/(main)/master-data/item-info/page.tsx new file mode 100644 index 00000000..b4be52f0 --- /dev/null +++ b/frontend/app/(main)/master-data/item-info/page.tsx @@ -0,0 +1,505 @@ +"use client"; + +import React, { useState, useEffect, useCallback } 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 { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Plus, Trash2, RotateCcw, Save, Search, Loader2, FileSpreadsheet, Download, + Package, Pencil, Copy, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { apiClient } from "@/lib/api/client"; +import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; +import { exportToExcel } from "@/lib/utils/excelExport"; +import { useAuth } from "@/hooks/useAuth"; +import { toast } from "sonner"; + +// 테이블 컬럼 정의 +const TABLE_COLUMNS = [ + { key: "item_number", label: "품목코드", width: "w-[120px]" }, + { key: "item_name", label: "품명", width: "min-w-[150px]" }, + { key: "division", label: "관리품목", width: "w-[100px]" }, + { key: "type", label: "품목구분", width: "w-[100px]" }, + { key: "size", label: "규격", width: "w-[100px]" }, + { key: "unit", label: "단위", width: "w-[80px]" }, + { key: "material", label: "재질", width: "w-[100px]" }, + { key: "status", label: "상태", width: "w-[80px]" }, + { key: "selling_price", label: "판매가격", width: "w-[100px]" }, + { key: "standard_price", label: "기준단가", width: "w-[100px]" }, + { key: "weight", label: "중량", width: "w-[80px]" }, + { key: "inventory_unit", label: "재고단위", width: "w-[80px]" }, + { key: "user_type01", label: "대분류", width: "w-[100px]" }, + { key: "user_type02", label: "중분류", width: "w-[100px]" }, +]; + +// 등록 모달 필드 정의 +const FORM_FIELDS = [ + { key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" }, + { key: "item_name", label: "품명", type: "text", required: true }, + { key: "division", label: "관리품목", type: "category" }, + { key: "type", label: "품목구분", type: "category" }, + { key: "size", label: "규격", type: "text" }, + { key: "unit", label: "단위", type: "category" }, + { key: "material", label: "재질", type: "category" }, + { key: "status", label: "상태", type: "category" }, + { key: "weight", label: "중량", type: "text" }, + { key: "volum", label: "부피", type: "text" }, + { key: "specific_gravity", label: "비중", type: "text" }, + { key: "inventory_unit", label: "재고단위", type: "category" }, + { key: "selling_price", label: "판매가격", type: "text" }, + { key: "standard_price", label: "기준단가", type: "text" }, + { key: "currency_code", label: "통화", type: "category" }, + { key: "user_type01", label: "대분류", type: "category" }, + { key: "user_type02", label: "중분류", type: "category" }, + { key: "meno", label: "메모", type: "textarea" }, +]; + +const TABLE_NAME = "item_info"; + +export default function ItemInfoPage() { + const { user } = useAuth(); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [totalCount, setTotalCount] = useState(0); + + // 검색 + const [searchKeyword, setSearchKeyword] = useState(""); + const [searchDivision, setSearchDivision] = useState("all"); + const [searchType, setSearchType] = useState("all"); + const [searchStatus, setSearchStatus] = useState("all"); + + // 모달 + const [isModalOpen, setIsModalOpen] = useState(false); + const [isEditMode, setIsEditMode] = useState(false); + const [editId, setEditId] = useState(null); + const [saving, setSaving] = useState(false); + const [formData, setFormData] = useState>({}); + + // 엑셀 업로드 + const [excelUploadOpen, setExcelUploadOpen] = useState(false); + + // 카테고리 옵션 (API에서 로드) + const [categoryOptions, setCategoryOptions] = useState>({}); + + // 선택된 행 + const [selectedId, setSelectedId] = useState(null); + + // 카테고리 컬럼 목록 + const CATEGORY_COLUMNS = ["division", "type", "unit", "material", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]; + + // 카테고리 옵션 로드 (table_name + column_name 기반) + useEffect(() => { + const loadCategories = async () => { + try { + const optMap: Record = {}; + const flatten = (vals: any[]): { code: string; label: string }[] => { + const result: { code: string; label: string }[] = []; + for (const v of vals) { + result.push({ code: v.valueCode, label: v.valueLabel }); + if (v.children?.length) result.push(...flatten(v.children)); + } + return result; + }; + + await Promise.all( + CATEGORY_COLUMNS.map(async (colName) => { + try { + const res = await apiClient.get(`/table-categories/${TABLE_NAME}/${colName}/values`); + if (res.data?.success && res.data.data?.length > 0) { + optMap[colName] = flatten(res.data.data); + } + } catch { /* skip */ } + }) + ); + setCategoryOptions(optMap); + } catch (err) { + console.error("카테고리 로드 실패:", err); + } + }; + loadCategories(); + }, []); + + // 데이터 조회 + const fetchItems = useCallback(async () => { + setLoading(true); + try { + const filters: any[] = []; + if (searchKeyword) { + filters.push({ columnName: "item_name", operator: "contains", value: searchKeyword }); + } + if (searchDivision !== "all") { + filters.push({ columnName: "division", operator: "equals", value: searchDivision }); + } + if (searchType !== "all") { + filters.push({ columnName: "type", operator: "equals", value: searchType }); + } + if (searchStatus !== "all") { + filters.push({ columnName: "status", operator: "equals", value: searchStatus }); + } + + const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, { + page: 1, + size: 500, + dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, + autoFilter: true, + }); + + const data = res.data?.data?.data || res.data?.data?.rows || []; + setItems(data); + setTotalCount(res.data?.data?.total || data.length); + } catch (err) { + console.error("품목 조회 실패:", err); + toast.error("품목 목록을 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }, [searchKeyword, searchDivision, searchType, searchStatus]); + + useEffect(() => { + fetchItems(); + }, [fetchItems]); + + // 카테고리 코드 → 라벨 변환 + const getCategoryLabel = (columnName: string, code: string) => { + if (!code) return ""; + const opts = categoryOptions[columnName]; + if (!opts) return code; + const found = opts.find((o) => o.code === code); + return found?.label || code; + }; + + // 등록 모달 열기 + const openRegisterModal = () => { + setFormData({}); + setIsEditMode(false); + setEditId(null); + setIsModalOpen(true); + }; + + // 수정 모달 열기 + const openEditModal = (item: any) => { + setFormData({ ...item }); + setIsEditMode(true); + setEditId(item.id); + setIsModalOpen(true); + }; + + // 복사 모달 열기 + const openCopyModal = (item: any) => { + const { id, item_number, created_date, updated_date, writer, ...rest } = item; + setFormData(rest); + setIsEditMode(false); + setEditId(null); + setIsModalOpen(true); + }; + + // 저장 + const handleSave = async () => { + if (!formData.item_name) { + toast.error("품명은 필수 입력입니다."); + return; + } + + setSaving(true); + try { + if (isEditMode && editId) { + // 수정 + const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData; + await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, { + originalData: { id: editId }, + updatedData: updateFields, + }); + toast.success("수정되었습니다."); + } else { + // 등록 + const { id, created_date, updated_date, ...insertFields } = formData; + await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, insertFields); + toast.success("등록되었습니다."); + } + setIsModalOpen(false); + fetchItems(); + } catch (err: any) { + console.error("저장 실패:", err); + toast.error(err.response?.data?.message || "저장에 실패했습니다."); + } finally { + setSaving(false); + } + }; + + // 삭제 + const handleDelete = async () => { + if (!selectedId) { + toast.error("삭제할 품목을 선택해주세요."); + return; + } + if (!confirm("선택한 품목을 삭제하시겠습니까?")) return; + + try { + await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { + data: [{ id: selectedId }], + }); + toast.success("삭제되었습니다."); + setSelectedId(null); + fetchItems(); + } catch (err) { + console.error("삭제 실패:", err); + toast.error("삭제에 실패했습니다."); + } + }; + + // 엑셀 다운로드 + const handleExcelDownload = async () => { + if (items.length === 0) { + toast.error("다운로드할 데이터가 없습니다."); + return; + } + const exportData = items.map((item) => { + const row: Record = {}; + for (const col of TABLE_COLUMNS) { + row[col.label] = getCategoryLabel(col.key, item[col.key]) || item[col.key] || ""; + } + return row; + }); + await exportToExcel(exportData, "품목정보.xlsx", "품목정보"); + toast.success("엑셀 다운로드 완료"); + }; + + // 검색 초기화 + const handleResetSearch = () => { + setSearchKeyword(""); + setSearchDivision("all"); + setSearchType("all"); + setSearchStatus("all"); + }; + + // 카테고리 셀렉트 렌더링 + const renderCategorySelect = (field: typeof FORM_FIELDS[0]) => { + const options = categoryOptions[field.key] || []; + return ( + + ); + }; + + return ( +
+ {/* 검색 */} + + +
+ + setSearchKeyword(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && fetchItems()} + /> +
+
+ + +
+
+ + +
+
+ + +
+
+
+ {loading && } + +
+ + + + {/* 메인 테이블 */} +
+
+
+ 품목 목록 + {totalCount}건 +
+
+ + + + + + +
+
+ +
+ {loading ? ( +
+ +
+ ) : items.length === 0 ? ( +
+ + 등록된 품목이 없습니다 +
+ ) : ( +
+ + + No + {TABLE_COLUMNS.map((col) => ( + {col.label} + ))} + + + + {items.map((item, idx) => ( + setSelectedId(item.id)} + onDoubleClick={() => openEditModal(item)} + > + {idx + 1} + {TABLE_COLUMNS.map((col) => ( + + {["division", "type", "unit", "material", "status", "inventory_unit", "user_type01", "user_type02", "currency_code"].includes(col.key) + ? getCategoryLabel(col.key, item[col.key]) + : item[col.key] || ""} + + ))} + + ))} + +
+ )} +
+
+ + {/* 등록/수정 모달 */} + + + + {isEditMode ? "품목 수정" : "품목 등록"} + + {isEditMode ? "품목 정보를 수정합니다." : "새로운 품목을 등록합니다."} + + + +
+ {FORM_FIELDS.map((field) => ( +
+ + {field.type === "category" ? ( + renderCategorySelect(field) + ) : field.type === "textarea" ? ( +