first commit

This commit is contained in:
Noor E Ilahi
2026-01-09 12:54:53 +05:30
commit 7ccf44f7da
1070 changed files with 113036 additions and 0 deletions

View File

@@ -0,0 +1,138 @@
<template>
<div ref="container"></div>
</template>
<script>
import { Svg, SVG } from '@svgdotjs/svg.js';
import throttle from 'lodash/throttle';
const daysOfWeek = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
const cellWidth = 100 / daysOfWeek.length;
const cellHeight = 32;
export default {
props: {
tasksByDay: {
type: Array,
required: true,
},
},
created() {
window.addEventListener('resize', this.resize);
},
mounted() {
const { container } = this.$refs;
this.svg = SVG().addTo(container);
this.resize();
this.draw();
},
destroyed() {
window.removeEventListener('resize', this.resize);
},
watch: {
tasksByDay() {
this.resize();
this.draw();
},
},
methods: {
resize: throttle(function () {
const { container } = this.$refs;
const weeks = Math.ceil(this.tasksByDay.length / 7);
const rows = 1 + 2 * weeks;
const width = container.clientWidth;
const height = rows * cellHeight;
this.svg.viewbox(0, 0, width, height);
}, 100),
draw: throttle(function () {
const borderColor = '#eeeef5';
const blockColor = '#fff';
const textColor = 'rgb(63, 83, 110)';
/** @type {Svg} */
const svg = this.svg;
svg.clear();
const group = svg.group();
group.clipWith(svg.rect('100%', '100%').rx(20).ry(20));
let horizontalOffset = 0;
let verticalOffset = 0;
const drawHeader = () => {
for (const day of daysOfWeek) {
const rect = group
.rect(`${cellWidth.toFixed(2)}%`, cellHeight)
.move(`${horizontalOffset.toFixed(2)}%`, verticalOffset)
.fill(blockColor)
.stroke(borderColor);
group
.text(add => add.tspan(this.$t(`calendar.days.${day}`)))
.font({ anchor: 'middle', size: 16 })
.amove(`${(horizontalOffset + cellWidth / 2).toFixed(2)}%`, verticalOffset + cellHeight / 2)
.fill(textColor)
.clipWith(rect.clone());
horizontalOffset += cellWidth;
}
verticalOffset += cellHeight;
};
const drawDays = () => {
horizontalOffset = 0;
const days = this.tasksByDay;
for (let i = 0; i < days.length; i++) {
const { date, day, tasks } = days[i];
const onClick = () => this.$emit('show-tasks-modal', { date, tasks });
const rect = group
.rect(`${cellWidth.toFixed(2)}%`, 2 * cellHeight)
.move(`${horizontalOffset.toFixed(2)}%`, verticalOffset)
.fill(blockColor)
.stroke(borderColor)
.on('click', onClick);
const text = group
.text(add => add.tspan(day.toString()))
.font({ anchor: 'middle', size: 16 })
.amove(`${(horizontalOffset + cellWidth / 2).toFixed(2)}%`, verticalOffset + cellHeight / 2)
.fill(textColor)
.clipWith(rect.clone())
.on('click', onClick);
if (tasks.length > 0) {
rect.attr('cursor', 'pointer');
text.attr('cursor', 'pointer');
group
.circle(10)
.attr('cx', `${(horizontalOffset + cellWidth / 2).toFixed(2)}%`)
.attr('cy', verticalOffset + cellHeight * 1.5)
.fill('rgb(177, 177, 190)')
.on('click', onClick)
.attr('cursor', 'pointer');
}
if (i % daysOfWeek.length === daysOfWeek.length - 1) {
horizontalOffset = 0;
verticalOffset += 2 * cellHeight;
} else {
horizontalOffset += cellWidth;
}
}
};
drawHeader();
drawDays();
}, 100),
},
};
</script>

View File

@@ -0,0 +1,404 @@
<template>
<div class="calendar">
<div ref="container" class="calendar__svg"></div>
<div
v-show="hoverPopup.show"
:style="{
left: `${hoverPopup.x}px`,
top: `${hoverPopup.y}px`,
}"
class="calendar__popup popup"
>
<template v-if="hoverPopup.task">
<p class="popup__row">
<span class="popup__key">{{ $t('calendar.task.name') }}</span>
<span class="popup__value">{{ hoverPopup.task.task_name }}</span>
</p>
<p class="popup__row">
<span class="popup__key">{{ $t('calendar.task.status') }}</span>
<span class="popup__value">{{ hoverPopup.task.status.name }}</span>
</p>
<p class="popup__row">
<span class="popup__key">{{ $t('calendar.task.priority') }}</span>
<span class="popup__value">{{ hoverPopup.task.priority.name }}</span>
</p>
<p class="popup__row">
<span class="popup__key">{{ $t('calendar.task.estimate') }}</span>
<span class="popup__value">{{ formatDuration(hoverPopup.task.estimate) }}</span>
</p>
<p class="popup__row">
<span class="popup__key">{{ $t('calendar.task.total_spent_time') }}</span>
<span class="popup__value">{{ formatDuration(hoverPopup.task.total_spent_time) }}</span>
</p>
<p class="popup__row">
<span class="popup__key">{{ $t('calendar.task.start_date') }}</span>
<span class="popup__value">{{ formatDate(hoverPopup.task.start_date) }}</span>
</p>
<p class="popup__row">
<span class="popup__key">{{ $t('calendar.task.due_date') }}</span>
<span class="popup__value">{{ formatDate(hoverPopup.task.due_date) }}</span>
</p>
<p class="popup__row">
<span class="popup__key">{{ $t('calendar.task.forecast_completion_date') }}</span>
<span class="popup__value">{{ formatDate(hoverPopup.task.forecast_completion_date) }}</span>
</p>
</template>
</div>
</div>
</template>
<script>
import { Svg, SVG } from '@svgdotjs/svg.js';
import { formatDurationString } from '@/utils/time';
import throttle from 'lodash/throttle';
const msInDay = 24 * 60 * 60 * 1000;
const daysOfWeek = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
const months = [
'january',
'february',
'march',
'april',
'may',
'june',
'july',
'august',
'september',
'october',
'november',
'december',
];
const cellWidth = 100 / daysOfWeek.length;
const cellHeight = 32;
const maxTasks = 5;
export default {
props: {
tasksByWeek: {
type: Array,
required: true,
},
showAll: {
type: Boolean,
default: true,
},
},
data() {
return {
hoverPopup: {
show: false,
x: 0,
y: 0,
task: null,
},
};
},
created() {
window.addEventListener('resize', this.resize);
},
mounted() {
const { container } = this.$refs;
this.svg = SVG().addTo(container);
this.resize();
this.draw();
},
destroyed() {
window.removeEventListener('resize', this.resize);
},
watch: {
tasksByWeek() {
this.resize();
this.draw();
},
showAll() {
this.resize();
this.draw();
},
},
methods: {
formatDuration(value) {
return value !== null ? formatDurationString(value) : '—';
},
formatDate(value) {
return value !== null ? value : '—';
},
resize: throttle(function () {
const { container } = this.$refs;
const weeks = this.tasksByWeek.length;
const tasks = this.tasksByWeek.reduce(
(acc, item) => acc + (this.showAll ? item.tasks.length : Math.min(maxTasks, item.tasks.length)),
0,
);
const rows = 1 + weeks + tasks;
const width = container.clientWidth;
const height = rows * cellHeight;
this.svg.viewbox(0, 0, width, height);
}, 100),
draw: throttle(function () {
const backgroundColor = '#fafafa';
const borderColor = '#eeeef5';
const blockColor = '#fff';
const textColor = 'rgb(63, 83, 110)';
/** @type {Svg} */
const svg = this.svg;
svg.clear();
const group = svg.group();
group.clipWith(svg.rect('100%', '100%').rx(20).ry(20));
let horizontalOffset = 0;
let verticalOffset = 0;
const drawHeader = () => {
for (const day of daysOfWeek) {
const rect = group
.rect(`${cellWidth.toFixed(2)}%`, cellHeight)
.move(`${horizontalOffset.toFixed(2)}%`, verticalOffset)
.fill(blockColor)
.stroke(borderColor);
group
.text(add => add.tspan(this.$t(`calendar.days.${day}`)))
.font({ anchor: 'middle', size: 16 })
.amove(`${(horizontalOffset + cellWidth / 2).toFixed(2)}%`, verticalOffset + cellHeight / 2)
.fill(textColor)
.clipWith(rect.clone());
horizontalOffset += cellWidth;
}
verticalOffset += cellHeight;
};
/**
* @param {string[]} days
*/
const drawDaysRow = days => {
horizontalOffset = 0;
for (let i = 0; i < days.length; i++) {
const { month, day } = days[i];
const rect = group
.rect(`${cellWidth.toFixed(2)}%`, cellHeight)
.move(`${horizontalOffset.toFixed(2)}%`, verticalOffset)
.fill(i < 5 ? blockColor : backgroundColor)
.stroke(borderColor);
const dayText =
day === 1 ? `${this.$t(`calendar.months.${months[month - 1]}`)} ${day}` : day.toString();
group
.text(add => add.tspan(dayText).dmove(-8, 0))
.font({ anchor: 'end', size: 16 })
.amove(`${(horizontalOffset + cellWidth).toFixed(2)}%`, verticalOffset + cellHeight / 2)
.fill(textColor)
.clipWith(rect.clone());
horizontalOffset += cellWidth;
}
group.line(0, verticalOffset, '100%', verticalOffset).stroke({ width: 1, color: '#C5D9E8' });
group
.line(0, verticalOffset + cellHeight - 1, '100%', verticalOffset + cellHeight - 1)
.stroke({ width: 1, color: '#C5D9E8' });
verticalOffset += cellHeight;
};
/**
* @param {Object} task
* @param {number} startWeekDay
* @param {number} endWeekDay
*/
const drawTaskRow = (task, startWeekDay, endWeekDay) => {
const width = cellWidth * (endWeekDay - startWeekDay + 1);
horizontalOffset = cellWidth * startWeekDay;
group.rect(`100%`, cellHeight).move(0, verticalOffset).fill(backgroundColor).stroke(borderColor);
const taskHorizontalPadding = 0.2;
const taskVerticalPadding = 3;
const popupWidth = 420;
const popupHeight = 220;
const onClick = () => {
this.$router.push(`/tasks/view/${task.id}`);
};
const onMouseOver = event => {
const rectBBox = rect.bbox();
const popupX =
event.clientX < this.$refs.container.clientWidth - popupWidth
? event.clientX
: event.clientX - popupWidth - 40;
const popupY =
rectBBox.y + this.$refs.container.getBoundingClientRect().y <
window.innerHeight - popupHeight
? rectBBox.y
: rectBBox.y - popupHeight;
this.hoverPopup = {
show: true,
x: popupX,
y: popupY,
task,
};
};
const onMouseOut = event => {
this.hoverPopup = {
...this.hoverPopup,
show: false,
};
};
const rect = group
.rect(`${width - 2 * taskHorizontalPadding}%`, cellHeight - 2 * taskVerticalPadding)
.move(`${horizontalOffset + taskHorizontalPadding}%`, verticalOffset + taskVerticalPadding)
.fill(blockColor)
.stroke(borderColor)
.on('mouseover', event => {
onMouseOver(event);
event.target.style.cursor = 'pointer';
})
.on('mouseout', onMouseOut)
.on('click', onClick);
let pxOffset = 0;
if (new Date(task.due_date).getTime() + msInDay < new Date().getTime()) {
pxOffset += 2 * taskVerticalPadding;
group
.rect(cellHeight - 4 * taskVerticalPadding, cellHeight - 4 * taskVerticalPadding)
.move(
`${horizontalOffset + taskHorizontalPadding}%`,
verticalOffset + 2 * taskVerticalPadding,
)
.transform({ translateX: pxOffset })
.fill('#FF5569')
.stroke(borderColor)
.rx(4)
.ry(4)
.on('mouseover', event => {
onMouseOver(event);
event.target.style.cursor = 'pointer';
})
.on('mouseout', onMouseOut)
.on('click', onClick);
pxOffset += cellHeight - 4 * taskVerticalPadding;
}
if (task.estimate !== null && Number(task.total_spent_time) > Number(task.estimate)) {
pxOffset += 2 * taskVerticalPadding;
group
.rect(cellHeight - 4 * taskVerticalPadding, cellHeight - 4 * taskVerticalPadding)
.move(
`${horizontalOffset + taskHorizontalPadding}%`,
verticalOffset + 2 * taskVerticalPadding,
)
.transform({ translateX: pxOffset })
.fill('#FFC82C')
.stroke(borderColor)
.rx(4)
.ry(4)
.on('mouseover', event => {
onMouseOver(event);
event.target.style.cursor = 'pointer';
})
.on('mouseout', onMouseOut)
.on('click', onClick);
pxOffset += cellHeight - 4 * taskVerticalPadding;
}
group
.text(add => add.tspan(task.task_name).dmove(8, 0))
.font({ anchor: 'start', size: 16 })
.amove(`${horizontalOffset + taskHorizontalPadding}%`, verticalOffset + cellHeight / 2)
.transform({ translateX: pxOffset })
.fill(textColor)
.clipWith(rect.clone())
.on('mouseover', event => {
onMouseOver(event);
event.target.style.cursor = 'pointer';
})
.on('mouseout', onMouseOut)
.on('click', onClick);
verticalOffset += cellHeight;
};
drawHeader();
for (const { days, tasks } of this.tasksByWeek) {
drawDaysRow(days);
for (const { task, start_week_day, end_week_day } of this.showAll
? tasks
: tasks.slice(0, maxTasks)) {
drawTaskRow(task, start_week_day, end_week_day);
}
}
}, 100),
},
};
</script>
<style lang="scss" scoped>
.calendar {
display: flex;
align-items: center;
justify-content: center;
position: relative;
&__svg {
width: 100%;
height: 100%;
}
&__popup {
background: #ffffff;
border-radius: 20px;
border: 0;
box-shadow: 0px 7px 64px rgba(0, 0, 0, 0.07);
position: absolute;
display: block;
padding: 10px;
width: 100%;
max-width: 420px;
pointer-events: none;
z-index: 1;
}
}
.popup {
&__row {
display: flex;
justify-content: space-between;
}
&__value {
font-weight: bold;
text-align: right;
}
}
</style>

View File

@@ -0,0 +1,95 @@
<template>
<div v-if="visible" class="tasks-modal">
<div class="tasks-modal__header">
<h4 class="tasks-modal__title">{{ $t('calendar.tasks', { date: formattedDate }) }}</h4>
<at-button @click="close"><i class="icon icon-x"></i></at-button>
</div>
<ul class="tasks-modal__list">
<li v-for="task of tasks" :key="task.id" class="tasks-modal__item">
<router-link class="tasks-modal__link" :to="`/tasks/view/${task.id}`">
{{ task.task_name }}
</router-link>
</li>
</ul>
</div>
</template>
<script>
import moment from 'moment';
const MODAL_VISIBLE_CLASS = 'modal-visible';
export default {
props: {
date: {
type: String,
required: true,
},
tasks: {
type: Array,
required: true,
},
},
watch: {
visible(value) {
if (value) {
document.body.classList.add(MODAL_VISIBLE_CLASS);
} else {
document.body.classList.remove(MODAL_VISIBLE_CLASS);
}
},
},
beforeDestroy() {
document.body.classList.remove(MODAL_VISIBLE_CLASS);
},
computed: {
visible() {
return this.tasks.length > 0;
},
formattedDate() {
return moment(this.date).format('LL');
},
},
methods: {
close() {
this.$emit('close');
},
},
};
</script>
<style lang="scss" scoped>
.tasks-modal {
background: #fff;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: auto;
padding: 0.75em 24px;
z-index: 10;
&__header {
display: flex;
flex-flow: row nowrap;
align-items: center;
}
&__title {
flex: 1;
text-align: center;
}
}
</style>
<style lang="scss">
body.modal-visible {
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,43 @@
{
"navigation": {
"calendar": "Calendar"
},
"calendar": {
"days": {
"monday": "Mon",
"tuesday": "Tue",
"wednesday": "Wed",
"thursday": "Thu",
"friday": "Fri",
"saturday": "Sat",
"sunday": "Sun"
},
"months": {
"january": "Jan.",
"february": "Feb.",
"april": "Apr.",
"march": "Mar.",
"may": "May.",
"june": "Jun.",
"july": "Jul.",
"august": "Aug.",
"september": "Sep.",
"october": "Oct.",
"november": "Nov.",
"december": "Dec."
},
"task": {
"name": "Name",
"status": "Status",
"priority": "Priority",
"estimate": "Estimate",
"total_spent_time": "Total spent time",
"start_date": "Start date",
"due_date": "Due date",
"forecast_completion_date": "Forecast completion date"
},
"tasks": "Tasks {date}",
"show_all": "Show all",
"show_first": "Show first 5 tasks"
}
}

View File

@@ -0,0 +1,43 @@
{
"navigation": {
"calendar": "Календарь"
},
"calendar": {
"days": {
"monday": "Пн.",
"tuesday": "Вт.",
"wednesday": "Ср.",
"thursday": "Чт.",
"friday": "Пт.",
"saturday": "Сб.",
"sunday": "Вс."
},
"months": {
"january": "Янв.",
"february": "Февр.",
"april": "Апр.",
"march": "Март",
"may": "Май",
"june": "Июнь",
"july": "Июль",
"august": "Авг.",
"september": "Сент.",
"october": "Окт.",
"november": "Нояб.",
"december": "Дек."
},
"task": {
"name": "Название",
"status": "Статус",
"priority": "Приоритет",
"estimate": "Оценка времени",
"total_spent_time": "Всего потрачено времени",
"start_date": "Дата начала",
"due_date": "Дата завершения",
"forecast_completion_date": "Прогнозируемая дата завершения"
},
"tasks": "Задачи {date}",
"show_all": "Показать всё",
"show_first": "Показать первые 5 задач"
}
}

View File

@@ -0,0 +1,25 @@
export const ModuleConfig = {
routerPrefix: 'calendar',
loadOrder: 30,
moduleName: 'Calendar',
};
export function init(context) {
context.addRoute({
path: '/calendar',
name: context.getModuleRouteName() + '.index',
component: () => import(/* webpackChunkName: "calendar" */ './views/Calendar.vue'),
});
context.addNavbarEntry({
label: 'navigation.calendar',
to: { path: '/calendar' },
});
context.addLocalizationData({
en: require('./locales/en'),
ru: require('./locales/ru'),
});
return context;
}

View File

@@ -0,0 +1,12 @@
import axios from '@/config/app';
export default class CalendarService {
/**
* @param {string|Date} startAt
* @param {string|Date} endAt
* @param {null|number|number[]} projectId
*/
get(startAt, endAt, projectId = null) {
return axios.post('tasks/calendar', { start_at: startAt, end_at: endAt, project_id: projectId });
}
}

View File

@@ -0,0 +1,165 @@
<template>
<div class="calendar">
<h1 class="page-title">{{ $t('navigation.calendar') }}</h1>
<div class="controls-row">
<DatePicker
class="controls-row__item"
:day="false"
:week="false"
:range="false"
initialTab="month"
@change="onDateChange"
/>
<ProjectSelect class="controls-row__item" @change="onProjectsChange" />
<at-button class="controls-row__item show-all" @click="onShowAllClick">{{
showAll ? $t('calendar.show_first') : $t('calendar.show_all')
}}</at-button>
</div>
<div class="at-container">
<calendar-view
class="svg-container svg-container__desktop"
:tasks-by-week="tasksByWeek"
:show-all="showAll"
/>
<calendar-mobile-view
class="svg-container svg-container__mobile"
:tasks-by-day="tasksByDay"
@show-tasks-modal="showTasksModal"
/>
<tasks-modal :date="modal.date" :tasks="modal.tasks" @close="hideTasksModal" />
</div>
</div>
</template>
<script>
import moment from 'moment';
import DatePicker from '@/components/Calendar';
import ProjectSelect from '@/components/ProjectSelect';
import CalendarMobileView from '../components/CalendarMobileView.vue';
import CalendarView from '../components/CalendarView.vue';
import TasksModal from '../components/TasksModal.vue';
import CalendarService from '../services/calendar.service';
import { debounce } from 'lodash';
const ISO8601_DATE_FORMAT = 'YYYY-MM-DD';
export default {
components: {
CalendarMobileView,
CalendarView,
DatePicker,
TasksModal,
ProjectSelect,
},
data() {
return {
start: moment().startOf('month').format(ISO8601_DATE_FORMAT),
end: moment().endOf('month').format(ISO8601_DATE_FORMAT),
projects: [],
tasksByDay: [],
tasksByWeek: [],
modal: {
date: moment().format(ISO8601_DATE_FORMAT),
tasks: [],
},
showAll: false,
};
},
created() {
this.service = new CalendarService();
this.load = debounce(this.load, 300);
this.load();
},
methods: {
async load() {
const response = await this.service.get(this.start, this.end, this.projects);
const { tasks, tasks_by_day, tasks_by_week } = response.data.data;
this.tasksByDay = tasks_by_day.map(day => ({
...day,
tasks: day.task_ids.map(task_id => tasks[task_id]),
}));
this.tasksByWeek = tasks_by_week.map(week => ({
...week,
tasks: week.tasks.map(item => ({
...item,
task: tasks[item.task_id],
})),
}));
},
onDateChange({ start, end }) {
this.start = moment(start).startOf('month').format(ISO8601_DATE_FORMAT);
this.end = moment(end).endOf('month').format(ISO8601_DATE_FORMAT);
this.load();
},
onProjectsChange(projects) {
this.projects = projects;
this.load();
},
showTasksModal({ date, tasks }) {
this.modal.date = date;
this.modal.tasks = tasks;
},
hideTasksModal() {
this.modal.tasks = [];
},
onShowAllClick() {
this.showAll = !this.showAll;
},
},
};
</script>
<style lang="scss" scoped>
.show-all {
display: none;
@media screen and (min-width: 768px) {
display: block;
}
}
.svg-container {
align-items: center;
justify-content: center;
&__mobile {
display: flex;
@media screen and (min-width: 768px) {
display: none;
}
}
&__desktop {
display: none;
@media screen and (min-width: 768px) {
display: flex;
}
}
&::v-deep svg {
width: 100%;
height: 100%;
}
&::v-deep text {
dominant-baseline: central;
}
}
.controls-row {
flex-flow: row wrap;
}
</style>