From b2c96e616a4a81c0f66a460c467e52d7873f020a Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 19 May 2026 18:09:57 +0900 Subject: [PATCH] Add Batch Process Equipment Registration Functionality - Implemented a new endpoint for batch registration of process equipment, allowing users to add multiple equipment codes at once while skipping duplicates. - Enhanced error handling to provide detailed feedback on the registration process, including the number of successfully inserted and skipped items. - Updated the process info routes to include the new batch registration functionality. (TASK: ERP-node-087) --- .../src/controllers/processInfoController.ts | 41 +++ backend-node/src/routes/processInfoRoutes.ts | 1 + .../production/plan-management/page.tsx | 5 +- .../production/plan-management/page.tsx | 5 +- .../production/plan-management/page.tsx | 5 +- .../process-info/ProcessMasterTab.tsx | 152 +++++++--- .../production/plan-management/page.tsx | 5 +- .../common/MultiTableExcelUploadModal.tsx | 32 ++- .../components/common/TimelineScheduler.tsx | 268 +++++++++++------- frontend/lib/api/multiTableExcel.ts | 8 +- frontend/lib/api/processInfo.ts | 12 + 11 files changed, 384 insertions(+), 150 deletions(-) diff --git a/backend-node/src/controllers/processInfoController.ts b/backend-node/src/controllers/processInfoController.ts index b1ff67f2..12e44106 100644 --- a/backend-node/src/controllers/processInfoController.ts +++ b/backend-node/src/controllers/processInfoController.ts @@ -200,6 +200,47 @@ export async function addProcessEquipment(req: AuthenticatedRequest, res: Respon } } +// 다중 설비 일괄 등록 (TASK:ERP-node-087). 중복은 건너뛰고 신규만 INSERT. +export async function addProcessEquipmentBatch(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const writer = req.user!.userId; + const { process_code } = req.body; + const codes: string[] = Array.isArray(req.body?.equipment_codes) + ? Array.from(new Set(req.body.equipment_codes.filter(Boolean))) + : []; + if (!process_code || codes.length === 0) { + return res.status(400).json({ success: false, message: "공정코드와 설비를 선택해주세요." }); + } + + const dup = await pool.query( + `SELECT equipment_code FROM process_equipment + WHERE process_code=$1 AND company_code=$2 AND equipment_code = ANY($3::text[])`, + [process_code, companyCode, codes] + ); + const exists = new Set(dup.rows.map((r: any) => r.equipment_code)); + const toInsert = codes.filter((c) => !exists.has(c)); + + let inserted = 0; + for (const code of toInsert) { + await pool.query( + `INSERT INTO process_equipment (id, company_code, process_code, equipment_code, writer) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4)`, + [companyCode, process_code, code, writer] + ); + inserted++; + } + + return res.json({ + success: true, + data: { inserted, skipped: codes.length - inserted, total: codes.length }, + }); + } catch (error: any) { + logger.error("공정 설비 일괄 등록 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + export async function removeProcessEquipment(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; diff --git a/backend-node/src/routes/processInfoRoutes.ts b/backend-node/src/routes/processInfoRoutes.ts index 3507707e..82d00054 100644 --- a/backend-node/src/routes/processInfoRoutes.ts +++ b/backend-node/src/routes/processInfoRoutes.ts @@ -19,6 +19,7 @@ router.post("/processes/delete", ctrl.deleteProcesses); // 공정별 설비 관리 router.get("/processes/:processCode/equipments", ctrl.getProcessEquipments); router.post("/process-equipments", ctrl.addProcessEquipment); +router.post("/process-equipments/batch", ctrl.addProcessEquipmentBatch); router.delete("/process-equipments/:id", ctrl.removeProcessEquipment); // 설비 목록 (드롭다운용) diff --git a/frontend/app/(main)/COMPANY_10/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_10/production/plan-management/page.tsx index 8fde0ca5..1c3bd423 100644 --- a/frontend/app/(main)/COMPANY_10/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_10/production/plan-management/page.tsx @@ -345,7 +345,7 @@ export default function ProductionPlanManagementPage() { // 검색 state 변경 시 자동 재조회 (필터 비어있어도 default 기간으로 재조회) fetchOrderSummary(); fetchPlans(); - }, [searchItemCode, searchStatus, searchStartDate, searchEndDate]); // eslint-disable-line react-hooks/exhaustive-deps + }, [searchItemCode, searchStatus, searchStartDate, searchEndDate, filterUnplannedOrdersOnly]); // eslint-disable-line react-hooks/exhaustive-deps // ========== 토글/선택 핸들러 ========== @@ -1208,8 +1208,9 @@ export default function ProductionPlanManagementPage() { { + // 상태만 변경 → 자동 재조회 effect가 갱신된 값으로 다시 조회 + // (이전 setTimeout 즉시호출은 갱신 전 값을 캡처하는 stale closure 버그였음) setFilterUnplannedOrdersOnly(!!c); - setTimeout(fetchOrderSummary, 0); }} className="h-4 w-4" /> diff --git a/frontend/app/(main)/COMPANY_16/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_16/production/plan-management/page.tsx index 8fde0ca5..1c3bd423 100644 --- a/frontend/app/(main)/COMPANY_16/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_16/production/plan-management/page.tsx @@ -345,7 +345,7 @@ export default function ProductionPlanManagementPage() { // 검색 state 변경 시 자동 재조회 (필터 비어있어도 default 기간으로 재조회) fetchOrderSummary(); fetchPlans(); - }, [searchItemCode, searchStatus, searchStartDate, searchEndDate]); // eslint-disable-line react-hooks/exhaustive-deps + }, [searchItemCode, searchStatus, searchStartDate, searchEndDate, filterUnplannedOrdersOnly]); // eslint-disable-line react-hooks/exhaustive-deps // ========== 토글/선택 핸들러 ========== @@ -1208,8 +1208,9 @@ export default function ProductionPlanManagementPage() { { + // 상태만 변경 → 자동 재조회 effect가 갱신된 값으로 다시 조회 + // (이전 setTimeout 즉시호출은 갱신 전 값을 캡처하는 stale closure 버그였음) setFilterUnplannedOrdersOnly(!!c); - setTimeout(fetchOrderSummary, 0); }} className="h-4 w-4" /> diff --git a/frontend/app/(main)/COMPANY_7/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_7/production/plan-management/page.tsx index 8fde0ca5..1c3bd423 100644 --- a/frontend/app/(main)/COMPANY_7/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_7/production/plan-management/page.tsx @@ -345,7 +345,7 @@ export default function ProductionPlanManagementPage() { // 검색 state 변경 시 자동 재조회 (필터 비어있어도 default 기간으로 재조회) fetchOrderSummary(); fetchPlans(); - }, [searchItemCode, searchStatus, searchStartDate, searchEndDate]); // eslint-disable-line react-hooks/exhaustive-deps + }, [searchItemCode, searchStatus, searchStartDate, searchEndDate, filterUnplannedOrdersOnly]); // eslint-disable-line react-hooks/exhaustive-deps // ========== 토글/선택 핸들러 ========== @@ -1208,8 +1208,9 @@ export default function ProductionPlanManagementPage() { { + // 상태만 변경 → 자동 재조회 effect가 갱신된 값으로 다시 조회 + // (이전 setTimeout 즉시호출은 갱신 전 값을 캡처하는 stale closure 버그였음) setFilterUnplannedOrdersOnly(!!c); - setTimeout(fetchOrderSummary, 0); }} className="h-4 w-4" /> diff --git a/frontend/app/(main)/COMPANY_7/production/process-info/ProcessMasterTab.tsx b/frontend/app/(main)/COMPANY_7/production/process-info/ProcessMasterTab.tsx index f0f51cb6..7b7fcd1b 100644 --- a/frontend/app/(main)/COMPANY_7/production/process-info/ProcessMasterTab.tsx +++ b/frontend/app/(main)/COMPANY_7/production/process-info/ProcessMasterTab.tsx @@ -47,14 +47,13 @@ import { } from "@/components/ui/table"; import { Checkbox } from "@/components/ui/checkbox"; import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; -import { SmartSelect } from "@/components/common/SmartSelect"; import { getProcessList, createProcess, updateProcess, deleteProcesses, getProcessEquipments, - addProcessEquipment, + addProcessEquipmentBatch, removeProcessEquipment, getEquipmentList, type ProcessMaster, @@ -82,7 +81,9 @@ export function ProcessMasterTab() { const [selectedIds, setSelectedIds] = useState>(() => new Set()); const [processEquipments, setProcessEquipments] = useState([]); - const [equipmentPick, setEquipmentPick] = useState(""); + // 다중 선택 일괄 추가 (TASK:ERP-node-087) + const [equipmentPicks, setEquipmentPicks] = useState>(() => new Set()); + const [equipmentSearch, setEquipmentSearch] = useState(""); const [addingEquipment, setAddingEquipment] = useState(false); const [formOpen, setFormOpen] = useState(false); @@ -172,7 +173,9 @@ export function ProcessMasterTab() { }, [processes]); useEffect(() => { - setEquipmentPick(""); + // 공정 전환 시 다중선택/검색 초기화 + setEquipmentPicks(new Set()); + setEquipmentSearch(""); }, [selectedProcess?.id]); useEffect(() => { @@ -335,29 +338,57 @@ export function ProcessMasterTab() { }); }, [equipmentMaster, processEquipments]); - const handleAddEquipment = async () => { + // 검색어로 거른 추가 가능 설비 목록 + const filteredAvailableEquipments = useMemo(() => { + const q = equipmentSearch.trim().toLowerCase(); + if (!q) return availableEquipments; + return availableEquipments.filter( + (e) => + (e.equipment_name || "").toLowerCase().includes(q) || + (e.equipment_code || "").toLowerCase().includes(q) + ); + }, [availableEquipments, equipmentSearch]); + + const toggleEquipmentPick = (id: string) => { + setEquipmentPicks((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const handleAddEquipments = async () => { if (!selectedProcess) return; - if (!equipmentPick) { + if (equipmentPicks.size === 0) { toast.message("추가할 설비를 선택해주세요"); return; } - const picked = availableEquipments.find((e) => e.id === equipmentPick); - if (!picked) { + const codes = availableEquipments + .filter((e) => equipmentPicks.has(e.id)) + .map((e) => e.equipment_code || e.id); + if (codes.length === 0) { toast.error("선택한 설비를 찾을 수 없어요"); return; } setAddingEquipment(true); try { - const res = await addProcessEquipment({ + const res = await addProcessEquipmentBatch({ process_code: selectedProcess.process_code, - equipment_code: picked.equipment_code || picked.id, + equipment_codes: codes, }); if (!res.success) { toast.error(res.message || "설비 추가에 실패했어요"); return; } - toast.success("설비가 등록되었어요"); - setEquipmentPick(""); + const d = res.data; + toast.success( + d + ? `설비 ${d.inserted}개 등록${d.skipped > 0 ? ` (${d.skipped}개는 이미 등록되어 제외)` : ""}` + : "설비가 등록되었어요" + ); + setEquipmentPicks(new Set()); + setEquipmentSearch(""); const listRes = await getProcessEquipments(selectedProcess.process_code); if (listRes.success && listRes.data) setProcessEquipments(listRes.data); } finally { @@ -521,33 +552,82 @@ export function ProcessMasterTab() { ) : (
-
-
- - ({ - code: eq.id, - label: eq.equipment_name, - }))} - value={equipmentPick || ""} - onValueChange={setEquipmentPick} - placeholder="설비를 선택해주세요" - disabled={addingEquipment || availableEquipments.length === 0} - /> +
+
+ +
- +
diff --git a/frontend/app/(main)/COMPANY_8/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_8/production/plan-management/page.tsx index 8fde0ca5..1c3bd423 100644 --- a/frontend/app/(main)/COMPANY_8/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_8/production/plan-management/page.tsx @@ -345,7 +345,7 @@ export default function ProductionPlanManagementPage() { // 검색 state 변경 시 자동 재조회 (필터 비어있어도 default 기간으로 재조회) fetchOrderSummary(); fetchPlans(); - }, [searchItemCode, searchStatus, searchStartDate, searchEndDate]); // eslint-disable-line react-hooks/exhaustive-deps + }, [searchItemCode, searchStatus, searchStartDate, searchEndDate, filterUnplannedOrdersOnly]); // eslint-disable-line react-hooks/exhaustive-deps // ========== 토글/선택 핸들러 ========== @@ -1208,8 +1208,9 @@ export default function ProductionPlanManagementPage() { { + // 상태만 변경 → 자동 재조회 effect가 갱신된 값으로 다시 조회 + // (이전 setTimeout 즉시호출은 갱신 전 값을 캡처하는 stale closure 버그였음) setFilterUnplannedOrdersOnly(!!c); - setTimeout(fetchOrderSummary, 0); }} className="h-4 w-4" /> diff --git a/frontend/components/common/MultiTableExcelUploadModal.tsx b/frontend/components/common/MultiTableExcelUploadModal.tsx index 9d58eb0e..5b50aac9 100644 --- a/frontend/components/common/MultiTableExcelUploadModal.tsx +++ b/frontend/components/common/MultiTableExcelUploadModal.tsx @@ -359,22 +359,36 @@ export const MultiTableExcelUploadModal: React.FC 0 ? ` (오류: ${errors.length}건)` : ""; - - toast.success(`업로드 완료: ${msg}${errorMsg}`); if (errors.length > 0) { + // 일부 행 실패 — 실제 원인을 화면에 노출(이전엔 console 에만 남겨 진단 불가) console.warn("업로드 오류 목록:", errors); + toast.error( + `${msg ? `일부 반영(${msg}) / ` : ""}오류 ${errors.length}건 — ${errors.slice(0, 2).join(" | ")}${errors.length > 2 ? " …" : ""}`, + { duration: 12000 } + ); + onSuccess?.(); + // 실패 행이 있으면 사용자가 원인을 볼 수 있게 모달 유지 + } else { + toast.success(`업로드 완료: ${msg}`); + onSuccess?.(); + onOpenChange(false); } - - onSuccess?.(); - onOpenChange(false); } else { - toast.error(result.message || "업로드에 실패했습니다."); + // 실패: 백엔드가 담아준 실제 원인(data.errors / error)을 표면화 + const detail = + (result.data?.errors && result.data.errors.length > 0 + ? result.data.errors.slice(0, 3).join(" | ") + : "") || result.error || ""; + console.error("다중 테이블 업로드 실패:", { message: result.message, detail, raw: result }); + toast.error( + `업로드 실패: ${detail || result.message || "원인 미상"}`, + { duration: 12000 } + ); } - } catch (error) { + } catch (error: any) { console.error("다중 테이블 업로드 실패:", error); - toast.error("업로드 중 오류가 발생했습니다."); + toast.error(`업로드 중 오류: ${error?.message || "알 수 없는 오류"}`, { duration: 10000 }); } finally { setIsUploading(false); } diff --git a/frontend/components/common/TimelineScheduler.tsx b/frontend/components/common/TimelineScheduler.tsx index 9814b17e..58777885 100644 --- a/frontend/components/common/TimelineScheduler.tsx +++ b/frontend/components/common/TimelineScheduler.tsx @@ -106,10 +106,13 @@ const DEFAULT_STATUS_COLORS: StatusColor[] = [ { key: "completed", label: "완료", bgClass: "from-gray-400 to-gray-500" }, ]; +// cellWidth = "1일당 픽셀(pxPerDay)". 막대/드래그/오늘선은 일 비율로 정확히 계산하고, +// 헤더 축/배경 그리드만 zoom에 따라 일·주차·월 버킷 컬럼으로 묶어 렌더한다. +// - 일: 1칸=1일, 주: 1칸=월내 N주차(최대 7일), 월: 1칸=한 달 const ZOOM_CONFIG: Record = { day: { cellWidth: 60, spanDays: 28, navStep: 7 }, - week: { cellWidth: 36, spanDays: 56, navStep: 14 }, - month: { cellWidth: 16, spanDays: 90, navStep: 30 }, + week: { cellWidth: 24, spanDays: 70, navStep: 28 }, // 주당 ≈168px, 약 10주 표시 + month: { cellWidth: 7, spanDays: 150, navStep: 60 }, // 월당 ≈210px, 약 5개월 표시 }; // ─── 유틸리티 함수 ─── @@ -214,6 +217,8 @@ export default function TimelineScheduler({ d.setHours(0, 0, 0, 0); return d; }); + // 사용자가 시작~종료를 직접 지정하면 zoom 고정 기간 대신 이 일수를 사용 (null=기본) + const [spanOverride, setSpanOverride] = useState(null); // 드래그/리사이즈 상태 const [dragState, setDragState] = useState<{ @@ -238,6 +243,11 @@ export default function TimelineScheduler({ }, [propZoom]); const config = ZOOM_CONFIG[zoom]; + const pxPerDay = config.cellWidth; // 1일당 픽셀 (막대/그리드/오늘선 공통 기준) + // 실제 표시 일수: 사용자가 시작~종료를 지정했으면 그 일수, 아니면 zoom 기본 기간 + const effectiveSpanDays = spanOverride ?? config.spanDays; + // 이전/다음 이동 폭: 사용자 지정 기간이면 그 기간만큼, 아니면 zoom 기본 navStep + const navStep = spanOverride ?? config.navStep; const today = useMemo(() => { const d = new Date(); d.setHours(0, 0, 0, 0); @@ -247,11 +257,11 @@ export default function TimelineScheduler({ // 날짜 배열 생성 const dates = useMemo(() => { const arr: Date[] = []; - for (let i = 0; i < config.spanDays; i++) { + for (let i = 0; i < effectiveSpanDays; i++) { arr.push(addDays(baseDate, i)); } return arr; - }, [baseDate, config.spanDays]); + }, [baseDate, effectiveSpanDays]); // 표시 범위 변경 시 부모에 알림 (데이터 재조회 트리거용) // 부모가 인라인 함수로 onRangeChange를 넘기는 경우(매 렌더마다 새 참조) useEffect가 @@ -261,14 +271,14 @@ export default function TimelineScheduler({ useEffect(() => { if (!onRangeChange) return; const start = toDateStr(baseDate); - const end = toDateStr(addDays(baseDate, config.spanDays - 1)); + const end = toDateStr(addDays(baseDate, effectiveSpanDays - 1)); if (lastRangeRef.current?.s === start && lastRangeRef.current?.e === end) return; lastRangeRef.current = { s: start, e: end }; onRangeChange(start, end); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [baseDate, config.spanDays]); + }, [baseDate, effectiveSpanDays]); - const totalWidth = config.cellWidth * config.spanDays; + const totalWidth = pxPerDay * effectiveSpanDays; // 충돌 ID 집합 const conflictIds = useMemo(() => { @@ -370,19 +380,42 @@ export default function TimelineScheduler({ ); const handleNavPrev = useCallback(() => { - setBaseDate((prev) => addDays(prev, -config.navStep)); - }, [config.navStep]); + setBaseDate((prev) => addDays(prev, -navStep)); + }, [navStep]); const handleNavNext = useCallback(() => { - setBaseDate((prev) => addDays(prev, config.navStep)); - }, [config.navStep]); + setBaseDate((prev) => addDays(prev, navStep)); + }, [navStep]); const handleNavToday = useCallback(() => { const d = new Date(); d.setHours(0, 0, 0, 0); + setSpanOverride(null); // 사용자 지정 기간 해제 → zoom 기본 기간 복귀 setBaseDate(d); }, []); + // 시작~종료 직접 지정 + const handlePickStart = useCallback((v: string) => { + if (!v) return; + const s = parseDate(v); + if (isNaN(s.getTime())) return; + setBaseDate(s); + setSpanOverride((prev) => { + // 종료일 유지: 기존 종료(baseDate+span-1) 기준으로 일수 재계산 + const curEnd = addDays(baseDate, (prev ?? config.spanDays) - 1); + const days = diffDays(s, curEnd) + 1; + return days >= 1 ? Math.min(days, 731) : 1; + }); + }, [baseDate, config.spanDays]); + + const handlePickEnd = useCallback((v: string) => { + if (!v) return; + const e = parseDate(v); + if (isNaN(e.getTime())) return; + const days = diffDays(baseDate, e) + 1; + setSpanOverride(days >= 1 ? Math.min(days, 731) : 1); + }, [baseDate]); + // ── 이벤트 바 위치 계산 ── const getBarStyle = useCallback( @@ -396,13 +429,13 @@ export default function TimelineScheduler({ if (evEnd < firstDate || evStart > lastDate) return null; const startIdx = Math.max(0, diffDays(firstDate, evStart)); - const endIdx = Math.min(config.spanDays - 1, diffDays(firstDate, evEnd)); + const endIdx = Math.min(effectiveSpanDays - 1, diffDays(firstDate, evEnd)); const left = startIdx * config.cellWidth; const width = (endIdx - startIdx + 1) * config.cellWidth; return { left, width }; }, - [dates, config.cellWidth, config.spanDays] + [dates, config.cellWidth, effectiveSpanDays] ); // ── 드래그/리사이즈 핸들러 ── @@ -415,6 +448,8 @@ export default function TimelineScheduler({ startDate: string, endDate: string ) => { + // 주/월 뷰에서는 1칸이 여러 일이라 드래그로 일 단위 세밀 조정이 불가 → 일(day) 뷰에서만 허용 + if (zoom !== "day") return; e.preventDefault(); e.stopPropagation(); setDragState({ @@ -427,7 +462,7 @@ export default function TimelineScheduler({ currentOffsetDays: 0, }); }, - [] + [zoom] ); // mousemove / mouseup (document-level) @@ -438,7 +473,7 @@ export default function TimelineScheduler({ const clampOffset = (rawOffset: number): number => { const origStart = parseDate(dragState.origStartDate); const origEnd = parseDate(dragState.origEndDate); - const lastDate = addDays(baseDate, config.spanDays - 1); + const lastDate = addDays(baseDate, effectiveSpanDays - 1); const msPerDay = 86400000; if (dragState.mode === "move") { const minOffset = Math.ceil((baseDate.getTime() - origStart.getTime()) / msPerDay); @@ -555,7 +590,7 @@ export default function TimelineScheduler({ document.removeEventListener("mouseup", handleMouseUp); if (rafId !== null) cancelAnimationFrame(rafId); }; - }, [dragState, config.cellWidth, config.spanDays, baseDate, onEventMove, onEventResize]); + }, [dragState, config.cellWidth, effectiveSpanDays, baseDate, onEventMove, onEventResize]); // 드래그 중인 이벤트의 현재 표시 위치 계산 const getDraggedBarStyle = useCallback( @@ -609,33 +644,57 @@ export default function TimelineScheduler({ // ── 날짜 헤더 그룹 ── - const dateGroups = useMemo(() => { - if (zoom === "day") { - return null; // day 뷰에서는 상위 그룹 없이 바로 날짜 표시 - } - - // week / month 뷰: 월 단위로 그룹 - const groups: { label: string; span: number; startIdx: number }[] = []; - let currentMonth = -1; - let currentYear = -1; - + // 상단 tier: 월(연도) 그룹 — 일/주 뷰 공통. 월 뷰는 컬럼 자체가 달이라 null. + const monthGroups = useMemo(() => { + if (zoom === "month") return null; + const groups: { label: string; days: number; startIdx: number }[] = []; + let cm = -1, cy = -1; for (let i = 0; i < dates.length; i++) { const d = dates[i]; - if (d.getMonth() !== currentMonth || d.getFullYear() !== currentYear) { - groups.push({ - label: `${d.getFullYear()}년 ${MONTH_NAMES[d.getMonth()]}`, - span: 1, - startIdx: i, - }); - currentMonth = d.getMonth(); - currentYear = d.getFullYear(); + if (d.getMonth() !== cm || d.getFullYear() !== cy) { + groups.push({ label: `${d.getFullYear()}년 ${MONTH_NAMES[d.getMonth()]}`, days: 1, startIdx: i }); + cm = d.getMonth(); cy = d.getFullYear(); } else { - groups[groups.length - 1].span++; + groups[groups.length - 1].days++; } } return groups; }, [dates, zoom]); + // 하단 tier: 축 단위 컬럼. 일=하루 / 주=월내 N주차(ceil(일/7)) / 월=한 달. + const columns = useMemo(() => { + type Col = { startIdx: number; days: number; label: string; sub?: string; isToday: boolean; isWeekend: boolean }; + const cols: Col[] = []; + if (zoom === "day") { + dates.forEach((d, i) => cols.push({ + startIdx: i, days: 1, + label: String(d.getDate()), sub: DAY_NAMES[d.getDay()], + isToday: isSameDay(d, today), isWeekend: isWeekend(d), + })); + } else { + let key = ""; + dates.forEach((d, i) => { + let k: string, label: string; + if (zoom === "week") { + const wom = Math.ceil(d.getDate() / 7); // 1~7→1, 8~14→2 ... (월내 주차) + k = `${d.getFullYear()}-${d.getMonth()}-${wom}`; + label = `${MONTH_NAMES[d.getMonth()]} ${wom}주차`; + } else { + k = `${d.getFullYear()}-${d.getMonth()}`; + label = `${d.getFullYear()}년 ${MONTH_NAMES[d.getMonth()]}`; + } + if (k !== key) { + cols.push({ startIdx: i, days: 1, label, isToday: false, isWeekend: false }); + key = k; + } else { + cols[cols.length - 1].days++; + } + if (isSameDay(d, today)) cols[cols.length - 1].isToday = true; + }); + } + return cols; + }, [dates, zoom, today]); + // ── 렌더링 ── if (loading) { @@ -664,9 +723,28 @@ export default function TimelineScheduler({ - - {toDateStr(dates[0])} ~ {toDateStr(dates[dates.length - 1])} - + {/* 시작~종료 직접 선택 (자유 기간 보기) */} +
+ handlePickStart(e.target.value)} + className="h-7 rounded border border-input bg-background px-2 text-xs text-foreground" + title="시작일" + /> + ~ + handlePickEnd(e.target.value)} + className="h-7 rounded border border-input bg-background px-2 text-xs text-foreground" + title="종료일" + /> + {spanOverride != null && ( + ({effectiveSpanDays}일) + )} +
{(["day", "week", "month"] as ZoomLevel[]).map((z) => ( @@ -731,7 +809,7 @@ export default function TimelineScheduler({ {/* 좌상단 코너 — 가로/세로 스크롤 모두에서 고정 (이벤트 바보다 위) */}
리소스
@@ -771,14 +849,14 @@ export default function TimelineScheduler({
{/* 날짜 헤더 — 이벤트 바(z-10)보다 위로 올려야 스크롤 시 겹침 방지 */}
- {/* 상위 그룹 (월) */} - {dateGroups && ( + {/* 상위 그룹 (연·월) — 일/주 뷰 */} + {monthGroups && (
- {dateGroups.map((g, idx) => ( + {monthGroups.map((g, idx) => (
{g.label}
@@ -786,37 +864,33 @@ export default function TimelineScheduler({
)} - {/* 하위 날짜 셀 */} + {/* 하위 축 컬럼 — 일=날짜 / 주=N주차 / 월=연·월 */}
- {dates.map((date, idx) => { - const isT = isSameDay(date, today); - const isW = isWeekend(date); - return ( -
- {zoom === "month" ? ( -
{date.getDate()}
- ) : ( - <> -
{date.getDate()}
-
{DAY_NAMES[date.getDay()]}
- - )} -
- ); - })} + {columns.map((c, idx) => ( +
+ {c.sub ? ( + <> +
{c.label}
+
{c.sub}
+ + ) : ( +
{c.label}
+ )} +
+ ))}
@@ -842,14 +916,14 @@ export default function TimelineScheduler({ > {/* 배경 그리드 */}
- {dates.map((date, idx) => ( + {columns.map((c, idx) => (
))}
@@ -896,6 +970,8 @@ export default function TimelineScheduler({ const colorClass = getStatusColor(ev.status); const isConflict = conflictIds.has(ev.id); const progress = ev.progress ?? 0; + // 주/월 뷰에서는 드래그 일정 조정 비활성(세밀 조정 불가) — 일 뷰에서만 가능 + const canDrag = zoom === "day"; return (
- {/* 좌측 리사이즈 핸들 */} -
{ - e.stopPropagation(); - handleMouseDown(e, ev.id, "resize-left", ev.startDate, ev.endDate); - }} - /> - - {/* 우측 리사이즈 핸들 */} -
{ - e.stopPropagation(); - handleMouseDown(e, ev.id, "resize-right", ev.startDate, ev.endDate); - }} - /> + {/* 리사이즈 핸들 — 일 뷰에서만(주/월은 세밀 조정 불가) */} + {canDrag && ( + <> +
{ + e.stopPropagation(); + handleMouseDown(e, ev.id, "resize-left", ev.startDate, ev.endDate); + }} + /> +
{ + e.stopPropagation(); + handleMouseDown(e, ev.id, "resize-right", ev.startDate, ev.endDate); + }} + /> + + )}
); })} diff --git a/frontend/lib/api/multiTableExcel.ts b/frontend/lib/api/multiTableExcel.ts index faa75a2b..0bee5ed8 100644 --- a/frontend/lib/api/multiTableExcel.ts +++ b/frontend/lib/api/multiTableExcel.ts @@ -85,14 +85,18 @@ export async function uploadMultiTableExcel(params: { config: TableChainConfig; modeId: string; rows: Record[]; -}): Promise<{ success: boolean; data?: MultiTableUploadResult; message?: string }> { +}): Promise<{ success: boolean; data?: MultiTableUploadResult; message?: string; error?: string }> { try { const response = await apiClient.post("/data/multi-table/upload", params); return response.data; } catch (error: any) { + // 500 응답에도 백엔드가 실제 원인을 error/data.errors 에 담아줌 — 버리지 말고 전달 + const resp = error.response?.data; return { success: false, - message: error.response?.data?.message || error.message, + data: resp?.data, + error: resp?.error, + message: resp?.message || error.message, }; } } diff --git a/frontend/lib/api/processInfo.ts b/frontend/lib/api/processInfo.ts index 0f512fae..7e5336df 100644 --- a/frontend/lib/api/processInfo.ts +++ b/frontend/lib/api/processInfo.ts @@ -141,6 +141,18 @@ export async function addProcessEquipment(data: { process_code: string; equipmen } } +// 다중 설비 일괄 등록 (TASK:ERP-node-087) +export async function addProcessEquipmentBatch( + data: { process_code: string; equipment_codes: string[] } +): Promise> { + try { + const res = await apiClient.post(`${BASE}/process-equipments/batch`, data); + return res.data; + } catch (e: any) { + return { success: false, message: e.response?.data?.message || e.message }; + } +} + export async function removeProcessEquipment(id: string): Promise> { try { const res = await apiClient.delete(`${BASE}/process-equipments/${id}`);