- https://reffect.co.jp/vue/vue-js-calendar-from-scratch
- https://reffect.co.jp/vue/vue-js-gantt-chart-event
- https://reffect.co.jp/vue/vue-js-ganttchart-form
上記urlの写経です。
先日の工程計画作成pythonと併せて、 AI、かつ、インタラクティブな、工程計画ガントチャートwebアプリを 完成できると思います。
その他、Frappe Gantt( ※1 )のように先行&後続taskを、svgによる線で連結させたい。
※1 https://zenn.dev/phi/articles/how-to-use-frappe-gantt-js
https://end0tknr.github.io/sandbox/vue_js_3/vue_gantt.html
↓こうかくと、↑こう表示されます
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <script src="https://unpkg.com/vue@next"></script> <link href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet"> <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.1/moment.min.js"></script> <title>スクラッチから作るガントチャート</title> <style> .base { position: fixed; top: 0; left: 0; right: 0; display: flex; justify-content: center; margin-top: 50px; } .overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: gray; opacity: 0.5; } .content { background-color: white; position: relative; border-radius: 10px; padding: 40px; } </style> </head> <body> <div id="app"> <div id="gantt-header" class="h-12 p-2 flex items-center"> <h1 class="text-sm font-bold">ガントチャート</h1> <button @click="addTask" class="bg-indigo-700 hover:bg-indigo-900 text-white py-2 px-4 rounded-lg flex items-center"> <svg class="w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" /> </svg> <span class="font-bold text-xs"> タスクを追加する </span> </button> cf. https://reffect.co.jp/vue/vue-js-calendar-from-scratch <teleport to="#form"> <div class="base" v-show="show"> <div class="overlay" v-show="show" @click="show=false"> </div> <div class="content" v-show="show"> <h2 class="font-bold" v-if="update_mode">タスクの更新</h2> <h2 class="font-bold" v-else>タスクの追加</h2> <div class="my-4"> <label class="text-xs">カテゴリーID:</label> <select v-model="form.category_id" class="text-xs border px-4 py-2 rounded-lg"> <option v-for="category in categories" :key="category.id" :value="category.id">{{ category.name }} </option> </select> </div> <div class="my-4"> <label class="text-xs">ID:</label> <input class="text-xs border rounded-lg px-4 py-2" v-model.number="form.id"> </div> <div class="my-4"> <label class="text-xs">タスク名:</label> <input class="text-xs border rounded-lg px-4 py-2" v-model="form.name"> </div> <div class="my-4"> <label class="text-xs">担当者:</label> <input class="text-xs border rounded-lg px-4 py-2" v-model="form.incharge_user"> </div> <div class="my-4"> <label class="text-xs">開始日:</label> <input class="text-xs border rounded-lg px-4 py-2" v-model="form.start_date" type="date"> </div> <div class="my-4"> <label class="text-xs">完了期限日:</label> <input class="text-xs border rounded-lg px-4 py-2" v-model="form.end_date" type="date"> </div> <div class="my-4"> <label class="text-xs">進捗度:</label> <input class="text-xs border rounded-lg px-4 py-2" v-model="form.percentage" type="number"> </div> <div v-if="update_mode" class="flex items-center justify-between"> <button @click="updateTask(form.id)" class="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded-lg text-xs flex items-center"> <svg class="w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /> </svg> <span class="text-xs font-bold text-white">タスクを更新</span> </button> <button @click="deleteTask(form.id)" class="bg-red-500 hover:bg-red-700 text-white py-2 px-4 rounded-lg flex items-center ml-2"> <svg class="w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> </svg> <span class="text-xs font-bold text-white">タスクを削除</span> </button> </div> <div v-else> <button @click="saveTask" class="bg-indigo-500 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded-lg flex items-center"> <svg class="w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" /> </svg> <span class="font-bold text-xs"> タスクを追加する </span> </button> </div> </div> </div> </teleport> </div> <div id="gantt-content" class="flex"> <div id="gantt-task"> <div id="gantt-task-title" class="flex items-center bg-green-600 text-white h-20" ref="task"> <div class="border-t border-r border-b flex items-center justify-center font-bold text-xs w-48 h-full">タスク </div> <div class="border-t border-r border-b flex items-center justify-center font-bold text-xs w-24 h-full">開始日 </div> <div class="border-t border-r border-b flex items-center justify-center font-bold text-xs w-24 h-full">完了期限日 </div> <div class="border-t border-r border-b flex items-center justify-center font-bold text-xs w-16 h-full">担当 </div> <div class="border-t border-r border-b flex items-center justify-center font-bold text-xs w-12 h-full">進捗 </div> </div> <div id="gantt-task-list" class="overflow-y-hidden" :style="`height:${calendarViewHeight}px`"> <div v-for="(task, index) in displayTasks" :key="index" class="flex h-10 border-b" @dragstart="dragTask(task)" @dragover.prevent="dragTaskOver(task)" draggable=true> <template v-if="task.cat === 'category'"> <div class="flex items-center font-bold w-full text-sm pl-2 flex justify-between items-center bg-teal-100"> <span>{{task.name}}</span> <div class="pr-4" @click="toggleCategory(task.id)"> <span v-if="task.collapsed"> <svg class="w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> </svg> </span> <span v-else> <svg class="w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /> </svg> </span> </div> </div> </template> <template v-else> <div @click="editTask(task)" class="border-r flex items-center font-bold w-48 text-sm pl-4"> {{task.name}} </div> <div class="border-r flex items-center justify-center w-24 text-sm"> {{task.start_date}} </div> <div class="border-r flex items-center justify-center w-24 text-sm"> {{task.end_date}} </div> <div class="border-r flex items-center justify-center w-16 text-sm"> {{task.incharge_user}} </div> <div class="flex items-center justify-center w-12 text-sm"> {{task.percentage}}% </div> </template> </div> </div> </div> <div id="gantt-calendar" class="overflow-x-scroll overflow-y-hidden border-l" :style="`width:${calendarViewWidth}px`" ref="calendar"> <div id="gantt-date" class="h-20"> <div id="gantt-year-month" class="relative h-8"> <div v-for="(calendar,index) in calendars" :key="index"> <div class="bg-indigo-700 text-white border-b border-r border-t h-8 absolute font-bold text-sm flex items-center justify-center" :style="`width:${calendar.calendar*block_size}px;left:${calendar.start_block_number*block_size}px`"> {{calendar.date}} </div> </div> </div> <div id="gantt-day" class="relative h-12"> <div v-for="(calendar,index) in calendars" :key="index"> <div v-for="(day,index) in calendar.days" :key="index"> <div class="border-r border-b h-12 absolute flex items-center justify-center flex-col font-bold text-xs" :class="{'bg-blue-100': day.dayOfWeek === '土', 'bg-red-100': day.dayOfWeek ==='日', 'bg-red-600 text-white': calendar.year=== today.year() && calendar.month === today.month() && day.day === today.date()}" :style="`width:${block_size}px;left:${day.block_number*block_size}px`"> <span>{{ day.day }}</span> <span>{{ day.dayOfWeek }}</span> </div> </div> </div> </div> <div id="gantt-height" class="relative"> <div v-for="(calendar,index) in calendars" :key="index"> <div v-for="day in calendar.days" :key="index"> <div class="border-r border-b absolute" :class="{'bg-blue-100': day.dayOfWeek === '土', 'bg-red-100': day.dayOfWeek ==='日'}" :style="`width:${block_size}px;left:${day.block_number*block_size}px;height:${calendarViewHeight}px`"> </div> </div> </div> </div> </div> <div id="gantt-bar-area" class="relative" :style="`width:${calendarViewWidth}px;height:${calendarViewHeight}px`"> <div v-for="(bar,index) in taskBars" :key="index"> <div :style="bar.style" class="rounded-lg absolute h-5 bg-yellow-100" v-if="bar.task.cat === 'task'" @mousedown="mouseDownMove(bar.task)"> <div class="w-full h-full" style="pointer-events: none;"> <div class="h-full bg-yellow-500 rounded-l-lg" style="pointer-events: none;" :style="`width:${bar.task.percentage}%`" :class="{'rounded-r-lg': bar.task.percentage === 100}"></div> </div> <div class="absolute w-2 h-2 bg-gray-300 border border-black" style="top:6px;left:-6px;cursor:col-resize" @mousedown.stop="mouseDownResize(bar.task,'left')"> </div> <div class="absolute w-2 h-2 bg-gray-300 border border-black" style="top:6px;right:-6px;cursor:col-resize" @mousedown.stop="mouseDownResize(bar.task,'right')"> </div> </div> </div> </div> </div> </div> </div> <div id="form"> </div> </body> </html> <script> const app = Vue.createApp({ data(){ return { start_month: '2020-10', end_month: '2021-02', block_size: 30, block_number: 0, calendars:[], inner_width: '', inner_height: '', task_width: '', task_height: '', today:moment(), position_id:0, dragging:false, pageX:'', elememt:'', left:'', task_id:'', width:'', leftResizing:false, rightResizing:false, task:'', show:false, update_mode:false, form: { category_id: '', id: '', name: '', start_date: '', end_date: '', incharge_user: '', percentage: 0 }, categories: [ {id: 1, name: 'テストA', collapsed: false, }, {id: 2, name: 'テストB', collapsed: false, } ], tasks: [ {id: 1, category_id: 1, name: 'テスト1', start_date: '2020-11-18', end_date: '2020-11-20', incharge_user: '鈴木', percentage: 100,}, {id: 2, category_id: 1, name: 'テスト2', start_date: '2020-11-19', end_date: '2020-11-23', incharge_user: '佐藤', percentage: 90, }, {id: 3, category_id: 1, name: 'テスト3', start_date: '2020-11-19', end_date: '2020-12-04', incharge_user: '鈴木', percentage: 40, }, {id: 4, category_id: 1, name: 'テスト4', start_date: '2020-11-21',end_date: '2020-11-30', incharge_user: '山下', percentage: 60, }, {id: 5, category_id: 1, name: 'テスト5', start_date: '2020-11-25', end_date: '2020-12-04', incharge_user: '佐藤', percentage: 5, }, {id: 6, category_id: 2, name: 'テスト6', start_date: '2020-11-28', end_date: '2020-12-08', incharge_user: '佐藤', percentage: 0, }, ], } }, methods:{ deleteTask(id) { let delete_index; this.tasks.map((task, index) => { if (task.id === id) delete_index = index; }) this.tasks.splice(delete_index, 1) this.form = {} this.show = false; }, updateTask(id) { let task = this.tasks.find(task => task.id === id); Object.assign(task, this.form); this.form = {} this.show = false; }, editTask(task){ this.update_mode=true; this.show = true; Object.assign(this.form, task); }, saveTask() { this.tasks.push( this.form ) this.form = {} this.show = false }, addTask(){ this.update_mode = false; this.form = {} this.show = true; }, toggleCategory(task_id) { let category = this.categories.find(category => category.id === task_id) category['collapsed'] = !category['collapsed']; }, dragTaskOver(overTask) { let deleteIndex; let addIndex; if (this.task.cat !== 'category') { if (overTask.cat === 'category') { let updateTask = this.tasks.find(task => task.id === this.task.id) updateTask['category_id'] = overTask['id'] } else { if (overTask.id !== this.task.id) { this.tasks.map((task, index) => { if (task.id === this.task.id) deleteIndex = index }) this.tasks.map((task, index) => { if (task.id === overTask.id) addIndex = index }) this.tasks.splice(deleteIndex, 1) this.task['category_id'] = overTask['category_id'] this.tasks.splice(addIndex, 0, this.task) } } } }, dragTask(dragTask) { this.task = dragTask; }, mouseResize() { if (this.leftResizing) { let diff = this.pageX - event.pageX if (parseInt(this.width.replace('px', '')) + diff > this.block_size) { this.element.style.width = `${parseInt(this.width.replace('px', '')) + diff}px` this.element.style.left = `${this.left.replace('px', '') - diff}px`; } } if (this.rightResizing) { let diff = this.pageX - event.pageX; if (parseInt(this.width.replace('px', '')) - diff > this.block_size) { this.element.style.width = `${parseInt(this.width.replace('px', '')) - diff}px` } } }, mouseDownResize(task, direction) { direction === 'left' ? this.leftResizing = true : this.rightResizing = true; this.pageX = event.pageX; this.width = event.target.parentElement.style.width; this.left = event.target.parentElement.style.left; this.element = event.target.parentElement; this.task_id = task.id }, stopDrag(){ if (this.dragging) { let diff = this.pageX - event.pageX let days = Math.ceil(diff / this.block_size) if (days !== 0) { console.log(days) let task = this.tasks.find(task => task.id === this.task_id); let start_date = moment(task.start_date).add(-days, 'days') let end_date = moment(task.end_date).add(-days, 'days') task['start_date'] = start_date.format('YYYY-MM-DD') task['end_date'] = end_date.format('YYYY-MM-DD') } else { this.element.style.left = `${this.left.replace('px', '')}px`; } } if (this.leftResizing) { let diff = this.pageX - event.pageX; let days = Math.ceil(diff / this.block_size) if (days !== 0) { let task = this.tasks.find(task => task.id === this.task_id); let start_date = moment(task.start_date).add(-days, 'days') let end_date = moment(task.end_date) if (end_date.diff(start_date, 'days') <= 0) { task['start_date'] = end_date.format('YYYY-MM-DD') } else { task['start_date'] = start_date.format('YYYY-MM-DD') } } else { this.element.style.width = this.width; this.element.style.left = `${this.left.replace('px', '')}px`; } } if (this.rightResizing) { let diff = this.pageX - event.pageX; let days = Math.ceil(diff / this.block_size) if (days === 1) { this.element.style.width = `${parseInt(this.width.replace('px', ''))}px`; } else if (days <= 2) { days--; let task = this.tasks.find(task => task.id === this.task_id); let end_date = moment(task.end_date).add(-days, 'days') task['end_date'] = end_date.format('YYYY-MM-DD') } else { let task = this.tasks.find(task => task.id === this.task_id); let start_date = moment(task.start_date); let end_date = moment(task.end_date).add(-days, 'days') if (end_date.diff(start_date, 'days') < 0) { task['end_date'] = start_date.format('YYYY-MM-DD') } else { task['end_date'] = end_date.format('YYYY-MM-DD') } } } this.dragging = false; this.leftResizing = false; this.rightResizing = false; }, mouseMove() { if (this.dragging) { let diff = this.pageX - event.pageX; this.element.style.left = `${parseInt(this.left.replace('px', '')) - diff}px`; } }, mouseDownMove(task){ this.dragging = true; this.pageX = event.pageX; this.element = event.target; this.left = event.target.style.left; this.task_id = task.id console.log('mouseDownMove') }, windowSizeCheck() { let height = this.lists.length - this.position_id if (event.deltaY > 0 && height * 40 > this.calendarViewHeight) { this.position_id++ } else if (event.deltaY < 0 && this.position_id !== 0) { this.position_id-- } }, getDays(year, month, block_number) { const dayOfWeek = ['日', '月', '火', '水', '木', '金', '土']; let days = []; let date = moment(`${year}-${month}-01`); let num = date.daysInMonth(); for (let i = 0; i < num; i++) { days.push({ day: date.date(), dayOfWeek: dayOfWeek[date.day()], block_number }) date.add(1, 'day'); block_number++; } return days; }, getCalendar() { let block_number = 0; let days; let start_month = moment(this.start_month) let end_month = moment(this.end_month) let between_month = end_month.diff(start_month, 'months') for (let i = 0; i <= between_month; i++) { days = this.getDays(start_month.year(), start_month.format('MM'), block_number); this.calendars.push({ date: start_month.format('YYYY年MM月'), year: start_month.year(), month: start_month.month(), //month(), 0,1..11と表示 start_block_number: block_number, calendar: days.length, days: days }) start_month.add(1, 'months') block_number = days[days.length - 1].block_number block_number++; } return block_number; }, getWindowSize() { this.inner_width = window.innerWidth; this.inner_height = window.innerHeight; this.task_width = this.$refs.task.offsetWidth; this.task_height = this.$refs.task.offsetHeight; }, todayPosition() { this.$refs.calendar.scrollLeft = this.scrollDistance }, }, computed: { displayTasks() { let display_task_number = Math.floor(this.calendarViewHeight / 40); return this.lists.slice(this.position_id, this.position_id + display_task_number); }, taskBars() { let start_date = moment(this.start_month); let top = 10; let left; let between; let start; let style; return this.displayTasks.map(task => { style = {} if(task.cat==='task'){ let date_from = moment(task.start_date); let date_to = moment(task.end_date); between = date_to.diff(date_from, 'days'); between++; start = date_from.diff(start_date, 'days'); left = start * this.block_size; style = { top: `${top}px`, left: `${left}px`, width: `${this.block_size * between}px`, } } top = top + 40; return { style, task } }) }, calendarViewWidth() { return this.inner_width - this.task_width; }, calendarViewHeight() { return this.inner_height - this.task_height - 48 - 20; }, scrollDistance() { let start_date = moment(this.start_month); let between_days = this.today.diff(start_date, 'days') return between_days * this.block_size; }, scrollDistance() { let start_date = moment(this.start_month); let between_days = this.today.diff(start_date, 'days') return (between_days + 1) * this.block_size - this.calendarViewWidth / 2; }, lists() { let lists = []; this.categories.map(category => { lists.push({ cat: 'category', ...category }); this.tasks.map(task => { if (task.category_id === category.id && !category.collapsed) { lists.push({ cat: 'task', ...task }) } }) }) return lists; }, }, mounted() { this.getCalendar(); this.getWindowSize(); this.$nextTick(() => { this.todayPosition(); }); window.addEventListener('resize', this.getWindowSize); window.addEventListener('wheel', this.windowSizeCheck); window.addEventListener('mousemove', this.mouseMove); window.addEventListener('mousemove', this.mouseResize); window.addEventListener('mouseup', this.stopDrag); } }).mount('#app') </script>