Files
ulitka-app/src/views/calendar/CalendarView.vue
T

442 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>