diff --git a/src/components/GanttChart.vue b/src/components/GanttChart/GanttChart.vue similarity index 57% rename from src/components/GanttChart.vue rename to src/components/GanttChart/GanttChart.vue index 84b894e43a4dec154cdc412e770ced159a34a55b..054f5d96bbe81158ddd42cd891ddb43fac30451c 100644 --- a/src/components/GanttChart.vue +++ b/src/components/GanttChart/GanttChart.vue @@ -12,55 +12,43 @@ <!-- Rows: เครื่องจักรแต่ละตัว --> <div class="rows"> - <div - v-for="machine in machines" - :key="machine.id" - class="row" - @dragover.prevent - @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> + <div v-for="hour in hours" :key="'line-' + hour" class="vertical-line" :style="getLineStyle(hour)"></div> - <!-- แสดง Order เฉพาะหน้าปัจจุบันและเครื่องจักรตรงกัน --> + <!-- แสดง Order --> <div v-for="order in orders.filter(o => pages.includes(o.page) && o.page === currentPage && o.machine === machine.name)" - :key="order.id" - class="order" - :style="getOrderStyle(order)" - > + :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> + <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)" - > + <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 class="resize-handle right" @mousedown="onResizeStart($event, order, 'right')"></div> </div> </div> </div> </div> - <v-divider :thickness="7" ></v-divider> + + <!-- Ghost Order ขณะลาก --> + <div v-if="ghostOrder" class="drag-ghost" :style="ghostStyle"> + {{ ghostOrder.name }} ({{ ghostOrder.start }} - {{ ghostOrder.end }}) + </div> + + <v-divider :thickness="7"></v-divider> + <!-- Pagination --> <div class="pagination"> <button @@ -68,8 +56,11 @@ :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> @@ -95,7 +86,7 @@ export default { { id: 5, name: 'MC5' }, ], - // รายการ Order (ตัวอย่าง Order 1 มีสี blue) + // รายการ 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' }, @@ -109,9 +100,30 @@ export default { 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], // เริ่มต้นสองหน้า + pages: [1, 2], currentPage: 1, + + // สำหรับการแสดงปุ่ม delete เมื่อคลิกขวาที่ page + pageToShowDelete: null, } }, computed: { @@ -144,7 +156,7 @@ export default { return { left, width, - backgroundColor: order.color || '#4caf50' // ใช้สีใน order หรือสี default + backgroundColor: order.color || '#4caf50' }; }, @@ -170,54 +182,124 @@ export default { 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 = event.clientX - rect.left; - event.dataTransfer.effectAllowed = "move"; + 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); }, - // จบการลาก Order (DragEnd) => คำนวณตำแหน่งใหม่ - onDragEnd(event, order) { - const rowTimeline = event.target.closest('.row-timeline'); - if (!rowTimeline) return; + // ขณะลาก (DragOver) ที่ผูกใน template + onDragOver(event) { + event.preventDefault(); + }, + // onDragOverGlobal - อัปเดตตำแหน่ง Ghost และเวลา + onDragOverGlobal(event) { + event.preventDefault(); + const rowTimeline = document.querySelector('.row-timeline'); + if (!rowTimeline) return; const timelineRect = rowTimeline.getBoundingClientRect(); - const dropX = event.clientX - timelineRect.left - this.dragOffset; const timelineWidth = timelineRect.width; + const offsetX = event.clientX - timelineRect.left; - let ratioStart = dropX / timelineWidth; - ratioStart = Math.min(Math.max(ratioStart, 0), 1); - + 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 + ratioStart * (timelineEnd - timelineStart); + let newStartDecimal = timelineStart + ratio * (timelineEnd - timelineStart); - // ปัดค่าเป็นช่วงครึ่งชั่วโมง (0.5) newStartDecimal = Math.round(newStartDecimal * 2) / 2; - const duration = this.timeToDecimal(order.end) - this.timeToDecimal(order.start); + const duration = this.timeToDecimal(this.draggingOrder.end) - this.timeToDecimal(this.draggingOrder.start); let newEndDecimal = newStartDecimal + duration; - - // ถ้าเกิน endTime ก็ปรับให้พอดี if (newEndDecimal > timelineEnd) { newEndDecimal = timelineEnd; newStartDecimal = newEndDecimal - duration; } - order.start = this.decimalToTime(newStartDecimal); - order.end = this.decimalToTime(newEndDecimal); + 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; + + 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.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.draggingOrder = null; } + + this.ghostOrder = null; + this.ghostStyle.display = 'none'; + this.draggingOrder = null; }, // เริ่ม Resize (mousedown ที่ handle) @@ -246,15 +328,12 @@ export default { const timelineEnd = this.timeToDecimal(this.endTime); let newTimeDecimal = timelineStart + ratio * (timelineEnd - timelineStart); - // ปัดเป็นช่วงครึ่งชั่วโมง newTimeDecimal = Math.round(newTimeDecimal * 2) / 2; if (this.resizeDirection === 'left') { - // ไม่ให้ start เกิน end if (newTimeDecimal >= this.timeToDecimal(this.resizingOrder.end)) return; this.resizingOrder.start = this.decimalToTime(newTimeDecimal); } else if (this.resizeDirection === 'right') { - // ไม่ให้ end น้อยกว่า start if (newTimeDecimal <= this.timeToDecimal(this.resizingOrder.start)) return; this.resizingOrder.end = this.decimalToTime(newTimeDecimal); } @@ -272,7 +351,38 @@ export default { addPage() { const newPage = this.pages.length + 1; this.pages.push(newPage); + }, + + // เมื่อคลิกขวาที่ปุ่ม page + 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; + }, + + // ซ่อนปุ่ม 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> @@ -284,10 +394,12 @@ export default { display: flex; flex-direction: column; } + .header { display: flex; background: #fff; } + .machine-label { width: 80px; text-align: center; @@ -296,10 +408,12 @@ export default { line-height: 40px; border-bottom: 1px solid #ffffff; } + .time-scale { display: flex; flex: 1; } + .time-cell { flex: 1; text-align: center; @@ -308,20 +422,24 @@ export default { line-height: 40px; font-weight: bold; } + .rows { flex: 1; overflow-y: auto; } + .row { display: flex; min-height: 60px; - border-bottom: 1px solid #ddd; /* เส้นแบ่งระหว่างเครื่องจักร (แนวนอน) */ + border-bottom: 1px solid #ddd; } + .row-timeline { position: relative; flex: 1; overflow: hidden; } + .vertical-line { position: absolute; top: 0; @@ -329,6 +447,7 @@ export default { width: 1px; background-color: #ddd; } + .order { position: absolute; top: 10px; @@ -340,6 +459,7 @@ export default { align-items: center; z-index: 1; } + .order-content { flex: 1; text-align: center; @@ -347,44 +467,73 @@ export default { cursor: grab; padding: 0 10px; } + .resize-handle { width: 5px; height: 100%; - background-color: #fff; 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%; /* ทำให้ `.pagination` ขยายเต็มพื้นที่ */ + 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/OrderItem.vue b/src/components/OrderItem.vue new file mode 100644 index 0000000000000000000000000000000000000000..763e0fa7ca9757fedd5767833d070da8446edef7 --- /dev/null +++ b/src/components/OrderItem.vue @@ -0,0 +1,75 @@ +<template> + <div class="order" :style="getOrderStyle(order)"> + <div class="resize-handle left" @mousedown="$emit('resize-start', order, 'left')"></div> + <div class="order-content" draggable="true" @dragstart="$emit('drag-start', order)" @dragend="$emit('drag-end', order)"> + {{ order.name }} ({{ order.start }} - {{ order.end }}) + </div> + <div class="resize-handle right" @mousedown="$emit('resize-start', order, 'right')"></div> + </div> +</template> + +<script> +export default { + props: { + order: Object, + startTime: String, + endTime: String + }, + methods: { + timeToDecimal(timeStr) { + const [hours, minutes] = timeStr.split(':').map(Number); + return hours + minutes / 60; + }, + 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); + return { + left: ratioStart * 100 + '%', + width: (ratioEnd - ratioStart) * 100 + '%', + backgroundColor: order.color || '#4caf50' + }; + } + } +}; +</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; +} + +.resize-handle.right { + right: 0; +} +</style> diff --git a/src/types/Employee.ts b/src/types/Employee.ts new file mode 100644 index 0000000000000000000000000000000000000000..0ca3316d299be122140dcce058c72b736c141393 --- /dev/null +++ b/src/types/Employee.ts @@ -0,0 +1,12 @@ +type Gender = 'male' | 'female' | 'others' + +type Employee = { + id?: number + name: string + gender?: Gender + image?: string + Tel: string + dailyWageRate: number +} + +export type { Gender, Employee }