442 lines
14 KiB
Vue
442 lines
14 KiB
Vue
<script setup lang="ts">
|
||
import { computed, ref, watch } from 'vue'
|
||
import { storeToRefs } from 'pinia'
|
||
import { useRouter } from 'vue-router'
|
||
import { watchDebounced } from '@vueuse/core'
|
||
import { useIssueStore } from '@/stores/issue.ts'
|
||
import { useProjectStore } from '@/stores/project.ts'
|
||
import { useStatusStore } from '@/stores/status.ts'
|
||
import type { Issue } from '@/api/issue.api.ts'
|
||
|
||
interface CalendarEvent {
|
||
name: string
|
||
start: string
|
||
color: string
|
||
issue: Issue
|
||
}
|
||
|
||
interface CalendarRange {
|
||
start: { date: string }
|
||
end: { date: string }
|
||
}
|
||
|
||
const UNSCHEDULED_LIMIT = 20
|
||
/** Closed issues are not worth scheduling, so the side list shows only these */
|
||
const UNSCHEDULED_STATUSES: Issue['status'][] = ['OPEN', 'IN_PROGRESS']
|
||
const DRAG_TYPE = 'application/x-ulitka-issue'
|
||
|
||
const issueStore = useIssueStore()
|
||
const statusStore = useStatusStore()
|
||
const { currentProjectId } = storeToRefs(useProjectStore())
|
||
const router = useRouter()
|
||
|
||
const calendarRef = ref<{ prev: () => void; next: () => void }>()
|
||
const focus = ref(toLocalDate(new Date()))
|
||
const range = ref<CalendarRange>()
|
||
|
||
const scheduled = ref<Issue[]>([])
|
||
const truncated = ref(false)
|
||
const loadingScheduled = ref(false)
|
||
|
||
const unscheduled = ref<Issue[]>([])
|
||
const unscheduledPage = ref(1)
|
||
const unscheduledTotalPages = ref(0)
|
||
const unscheduledTotal = ref(0)
|
||
const loadingUnscheduled = ref(false)
|
||
const search = ref<string | null>(null)
|
||
|
||
/** Day picked on the calendar — target for the "+" buttons in the unscheduled list */
|
||
const selectedDate = ref<string | null>(null)
|
||
const dragOverDate = ref<string | null>(null)
|
||
const dragOverPanel = ref(false)
|
||
const savingIds = ref<number[]>([])
|
||
|
||
const title = computed(() => {
|
||
const [year, month] = focus.value.split('-').map(Number)
|
||
return new Date(year!, month! - 1, 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
|
||
})
|
||
|
||
const events = computed<CalendarEvent[]>(() =>
|
||
scheduled.value
|
||
.filter((issue) => issue.dueDate)
|
||
.map((issue) => ({
|
||
name: `${issue.key} ${issue.title}`,
|
||
start: toLocalDate(new Date(issue.dueDate!)),
|
||
color: statusColor(issue.status),
|
||
issue,
|
||
})),
|
||
)
|
||
|
||
function statusColor(status: Issue['status']) {
|
||
return statusStore.statuses?.find((el) => el.alias === status)?.color ?? 'primary'
|
||
}
|
||
|
||
/** Local YYYY-MM-DD of a moment */
|
||
function toLocalDate(date: Date): string {
|
||
const pad = (value: number) => String(value).padStart(2, '0')
|
||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||
}
|
||
|
||
/** Local midnight of a YYYY-MM-DD day, `shiftDays` days later */
|
||
function localMidnight(date: string, shiftDays = 0): Date {
|
||
const [year, month, day] = date.split('-').map(Number)
|
||
return new Date(year!, month! - 1, day! + shiftDays)
|
||
}
|
||
|
||
function formatDate(date: string) {
|
||
return localMidnight(date).toLocaleDateString('ru-RU')
|
||
}
|
||
|
||
let scheduledRequestId = 0
|
||
|
||
async function loadScheduled() {
|
||
if (!range.value) return
|
||
// The month view shows whole weeks (Mon–Sun), so request the visible grid, not just the month
|
||
const start = localMidnight(range.value.start.date)
|
||
start.setDate(start.getDate() - ((start.getDay() + 6) % 7))
|
||
const end = localMidnight(range.value.end.date, 1)
|
||
end.setDate(end.getDate() + ((8 - end.getDay()) % 7))
|
||
|
||
const requestId = ++scheduledRequestId
|
||
loadingScheduled.value = true
|
||
const res = await issueStore.calendar({
|
||
from: start.toISOString(),
|
||
to: end.toISOString(),
|
||
projectId: currentProjectId.value,
|
||
})
|
||
if (requestId !== scheduledRequestId) return
|
||
loadingScheduled.value = false
|
||
scheduled.value = res?.list ?? []
|
||
truncated.value = res?.truncated ?? false
|
||
}
|
||
|
||
let unscheduledRequestId = 0
|
||
|
||
async function loadUnscheduled(page = 1) {
|
||
const requestId = ++unscheduledRequestId
|
||
loadingUnscheduled.value = true
|
||
const res = await issueStore.list({
|
||
page,
|
||
limit: UNSCHEDULED_LIMIT,
|
||
text: search.value,
|
||
projectId: currentProjectId.value,
|
||
status: UNSCHEDULED_STATUSES,
|
||
hasDueDate: false,
|
||
})
|
||
if (requestId !== unscheduledRequestId) return
|
||
loadingUnscheduled.value = false
|
||
unscheduled.value = page === 1 ? res.list : [...unscheduled.value, ...res.list]
|
||
unscheduledPage.value = page
|
||
unscheduledTotalPages.value = res.meta.totalPages
|
||
unscheduledTotal.value = res.meta.totalCount
|
||
}
|
||
|
||
function onRangeChange(value: CalendarRange) {
|
||
range.value = value
|
||
loadScheduled()
|
||
}
|
||
|
||
watch(currentProjectId, () => {
|
||
loadScheduled()
|
||
loadUnscheduled()
|
||
})
|
||
watchDebounced(search, () => loadUnscheduled(), { debounce: 300 })
|
||
loadUnscheduled()
|
||
|
||
async function setDueDate(issue: Issue, date: string | null) {
|
||
if (savingIds.value.includes(issue.id)) return
|
||
if (date && issue.dueDate && toLocalDate(new Date(issue.dueDate)) === date) return
|
||
|
||
savingIds.value.push(issue.id)
|
||
const updated = await issueStore.update(issue.id, {
|
||
dueDate: date ? localMidnight(date).toISOString() : null,
|
||
})
|
||
savingIds.value = savingIds.value.filter((id) => id !== issue.id)
|
||
if (!updated) return
|
||
|
||
const wasUnscheduled = unscheduled.value.some((el) => el.id === issue.id)
|
||
unscheduled.value = unscheduled.value.filter((el) => el.id !== issue.id)
|
||
scheduled.value = scheduled.value.filter((el) => el.id !== issue.id)
|
||
|
||
if (updated.dueDate) {
|
||
scheduled.value.push(updated)
|
||
if (wasUnscheduled) unscheduledTotal.value--
|
||
} else if (UNSCHEDULED_STATUSES.includes(updated.status)) {
|
||
unscheduled.value.unshift(updated)
|
||
unscheduledTotal.value++
|
||
}
|
||
}
|
||
|
||
function findIssue(id: number) {
|
||
return unscheduled.value.find((el) => el.id === id) ?? scheduled.value.find((el) => el.id === id)
|
||
}
|
||
|
||
function onDragStart(e: DragEvent, issue: Issue) {
|
||
if (!e.dataTransfer) return
|
||
e.dataTransfer.effectAllowed = 'move'
|
||
e.dataTransfer.setData(DRAG_TYPE, String(issue.id))
|
||
e.dataTransfer.setData('text/plain', issue.key)
|
||
}
|
||
|
||
function onDragEnd() {
|
||
dragOverDate.value = null
|
||
dragOverPanel.value = false
|
||
}
|
||
|
||
function isIssueDrag(e: Event): e is DragEvent {
|
||
return e instanceof DragEvent && !!e.dataTransfer?.types.includes(DRAG_TYPE)
|
||
}
|
||
|
||
function onDayDragOver(e: Event, day: { date: string }) {
|
||
if (!isIssueDrag(e)) return
|
||
e.preventDefault()
|
||
dragOverDate.value = day.date
|
||
}
|
||
|
||
function onDayDrop(e: Event, day: { date: string }) {
|
||
dragOverDate.value = null
|
||
if (!isIssueDrag(e)) return
|
||
const issue = findIssue(Number(e.dataTransfer?.getData(DRAG_TYPE)))
|
||
if (issue) setDueDate(issue, day.date)
|
||
}
|
||
|
||
function onPanelDragOver(e: DragEvent) {
|
||
if (!isIssueDrag(e)) return
|
||
e.preventDefault()
|
||
dragOverPanel.value = true
|
||
}
|
||
|
||
function onPanelDrop(e: DragEvent) {
|
||
dragOverPanel.value = false
|
||
const id = Number(e.dataTransfer?.getData(DRAG_TYPE))
|
||
// Only calendar events can be dropped back to unschedule them
|
||
const issue = scheduled.value.find((el) => el.id === id)
|
||
if (issue) setDueDate(issue, null)
|
||
}
|
||
|
||
function onDayClick(_e: Event, day: { date: string }) {
|
||
selectedDate.value = selectedDate.value === day.date ? null : day.date
|
||
}
|
||
|
||
function onEventClick(e: Event, scope: { event: Partial<CalendarEvent> }) {
|
||
// Don't let the click reach the day cell and toggle the selection
|
||
e.stopPropagation()
|
||
if (scope.event.issue) router.push(`/issues/${scope.event.issue.key}`)
|
||
}
|
||
|
||
function goToday() {
|
||
focus.value = toLocalDate(new Date())
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="calendar-layout">
|
||
<div class="d-flex flex-column ga-2 calendar-main">
|
||
<div class="d-flex align-center ga-2">
|
||
<v-btn density="comfortable" icon="mdi-chevron-left" @click="calendarRef?.prev()" />
|
||
<v-btn density="comfortable" icon="mdi-chevron-right" @click="calendarRef?.next()" />
|
||
<v-btn density="comfortable" @click="goToday">Today</v-btn>
|
||
<div class="text-h6 ml-2">{{ title }}</div>
|
||
<v-progress-circular v-if="loadingScheduled" indeterminate size="20" width="2" />
|
||
<v-spacer />
|
||
<v-chip v-if="truncated" color="warning" size="small" prepend-icon="mdi-alert">
|
||
Not all issues are shown
|
||
</v-chip>
|
||
</div>
|
||
|
||
<v-sheet class="flex-grow-1" border>
|
||
<v-calendar
|
||
ref="calendarRef"
|
||
v-model="focus"
|
||
type="month"
|
||
:weekdays="[1, 2, 3, 4, 5, 6, 0]"
|
||
:events="events"
|
||
event-text-color="white"
|
||
@change="onRangeChange"
|
||
@click:day="onDayClick"
|
||
@click:event="onEventClick"
|
||
@dragover:day="onDayDragOver"
|
||
@drop:day="onDayDrop"
|
||
>
|
||
<template #day-label="day">
|
||
<div class="d-flex justify-center">
|
||
<v-btn
|
||
icon
|
||
size="x-small"
|
||
:variant="selectedDate === day.date ? 'flat' : day.present ? 'outlined' : 'text'"
|
||
:color="selectedDate === day.date || day.present ? 'primary' : undefined"
|
||
@click.stop="onDayClick($event, day)"
|
||
>
|
||
{{ day.day }}
|
||
</v-btn>
|
||
</div>
|
||
</template>
|
||
|
||
<template #day="day">
|
||
<div
|
||
class="day-overlay"
|
||
:class="{
|
||
'day-overlay--selected': selectedDate === day.date,
|
||
'day-overlay--drop': dragOverDate === day.date,
|
||
}"
|
||
/>
|
||
</template>
|
||
|
||
<template #event="{ event }">
|
||
<div
|
||
class="pl-1 text-truncate cursor-grab"
|
||
:class="{ 'opacity-50': savingIds.includes(event.issue.id) }"
|
||
:title="event.name"
|
||
draggable="true"
|
||
@dragstart.stop="onDragStart($event, event.issue)"
|
||
@dragend="onDragEnd"
|
||
>
|
||
<span
|
||
class="font-weight-bold"
|
||
:class="{ 'text-decoration-line-through': ['DONE', 'CANCELLED'].includes(event.issue.status) }"
|
||
>
|
||
{{ event.issue.key }}
|
||
</span>
|
||
{{ event.issue.title }}
|
||
</div>
|
||
</template>
|
||
</v-calendar>
|
||
</v-sheet>
|
||
</div>
|
||
|
||
<v-card
|
||
border
|
||
class="d-flex flex-column calendar-side"
|
||
:class="{ 'calendar-side--drop': dragOverPanel }"
|
||
@dragover="onPanelDragOver"
|
||
@dragleave.self="dragOverPanel = false"
|
||
@drop="onPanelDrop"
|
||
>
|
||
<v-card-title class="d-flex align-center ga-2 text-subtitle-1">
|
||
Unscheduled
|
||
<v-chip size="x-small">{{ unscheduledTotal }}</v-chip>
|
||
</v-card-title>
|
||
|
||
<div class="px-4 pb-2 d-flex flex-column ga-2">
|
||
<v-text-field v-model="search" placeholder="Search" prepend-inner-icon="mdi-magnify" clearable />
|
||
<div class="text-caption text-medium-emphasis d-flex align-center ga-1">
|
||
<template v-if="selectedDate">
|
||
<span>
|
||
Selected: <b>{{ formatDate(selectedDate) }}</b> — press + to schedule
|
||
</span>
|
||
<v-btn density="compact" size="x-small" icon="mdi-close" variant="text" @click="selectedDate = null" />
|
||
</template>
|
||
<span v-else>Drag an issue onto a day, or pick a day and press +</span>
|
||
</div>
|
||
</div>
|
||
|
||
<v-divider />
|
||
|
||
<div class="flex-grow-1 overflow-y-auto">
|
||
<v-list class="pa-0">
|
||
<v-list-item
|
||
v-for="issue in unscheduled"
|
||
:key="issue.id"
|
||
class="cursor-grab"
|
||
:class="{ 'opacity-50': savingIds.includes(issue.id) }"
|
||
draggable="true"
|
||
@dragstart="onDragStart($event, issue)"
|
||
@dragend="onDragEnd"
|
||
>
|
||
<template #prepend>
|
||
<v-icon icon="mdi-drag-vertical" size="small" class="mr-1" />
|
||
</template>
|
||
<v-list-item-title>
|
||
<router-link :to="`/issues/${issue.key}`" class="text-link font-weight-bold mr-1" draggable="false">
|
||
{{ issue.key }}
|
||
</router-link>
|
||
<span :title="issue.title">{{ issue.title }}</span>
|
||
</v-list-item-title>
|
||
<template #append>
|
||
<v-btn
|
||
density="compact"
|
||
icon="mdi-plus"
|
||
variant="text"
|
||
:disabled="!selectedDate"
|
||
:loading="savingIds.includes(issue.id)"
|
||
:title="selectedDate ? `Schedule for ${formatDate(selectedDate)}` : 'Pick a day on the calendar first'"
|
||
@click="selectedDate && setDueDate(issue, selectedDate)"
|
||
/>
|
||
</template>
|
||
</v-list-item>
|
||
</v-list>
|
||
|
||
<div v-if="!loadingUnscheduled && !unscheduled.length" class="pa-4 text-center text-medium-emphasis">
|
||
No unscheduled issues
|
||
</div>
|
||
|
||
<div v-if="unscheduledPage < unscheduledTotalPages || loadingUnscheduled" class="d-flex justify-center pa-2">
|
||
<v-btn density="compact" :loading="loadingUnscheduled" @click="loadUnscheduled(unscheduledPage + 1)">
|
||
Load more
|
||
</v-btn>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="dragOverPanel" class="text-caption text-center pa-2 text-primary">Drop here to remove the date</div>
|
||
</v-card>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped lang="scss">
|
||
.calendar-layout {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) 22rem;
|
||
gap: 16px;
|
||
height: calc(100vh - 140px);
|
||
min-height: 600px;
|
||
}
|
||
|
||
.calendar-main {
|
||
min-width: 0;
|
||
}
|
||
|
||
.calendar-side {
|
||
min-height: 0;
|
||
transition: border-color 0.15s;
|
||
|
||
&--drop {
|
||
border-color: rgb(var(--v-theme-primary)) !important;
|
||
}
|
||
}
|
||
|
||
.day-overlay {
|
||
position: absolute;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
|
||
&--selected {
|
||
background: rgba(var(--v-theme-primary), 0.08);
|
||
}
|
||
|
||
&--drop {
|
||
background: rgba(var(--v-theme-primary), 0.18);
|
||
outline: 2px dashed rgb(var(--v-theme-primary));
|
||
outline-offset: -2px;
|
||
}
|
||
}
|
||
|
||
.cursor-grab {
|
||
cursor: grab;
|
||
}
|
||
|
||
@media (max-width: 960px) {
|
||
.calendar-layout {
|
||
grid-template-columns: 1fr;
|
||
height: auto;
|
||
}
|
||
|
||
.calendar-main {
|
||
min-height: 600px;
|
||
}
|
||
|
||
.calendar-side {
|
||
max-height: 60vh;
|
||
}
|
||
}
|
||
</style>
|