diff --git a/src/components/GanttChart/GanttChart.vue b/src/components/GanttChart/GanttChart.vue index 4da3f5e26150a7c1ad81133d4587db0b0c0833eb..054f5d96bbe81158ddd42cd891ddb43fac30451c 100644 --- a/src/components/GanttChart/GanttChart.vue +++ b/src/components/GanttChart/GanttChart.vue @@ -12,36 +12,32 @@ <!-- Rows: เครื่องจักรแต่ละตัว --> <div class="rows"> - <div - v-for="machine in machines" - :key="machine.id" - class="row" - @dragover.prevent="onDragOver" - @drop="onDrop($event, machine.name)" - > + <div v-for="machine in machines" :key="machine.id" class="row" @dragover.prevent="onDragOver($event)" + @drop="onDrop($event, machine.name)"> <div class="machine-label"> {{ machine.name }} </div> <div class="row-timeline"> <!-- เส้นแนวตั้ง (Grid Lines) --> + <div v-for="hour in hours" :key="'line-' + hour" class="vertical-line" :style="getLineStyle(hour)"></div> + + <!-- แสดง Order --> <div - v-for="hour in hours" - :key="'line-' + hour" - class="vertical-line" - :style="getLineStyle(hour)" - ></div> - - <!-- แสดง Order โดยใช้ OrderItem Component --> - <OrderItem v-for="order in orders.filter(o => pages.includes(o.page) && o.page === currentPage && o.machine === machine.name)" - :key="order.id" - :order="order" - :draggingOrder="draggingOrder" - :getOrderStyle="getOrderStyle" - @resizeStart="onResizeStart" - @dragStart="onDragStart" - @dragEnd="onDragEnd" - /> + :key="order.id" class="order" :class="{ 'faded': draggingOrder && draggingOrder.id === order.id }" + :style="getOrderStyle(order)"> + <!-- Handle สำหรับ Resize ด้านซ้าย --> + <div class="resize-handle left" @mousedown="onResizeStart($event, order, 'left')"></div> + + <!-- ส่วนกลางของ Order ใช้สำหรับลาก --> + <div class="order-content" draggable="true" @dragstart="onDragStart($event, order)" + @dragend="onDragEnd($event, order)"> + {{ order.name }} ({{ order.start }} - {{ order.end }}) + </div> + + <!-- Handle สำหรับ Resize ด้านขวา --> + <div class="resize-handle right" @mousedown="onResizeStart($event, order, 'right')"></div> + </div> </div> </div> </div> @@ -53,27 +49,28 @@ <v-divider :thickness="7"></v-divider> - <!-- Pagination Component --> - <GanttPagination - :pages="pages" - :currentPage="currentPage" - @updateCurrentPage="updateCurrentPage" - @addPage="addPage" - @deletePage="deletePage" - /> + <!-- 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 }} + <!-- ปุ่ม Delete จะแสดงเฉพาะเมื่อคลิกขวาที่ปุ่มนั้น --> + <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> </div> </template> <script> -import OrderItem from './OrderItem.vue'; -import GanttPagination from './GanttPagination.vue'; - export default { name: 'GanttChart', - components: { - OrderItem, - GanttPagination - }, data() { return { // เวลางาน @@ -92,7 +89,7 @@ export default { // รายการ Order orders: [ { page: 1, id: 1, name: 'Order 1', start: '13:00', end: '15:00', machine: 'MC1', color: 'blue' }, - { page: 1, id: 2, name: 'Order 2', start: '09:00', end: '11:00', machine: 'MC2', color: 'red' }, + { page: 1, id: 2, name: 'Order 2', start: '09:00', end: '11:00', machine: 'MC2' }, { page: 2, id: 3, name: 'Order 3', start: '10:00', end: '12:00', machine: 'MC1' }, { page: 2, id: 4, name: 'Order 4', start: '14:00', end: '16:00', machine: 'MC5' }, ], @@ -103,8 +100,8 @@ export default { resizingOrder: null, resizeDirection: null, - // Ghost Order - ghostOrder: null, + // Ghost Order (ตัวอย่างที่ขยับตามเม้าส์) + ghostOrder: null, ghostStyle: { position: 'fixed', top: '0px', @@ -121,9 +118,12 @@ export default { zIndex: 9999, }, - // Pagination Data + // จัดการ Pagination pages: [1, 2], currentPage: 1, + + // สำหรับการแสดงปุ่ม delete เมื่อคลิกขวาที่ page + pageToShowDelete: null, } }, computed: { @@ -135,62 +135,89 @@ export default { } }, methods: { + // แปลงเลขชั่วโมงเป็น "HH:00" formatHour(hour) { return (hour < 10 ? '0' + hour : hour) + ':00'; }, + + // สไตล์การวาง Order (ตำแหน่ง left, width, และ backgroundColor) getOrderStyle(order) { const startDecimal = this.timeToDecimal(order.start); const endDecimal = this.timeToDecimal(order.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); + + const left = ratioStart * 100 + '%'; + const width = (ratioEnd - ratioStart) * 100 + '%'; + return { - left: ratioStart * 100 + '%', - width: (ratioEnd - ratioStart) * 100 + '%', + left, + width, backgroundColor: order.color || '#4caf50' }; }, + + // สไตล์ของเส้นแนวตั้ง (grid lines) ตามชั่วโมง getLineStyle(hour) { - const timelineStart = this.timeToDecimal(this.startTime); - const timelineEnd = this.timeToDecimal(this.endTime); - const ratio = (hour - timelineStart) / (timelineEnd - timelineStart); + const startDecimal = this.timeToDecimal(this.startTime); + const endDecimal = this.timeToDecimal(this.endTime); + const ratio = (hour - startDecimal) / (endDecimal - startDecimal); return { left: ratio * 100 + '%' }; }, + + // แปลงเวลา "HH:MM" -> เลขทศนิยม (เช่น 9:30 => 9.5) 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); }, onDragEndGlobal() { + // ลบ Ghost Order ออกจาก UI this.ghostOrder = null; this.ghostStyle.display = 'none'; this.draggingOrder = null; + + // เอา event listener ออก (ไม่งั้นมันจะถูกเรียกทุกครั้งที่ drag) document.removeEventListener('dragend', this.onDragEndGlobal); }, + + // เริ่มลาก Order (DragStart) onDragStart(event, order) { this.draggingOrder = order; const rect = event.target.getBoundingClientRect(); - this.dragOffset = rect.width / 2; - // ปิด preview ของ HTML5 Drag & Drop + this.dragOffset = event.target.getBoundingClientRect().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); + + // สร้าง Ghost Order this.ghostOrder = { ...order }; this.ghostStyle.display = 'block'; this.ghostStyle.backgroundColor = order.color || '#4caf50'; + document.addEventListener('dragover', this.onDragOverGlobal); document.addEventListener('dragend', this.onDragEndGlobal); }, + + // ขณะลาก (DragOver) ที่ผูกใน template onDragOver(event) { event.preventDefault(); }, + + // onDragOverGlobal - อัปเดตตำแหน่ง Ghost และเวลา onDragOverGlobal(event) { event.preventDefault(); const rowTimeline = document.querySelector('.row-timeline'); @@ -198,28 +225,36 @@ export default { 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); let newStartDecimal = timelineStart + ratio * (timelineEnd - timelineStart); + newStartDecimal = Math.round(newStartDecimal * 2) / 2; + const duration = this.timeToDecimal(this.draggingOrder.end) - this.timeToDecimal(this.draggingOrder.start); let newEndDecimal = newStartDecimal + duration; if (newEndDecimal > timelineEnd) { newEndDecimal = timelineEnd; newStartDecimal = newEndDecimal - duration; } + this.ghostOrder.start = this.decimalToTime(newStartDecimal); this.ghostOrder.end = this.decimalToTime(newEndDecimal); + const snappedRatioStart = (newStartDecimal - timelineStart) / (timelineEnd - timelineStart); const snappedLeft = timelineRect.left + snappedRatioStart * timelineWidth; + const snappedRatioEnd = (newEndDecimal - timelineStart) / (timelineEnd - timelineStart); const snappedWidth = (snappedRatioEnd - snappedRatioStart) * timelineWidth; - // คำนวณ snap ตามแนวตั้ง (เครื่องจักร) + 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); @@ -228,34 +263,46 @@ export default { closestRow = row; } }); + if (closestRow) { this.ghostOrder.machine = closestRow.querySelector('.machine-label').textContent.trim(); this.ghostStyle.top = closestRow.getBoundingClientRect().top + 'px'; } + this.ghostStyle.left = snappedLeft + 'px'; this.ghostStyle.width = snappedWidth + 'px'; }, + + // จบการลาก Order (DragEnd) => คำนวณตำแหน่งใหม่ onDragEnd(event, order) { if (!this.ghostOrder) return; + order.start = this.ghostOrder.start; order.end = this.ghostOrder.end; order.machine = this.ghostOrder.machine; + document.removeEventListener('dragover', this.onDragOverGlobal); this.ghostOrder = null; this.ghostStyle.display = 'none'; this.draggingOrder = null; }, + + // เมื่อปล่อย Drag บนเครื่องจักรใหม่ => เปลี่ยน machine ของ Order onDrop(event, newMachine) { event.preventDefault(); + if (this.draggingOrder) { this.draggingOrder.start = this.ghostOrder.start; this.draggingOrder.end = this.ghostOrder.end; this.draggingOrder.machine = newMachine; } + this.ghostOrder = null; this.ghostStyle.display = 'none'; this.draggingOrder = null; }, + + // เริ่ม Resize (mousedown ที่ handle) onResizeStart(event, order, direction) { event.preventDefault(); this.resizingOrder = order; @@ -263,19 +310,26 @@ export default { document.addEventListener("mousemove", this.onResizing); document.addEventListener("mouseup", this.onResizeEnd); }, + + // ขณะ Resize 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); let newTimeDecimal = timelineStart + ratio * (timelineEnd - timelineStart); + newTimeDecimal = Math.round(newTimeDecimal * 2) / 2; + if (this.resizeDirection === 'left') { if (newTimeDecimal >= this.timeToDecimal(this.resizingOrder.end)) return; this.resizingOrder.start = this.decimalToTime(newTimeDecimal); @@ -284,19 +338,28 @@ export default { this.resizingOrder.end = this.decimalToTime(newTimeDecimal); } }, + + // จบการ Resize onResizeEnd() { this.resizingOrder = null; this.resizeDirection = null; document.removeEventListener("mousemove", this.onResizing); document.removeEventListener("mouseup", this.onResizeEnd); }, + + // เพิ่มหน้าใหม่ addPage() { const newPage = this.pages.length + 1; this.pages.push(newPage); }, - updateCurrentPage(page) { - this.currentPage = page; + + // เมื่อคลิกขวาที่ปุ่ม page + onPageRightClick(page, event) { + event.preventDefault(); + this.pageToShowDelete = page; }, + + // ลบหน้า deletePage(page) { const index = this.pages.indexOf(page); if (index !== -1) { @@ -305,7 +368,21 @@ export default { this.currentPage = this.pages.length > 0 ? this.pages[0] : 1; } } + this.pageToShowDelete = null; + }, + + // ซ่อนปุ่ม delete เมื่อคลิกนอก page-btn + onDocumentClick(event) { + if (!event.target.closest('.page-btn')) { + this.pageToShowDelete = null; + } } + }, + mounted() { + document.addEventListener('click', this.onDocumentClick); + }, + beforeUnmount() { + document.removeEventListener('click', this.onDocumentClick); } } </script> @@ -371,9 +448,92 @@ export default { background-color: #ddd; } +.order { + position: absolute; + top: 10px; + height: 40px; + color: #fff; + border-radius: 20px; + user-select: none; + display: flex; + align-items: center; + z-index: 1; +} + +.order-content { + flex: 1; + text-align: center; + line-height: 40px; + cursor: grab; + padding: 0 10px; +} + +.resize-handle { + width: 5px; + height: 100%; + cursor: ew-resize; + position: absolute; +} + +.resize-handle.left { + left: 0; + border-top-left-radius: 10px; + border-bottom-left-radius: 10px; +} + +.resize-handle.right { + right: 0; + border-top-right-radius: 10px; + border-bottom-right-radius: 10px; +} + +.order.faded { + opacity: 0.3; +} + .drag-ghost { box-sizing: border-box; text-align: center; pointer-events: none; } + +.pagination { + display: flex; + justify-content: flex-start; + align-items: center; + gap: 5px; + width: 100%; + margin-top: 10px; +} + +.page-btn { + background: #ffffff; + border: 1px solid #ffffff; + padding: 5px 10px; + cursor: pointer; + font-size: 14px; + position: relative; +} + +.page-btn.active { + background: #007bff; + color: #ffffff; +} + +.page-btn.add-page { + font-weight: bold; + background: #ffffff; +} + +/* สไตล์สำหรับปุ่ม Delete ที่แสดงเมื่อคลิกขวา */ +.delete-btn { + margin-left: 5px; + background-color: red; + color: #fff; + border: none; + padding: 2px 5px; + cursor: pointer; + border-radius: 3px; + font-size: 12px; +} </style> diff --git a/src/components/GanttChart/GanttPagination.vue b/src/components/GanttChart/GanttPagination.vue deleted file mode 100644 index 97f3b7d409ce4c06426b8946277bfc4cc6435717..0000000000000000000000000000000000000000 --- a/src/components/GanttChart/GanttPagination.vue +++ /dev/null @@ -1,107 +0,0 @@ -<template> - <div class="pagination"> - <button - v-for="p in pages" - :key="p" - :class="['page-btn', { active: p === currentPage }]" - @click="$emit('updateCurrentPage', p)" - @contextmenu.prevent="onPageRightClick(p, $event)" - > - {{ p }} - <!-- แสดงปุ่ม Delete เมื่อคลิกขวาที่ปุ่มนั้น --> - <button - v-if="pageToShowDelete === p" - class="delete-btn" - @click.stop="deletePage(p)" - > - Delete - </button> - </button> - <!-- ปุ่ม + สำหรับเพิ่มหน้าใหม่ --> - <button class="page-btn add-page" @click="$emit('addPage')">+</button> - </div> - </template> - - <script> - export default { - name: 'GanttPagination', - props: { - pages: { - type: Array, - required: true - }, - currentPage: { - type: Number, - required: true - } - }, - data() { - return { - pageToShowDelete: null - } - }, - methods: { - onPageRightClick(page, event) { - event.preventDefault(); - this.pageToShowDelete = page; - }, - deletePage(page) { - this.$emit('deletePage', page); - this.pageToShowDelete = null; - }, - onDocumentClick(event) { - if (!event.target.closest('.page-btn')) { - this.pageToShowDelete = null; - } - } - }, - mounted() { - document.addEventListener('click', this.onDocumentClick); - }, - beforeUnmount() { - document.removeEventListener('click', this.onDocumentClick); - } - } - </script> - - <style scoped> - .pagination { - display: flex; - justify-content: flex-start; - align-items: center; - gap: 5px; - width: 100%; - margin-top: 10px; - } - - .page-btn { - background: #ffffff; - border: 1px solid #ffffff; - padding: 5px 10px; - cursor: pointer; - font-size: 14px; - position: relative; - } - - .page-btn.active { - background: #007bff; - color: #ffffff; - } - - .page-btn.add-page { - font-weight: bold; - background: #ffffff; - } - - .delete-btn { - margin-left: 5px; - background-color: red; - color: #fff; - border: none; - padding: 2px 5px; - cursor: pointer; - border-radius: 3px; - font-size: 12px; - } - </style> - \ No newline at end of file diff --git a/src/components/GanttChart/OrderItem.vue b/src/components/GanttChart/OrderItem.vue deleted file mode 100644 index 45006536566dc3278c76cf2920b3614f02a4113a..0000000000000000000000000000000000000000 --- a/src/components/GanttChart/OrderItem.vue +++ /dev/null @@ -1,101 +0,0 @@ -<template> - <div - class="order" - :class="{ 'faded': draggingOrder && draggingOrder.id === order.id }" - :style="getOrderStyle(order)" - > - <!-- Handle สำหรับ Resize ด้านซ้าย --> - <div class="resize-handle left" @mousedown="handleResizeStart('left', $event)"></div> - - <!-- ส่วนกลางของ Order ใช้สำหรับลาก --> - <div - class="order-content" - draggable="true" - @dragstart="handleDragStart($event)" - @dragend="handleDragEnd($event)" - > - {{ order.name }} ({{ order.start }} - {{ order.end }}) - </div> - - <!-- Handle สำหรับ Resize ด้านขวา --> - <div class="resize-handle right" @mousedown="handleResizeStart('right', $event)"></div> - </div> - </template> - - <script> - export default { - name: 'OrderItem', - props: { - order: { - type: Object, - required: true - }, - draggingOrder: { - type: Object, - default: null - }, - getOrderStyle: { - type: Function, - required: true - } - }, - methods: { - handleResizeStart(direction, event) { - // ส่ง event พร้อม order และทิศทางไปยัง parent - this.$emit('resizeStart', event, this.order, direction); - }, - handleDragStart(event) { - this.$emit('dragStart', event, this.order); - }, - handleDragEnd(event) { - this.$emit('dragEnd', event, this.order); - } - } - } - </script> - - <style scoped> - .order { - position: absolute; - top: 10px; - height: 40px; - color: #fff; - border-radius: 20px; - user-select: none; - display: flex; - align-items: center; - z-index: 1; - } - - .order-content { - flex: 1; - text-align: center; - line-height: 40px; - cursor: grab; - padding: 0 10px; - } - - .resize-handle { - width: 5px; - height: 100%; - cursor: ew-resize; - position: absolute; - } - - .resize-handle.left { - left: 0; - border-top-left-radius: 10px; - border-bottom-left-radius: 10px; - } - - .resize-handle.right { - right: 0; - border-top-right-radius: 10px; - border-bottom-right-radius: 10px; - } - - .faded { - opacity: 0.3; - } - </style> - \ No newline at end of file