上記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
↓こうかくと、↑こう表示されます
<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(),
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>