<template> <div class="gantt-container"> <!-- Calendar: เชื่อมกับ selectedDate --> <GanttCalendar v-model:modelValue="selectedDate" /> <!-- Gantt Chart UI --> <div class="gantt-chart"> <!-- Header: Time Scale --> <div class="header"> <div class="machine-label"></div> <div class="time-scale"> <div v-for="hour in hours" :key="hour" class="time-cell" > {{ formatHour(hour) }} </div> </div> </div> <!-- Rows: เครื่องจักรแต่ละตัว --> <div class="rows"> <div v-for="machine in machines" :key="machine.id" class="row" @dragover.prevent="onDragOver($event)" @drop="onDrop($event, machine.machineID)" > <div class="machine-label"> {{ machine.machineID }} </div> <div class="row-timeline"> <!-- เส้นแนวตั้ง (Grid Lines) --> <div v-for="hour in hours" :key="'line-' + hour" class="vertical-line" :style="getLineStyle(hour)" ></div> <!-- แสดง Order ที่ตรงกับวันที่ (selectedDate), Page, และ Machine --> <div v-for="order in filteredOrders(machine.machineID)" :key="order.queueID" class="order" :class="{ 'faded': draggingOrder && draggingOrder.queueID === order.queueID }" :style="getOrderStyle(order)" > <!-- Handle สำหรับ Resize ด้านซ้าย --> <div class="resize-handle left" @mousedown="onResizeStart($event, order, 'left')" ></div> <!-- ส่วนกลางของ Order ใช้สำหรับลาก และเปิด Dialog เมื่อคลิก --> <div class="order-content" draggable="true" @dragstart="onDragStart($event, order)" @dragend="onDragEnd($event, order)" @click.stop="openOrderDialog(order)" > {{ order.orderID }} ({{ getTimeString(order.startTime) }} - {{ getTimeString(order.finishTime) }}) </div> <!-- Handle สำหรับ Resize ด้านขวา --> <div class="resize-handle right" @mousedown="onResizeStart($event, order, 'right')" ></div> </div> </div> </div> </div> <!-- Ghost Order ขณะลาก --> <div v-if="ghostOrder" class="drag-ghost" :style="ghostStyle"> {{ ghostOrder.orderID }} ({{ getTimeString(ghostOrder.startTime) }} - {{ getTimeString(ghostOrder.finishTime) }}) </div> <v-divider :thickness="7"></v-divider> <!-- Pagination --> <div class="pagination"> <button v-for="p in pages" :key="p" :class="['page-btn', { active: p === currentPage }]" @click="currentPage = p" @contextmenu.prevent="onPageRightClick(p, $event)" > {{ p }} <button v-if="pageToShowDelete === p" class="delete-btn" @click.stop="deletePage(p)" > Delete </button> </button> <button class="page-btn add-page" @click="addPage">+</button> </div> <!-- Order Dialog --> <OrderDialog v-if="selectedOrder" :order="selectedOrder" @close="closeOrderDialog" @edit="editOrder" @delete="deleteOrder" /> </div> </div> </template> <script> import GanttCalendar from './GanttCalendar.vue'; import OrderDialog from './OrderDialog.vue'; import './ganttChart.css'; export default { name: 'GanttChart', components: { GanttCalendar, OrderDialog, }, data() { return { // ค่าจาก Calendar selectedDate: '1/1/2024', // กำหนดช่วงเวลาใน Gantt startTime: '08:00', endTime: '17:00', // รายการเครื่องจักร machines: [ { id: 1, machineID: 'MC1' }, { id: 2, machineID: 'MC2' }, { id: 3, machineID: 'MC3' }, { id: 4, machineID: 'MC4' }, { id: 5, machineID: 'MC5' }, ], // ข้อมูล Order ตามโครงสร้าง Queue orders: [ { queueID: 1, machineID: 'MC1', orderID: 1, pageNumber: 1, startTime: '1/1/2024 09:00', finishTime: '1/1/2024 11:00', status: 'Process', bottleSize: '600ml', producedQuantity: 400, }, { queueID: 2, machineID: 'MC2', orderID: 2, pageNumber: 1, startTime: '1/1/2024 13:00', finishTime: '1/1/2024 15:00', status: 'Waiting', bottleSize: '500ml', producedQuantity: 200, }, { queueID: 3, machineID: 'MC1', orderID: 3, pageNumber: 2, startTime: '1/1/2024 10:00', finishTime: '1/1/2024 12:00', status: 'Process', bottleSize: '700ml', producedQuantity: 350, }, { queueID: 4, machineID: 'MC5', orderID: 4, pageNumber: 2, startTime: '1/1/2024 14:00', finishTime: '1/1/2024 16:00', status: 'Process', bottleSize: '600ml', producedQuantity: 500, }, { queueID: 5, machineID: 'MC5', orderID: 4, pageNumber: 1, startTime: '2/1/2024 14:00', finishTime: '2/1/2024 16:00', status: 'Process', bottleSize: '600ml', producedQuantity: 500, }, ], // จัดการการลาก, Resize draggingOrder: null, dragOffset: 0, resizingOrder: null, resizeDirection: null, // Ghost Order (ขณะลาก) ghostOrder: null, ghostStyle: { position: 'fixed', top: '0px', left: '0px', display: 'none', width: 'auto', height: '40px', lineHeight: '40px', padding: '0 10px', borderRadius: '20px', pointerEvents: 'none', backgroundColor: '#4caf50', color: '#fff', zIndex: 9999, }, // Pagination pages: [1, 2], currentPage: 1, pageToShowDelete: null, // Dialog selectedOrder: null, }; }, computed: { // สร้าง list ของชั่วโมงทั้งหมดจาก startTime ถึง endTime hours() { const startHour = parseInt(this.startTime.split(':')[0]); const endHour = parseInt(this.endTime.split(':')[0]); return Array.from({ length: endHour - startHour + 1 }, (_, i) => startHour + i); }, }, methods: { // แยก date ส่วนหน้า (1/1/2024) ออกจาก startTime/finishTime getDateString(dateTimeStr) { // สมมติ format = '1/1/2024 09:00' return dateTimeStr.split(' ')[0]; }, // แยกเฉพาะส่วนเวลา (09:00) getTimeString(dateTimeStr) { return dateTimeStr.split(' ')[1]; }, // ฟิลเตอร์ order ตาม machineID, pageNumber, และ selectedDate filteredOrders(machineID) { return this.orders.filter((o) => { const orderDate = this.getDateString(o.startTime); return ( o.machineID === machineID && o.pageNumber === this.currentPage && orderDate === this.selectedDate ); }); }, // แปลงเลขชั่วโมงเป็น "HH:00" formatHour(hour) { return (hour < 10 ? '0' + hour : hour) + ':00'; }, // สไตล์ตำแหน่งของ Order getOrderStyle(order) { // แยกเวลาเฉพาะส่วน HH:mm const start = this.getTimeString(order.startTime); // ex "09:00" const end = this.getTimeString(order.finishTime); // ex "11:00" const startDecimal = this.timeToDecimal(start); const endDecimal = this.timeToDecimal(end); const timelineStart = this.timeToDecimal(this.startTime); const timelineEnd = this.timeToDecimal(this.endTime); const ratioStart = (startDecimal - timelineStart) / (timelineEnd - timelineStart); const ratioEnd = (endDecimal - timelineStart) / (timelineEnd - timelineStart); return { left: ratioStart * 100 + '%', width: (ratioEnd - ratioStart) * 100 + '%', backgroundColor: '#4caf50', // สามารถปรับตาม status หรืออื่นๆ ได้ }; }, // สไตล์ของเส้นแนวตั้ง (grid lines) getLineStyle(hour) { const timelineStart = this.timeToDecimal(this.startTime); const timelineEnd = this.timeToDecimal(this.endTime); const ratio = (hour - timelineStart) / (timelineEnd - timelineStart); return { left: ratio * 100 + '%', }; }, // แปลงเวลา "HH:MM" -> เลขทศนิยม timeToDecimal(timeStr) { const [hours, minutes] = timeStr.split(':').map(Number); return hours + minutes / 60; }, // แปลงเลขทศนิยม -> "HH:MM" decimalToTime(decimal) { const hours = Math.floor(decimal); const minutes = Math.round((decimal - hours) * 60); return (hours < 10 ? '0' + hours : hours) + ':' + (minutes < 10 ? '0' + minutes : minutes); }, // Event ลาก (DragStart) onDragStart(event, order) { this.draggingOrder = order; const rect = event.target.getBoundingClientRect(); this.dragOffset = rect.width / 2; // ปิด preview เริ่มต้นของ HTML5 Drag & Drop const emptyImg = document.createElement('img'); emptyImg.src = 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='; event.dataTransfer.setDragImage(emptyImg, 0, 0); this.ghostOrder = { ...order }; this.ghostStyle.display = 'block'; this.ghostStyle.backgroundColor = '#4caf50'; document.addEventListener('dragover', this.onDragOverGlobal); document.addEventListener('dragend', this.onDragEndGlobal); }, onDragOver(event) { event.preventDefault(); }, onDragOverGlobal(event) { event.preventDefault(); const rowTimeline = document.querySelector('.row-timeline'); if (!rowTimeline) return; const timelineRect = rowTimeline.getBoundingClientRect(); const timelineWidth = timelineRect.width; const offsetX = event.clientX - timelineRect.left; let ratio = offsetX / timelineWidth; ratio = Math.min(Math.max(ratio, 0), 1); const timelineStart = this.timeToDecimal(this.startTime); const timelineEnd = this.timeToDecimal(this.endTime); // คำนวณเวลาเริ่มใหม่ (เฉพาะส่วนเวลา) const startDecimal = this.timeToDecimal(this.getTimeString(this.draggingOrder.startTime)); const endDecimal = this.timeToDecimal(this.getTimeString(this.draggingOrder.finishTime)); const duration = endDecimal - startDecimal; let newStartDecimal = timelineStart + ratio * (timelineEnd - timelineStart); newStartDecimal = Math.round(newStartDecimal * 2) / 2; let newEndDecimal = newStartDecimal + duration; if (newEndDecimal > timelineEnd) { newEndDecimal = timelineEnd; newStartDecimal = newEndDecimal - duration; } // สร้าง String ใหม่ "วัน/เดือน/ปี เวลา" const datePart = this.getDateString(this.draggingOrder.startTime); // ex "1/1/2024" const newStartStr = datePart + ' ' + this.decimalToTime(newStartDecimal); const newEndStr = datePart + ' ' + this.decimalToTime(newEndDecimal); this.ghostOrder.startTime = newStartStr; this.ghostOrder.finishTime = newEndStr; const snappedRatioStart = (newStartDecimal - timelineStart) / (timelineEnd - timelineStart); const snappedLeft = timelineRect.left + snappedRatioStart * timelineWidth; const snappedRatioEnd = (newEndDecimal - timelineStart) / (timelineEnd - timelineStart); const snappedWidth = (snappedRatioEnd - snappedRatioStart) * timelineWidth; // หาแถว (machine) ที่ใกล้ที่สุด const rows = document.querySelectorAll('.row'); let closestRow = null; let minDistance = Infinity; rows.forEach((row) => { const rect = row.getBoundingClientRect(); const distance = Math.abs(event.clientY - rect.top); if (distance < minDistance) { minDistance = distance; closestRow = row; } }); if (closestRow) { this.ghostOrder.machineID = closestRow.querySelector('.machine-label').textContent.trim(); this.ghostStyle.top = closestRow.getBoundingClientRect().top + 'px'; } this.ghostStyle.left = snappedLeft + 'px'; this.ghostStyle.width = snappedWidth + 'px'; }, onDragEndGlobal() { this.ghostOrder = null; this.ghostStyle.display = 'none'; this.draggingOrder = null; document.removeEventListener('dragend', this.onDragEndGlobal); }, onDragEnd(event, order) { if (!this.ghostOrder) return; order.startTime = this.ghostOrder.startTime; order.finishTime = this.ghostOrder.finishTime; order.machineID = this.ghostOrder.machineID; document.removeEventListener('dragover', this.onDragOverGlobal); this.ghostOrder = null; this.ghostStyle.display = 'none'; this.draggingOrder = null; }, onDrop(event, newMachine) { event.preventDefault(); if (this.draggingOrder && this.ghostOrder) { this.draggingOrder.startTime = this.ghostOrder.startTime; this.draggingOrder.finishTime = this.ghostOrder.finishTime; this.draggingOrder.machineID = newMachine; } this.ghostOrder = null; this.ghostStyle.display = 'none'; this.draggingOrder = null; }, // Resize onResizeStart(event, order, direction) { event.preventDefault(); this.resizingOrder = order; this.resizeDirection = direction; document.addEventListener('mousemove', this.onResizing); document.addEventListener('mouseup', this.onResizeEnd); }, onResizing(event) { if (!this.resizingOrder) return; const rowTimeline = document.querySelector('.row-timeline'); if (!rowTimeline) return; const timelineRect = rowTimeline.getBoundingClientRect(); const timelineWidth = timelineRect.width; const offsetX = event.clientX - timelineRect.left; let ratio = offsetX / timelineWidth; ratio = Math.min(Math.max(ratio, 0), 1); const timelineStart = this.timeToDecimal(this.startTime); const timelineEnd = this.timeToDecimal(this.endTime); const startDec = this.timeToDecimal(this.getTimeString(this.resizingOrder.startTime)); const endDec = this.timeToDecimal(this.getTimeString(this.resizingOrder.finishTime)); let newTimeDecimal = timelineStart + ratio * (timelineEnd - timelineStart); newTimeDecimal = Math.round(newTimeDecimal * 2) / 2; const datePart = this.getDateString(this.resizingOrder.startTime); // ex: "1/1/2024" if (this.resizeDirection === 'left') { if (newTimeDecimal >= endDec) return; const newStartStr = datePart + ' ' + this.decimalToTime(newTimeDecimal); this.resizingOrder.startTime = newStartStr; } else if (this.resizeDirection === 'right') { if (newTimeDecimal <= startDec) return; const newEndStr = datePart + ' ' + this.decimalToTime(newTimeDecimal); this.resizingOrder.finishTime = newEndStr; } }, onResizeEnd() { this.resizingOrder = null; this.resizeDirection = null; document.removeEventListener('mousemove', this.onResizing); document.removeEventListener('mouseup', this.onResizeEnd); }, // Pagination addPage() { const newPage = this.pages.length + 1; this.pages.push(newPage); }, onPageRightClick(page, event) { event.preventDefault(); this.pageToShowDelete = page; }, deletePage(page) { const index = this.pages.indexOf(page); if (index !== -1) { this.pages.splice(index, 1); if (this.currentPage === page) { this.currentPage = this.pages.length > 0 ? this.pages[0] : 1; } } this.pageToShowDelete = null; }, onDocumentClick(event) { if (!event.target.closest('.page-btn')) { this.pageToShowDelete = null; } }, // Dialog openOrderDialog(order) { this.selectedOrder = { ...order }; }, closeOrderDialog() { this.selectedOrder = null; }, editOrder(order) { alert('Edit functionality not implemented yet.\nOrder ID: ' + order.orderID); }, deleteOrder(order) { this.orders = this.orders.filter((o) => o.queueID !== order.queueID); this.closeOrderDialog(); }, }, mounted() { document.addEventListener('click', this.onDocumentClick); }, beforeUnmount() { document.removeEventListener('click', this.onDocumentClick); }, }; </script>