1 parent
c0e815ae63
commit
0fe2c70848
5 files changed
+463
-42
No files matched your search
@@ -68,6 +68,12 @@ export interface DailyIssueStats {
|
||||
days: DailyIssueStat[]
|
||||
}
|
||||
|
||||
export interface CalendarIssues {
|
||||
list: Issue[]
|
||||
/** The range holds more issues than returned — narrow the filter */
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
// CLIENT REQUEST
|
||||
|
||||
export async function itemsList(params: {
|
||||
@@ -77,6 +83,7 @@ export async function itemsList(params: {
|
||||
status?: string | string[] | null
|
||||
assigneeId?: string | null
|
||||
projectId?: string | null
|
||||
hasDueDate?: boolean
|
||||
}) {
|
||||
const query = toQuery({
|
||||
page: params.page,
|
||||
@@ -85,10 +92,29 @@ export async function itemsList(params: {
|
||||
text: params.text,
|
||||
assigneeId: params.assigneeId,
|
||||
status: Array.isArray(params.status) ? params.status.join(',') : params.status,
|
||||
hasDueDate: params.hasDueDate === undefined ? undefined : String(params.hasDueDate),
|
||||
})
|
||||
return apiClient.get<{ list: Issue[]; meta: Meta }>(`/api/issues/list${query}`)
|
||||
}
|
||||
|
||||
/** Issues with dueDate in [from, to); from/to are ISO moments with an offset */
|
||||
export async function itemsCalendar(params: {
|
||||
from: string
|
||||
to: string
|
||||
projectId?: string | null
|
||||
assigneeId?: string | null
|
||||
status?: string[] | null
|
||||
}) {
|
||||
const query = toQuery({
|
||||
from: params.from,
|
||||
to: params.to,
|
||||
projectId: params.projectId,
|
||||
assigneeId: params.assigneeId,
|
||||
status: params.status?.join(','),
|
||||
})
|
||||
return apiClient.get<CalendarIssues>(`/api/issues/calendar${query}`)
|
||||
}
|
||||
|
||||
export async function itemGetByKey(ckey: string) {
|
||||
return apiClient.get<Issue>(`/api/issues/by-key/${ckey}`)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,10 @@ const backTo = computed(() => {
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
if (backTo.value) router.push({ name: backTo.value })
|
||||
// vue-router keeps the previous in-app location in history.state.back;
|
||||
// it's null when the page was opened directly, so fall back to backTo
|
||||
if (window.history.state?.back) router.back()
|
||||
else if (backTo.value) router.push({ name: backTo.value })
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
+11
-1
@@ -6,6 +6,7 @@ import {
|
||||
type DailyIssueStats,
|
||||
itemCreate,
|
||||
itemDailyStat,
|
||||
itemsCalendar,
|
||||
itemDelete,
|
||||
itemGetByKey,
|
||||
itemsList,
|
||||
@@ -34,6 +35,7 @@ export const useIssueStore = defineStore('item', () => {
|
||||
status?: string | string[] | null
|
||||
assigneeId?: string | null
|
||||
projectId?: string | null
|
||||
hasDueDate?: boolean
|
||||
}) {
|
||||
try {
|
||||
const res = await itemsList(params)
|
||||
@@ -45,6 +47,14 @@ export const useIssueStore = defineStore('item', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function calendar(params: { from: string; to: string; projectId?: string | null }) {
|
||||
try {
|
||||
return await itemsCalendar(params)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'error get calendar issues'
|
||||
}
|
||||
}
|
||||
|
||||
async function getByKey(ckey: string) {
|
||||
try {
|
||||
return await itemGetByKey(ckey)
|
||||
@@ -110,7 +120,7 @@ export const useIssueStore = defineStore('item', () => {
|
||||
}
|
||||
}
|
||||
|
||||
return { stats, dailyStats, list, getByKey, create, update, remove, getStat, getDailyStat }
|
||||
return { stats, dailyStats, list, calendar, getByKey, create, update, remove, getStat, getDailyStat }
|
||||
})
|
||||
|
||||
/** Local YYYY-MM-DD `days` days before today */
|
||||
|
||||
@@ -153,8 +153,6 @@ function onClickCard(status?: string) {
|
||||
<dashboard-count-card label="Overdue" :value="overdueCount" :alert="overdueCount > 0" />
|
||||
</div>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-row density="compact">
|
||||
<v-col cols="12" md="6">
|
||||
<v-card border>
|
||||
|
||||
@@ -1,54 +1,438 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
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: Date | string | number
|
||||
end?: Date | string | number
|
||||
color?: string
|
||||
timed?: boolean
|
||||
start: string
|
||||
color: string
|
||||
issue: Issue
|
||||
}
|
||||
|
||||
const type = ref('month')
|
||||
const types = ['month', 'week', 'day', '4day']
|
||||
const mode = ref('stack')
|
||||
const modes = ['stack', 'column']
|
||||
const weekday = ref([0, 1, 2, 3, 4, 5, 6])
|
||||
const weekdays = [
|
||||
{ title: 'Sun - Sat', value: [0, 1, 2, 3, 4, 5, 6] },
|
||||
{ title: 'Mon - Sun', value: [1, 2, 3, 4, 5, 6, 0] },
|
||||
{ title: 'Mon - Fri', value: [1, 2, 3, 4, 5] },
|
||||
{ title: 'Mon, Wed, Fri', value: [1, 3, 5] },
|
||||
]
|
||||
const value = ref('')
|
||||
const events = ref<CalendarEvent[]>([])
|
||||
const colors = ['blue', 'indigo', 'deep-purple', 'cyan', 'green', 'orange', 'grey-darken-1']
|
||||
const names = ['Meeting', 'Holiday', 'PTO', 'Travel', 'Event', 'Birthday', 'Conference', 'Party']
|
||||
|
||||
function rnd(a: number, b: number) {
|
||||
return Math.floor((b - a + 1) * Math.random()) + a
|
||||
interface CalendarRange {
|
||||
start: { date: string }
|
||||
end: { date: string }
|
||||
}
|
||||
|
||||
function getEvents() {
|
||||
events.value = [
|
||||
{
|
||||
name: 'test',
|
||||
start: new Date('2026-10-09T13:41:45.213Z'),
|
||||
end: new Date('2026-10-11T13:41:45.213Z'),
|
||||
color: 'primary',
|
||||
timed: true,
|
||||
},
|
||||
]
|
||||
const UNSCHEDULED_LIMIT = 20
|
||||
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,
|
||||
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 {
|
||||
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>
|
||||
<v-sheet height="600">
|
||||
<v-calendar format="24hr" type="month" first-day-of-week="1" @change="getEvents" :events="events">
|
||||
<template #day> aaaa </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>
|
||||
<style scoped lang="scss"></style>
|
||||
<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>
|
||||
Reference in new issue
Block a user