Files
clientsflow/frontend/app/components/workspace/calendar/CrmCalendarPanel.vue
Ruslan Bakiev 9505cecab2 feat(calendar): header continuity with week numbers + skeleton content in fly-rect
- Add ISO week numbers to the left of week rows in month view (8, 9, 10...)
  with spacer alignment on day-of-week headers
- Inject label + skeleton placeholder lines into fly-rect during zoom animations:
  zoom-in shows source label (month name / "Week N" / day name) + pulsing bars
  zoom-out shows target context label + skeleton
- Skeleton CSS uses pulse animation (0.8s alternate) for loading hint
- Non-scoped style block for dynamically injected innerHTML elements
- isoWeekNumber helper for ISO 8601 week calculation
- Extended MonthRow type with weekNumber property

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:28:31 +07:00

624 lines
21 KiB
Vue

<script setup lang="ts">
type CalendarEvent = {
id: string;
title: string;
start: string;
end: string;
contact: string;
note: string;
};
type YearMonthItem = {
monthIndex: number;
label: string;
count: number;
first?: CalendarEvent;
};
type MonthCell = {
key: string;
day: number;
inMonth: boolean;
events: CalendarEvent[];
};
type MonthRow = {
key: string;
startKey: string;
weekNumber: number;
cells: MonthCell[];
};
type WeekDay = {
key: string;
label: string;
day: number;
events: CalendarEvent[];
};
defineProps<{
contextPickerEnabled: boolean;
hasContextScope: (scope: "calendar") => boolean;
toggleContextScope: (scope: "calendar") => void;
contextScopeLabel: (scope: "calendar") => string;
setToday: () => void;
calendarPeriodLabel: string;
calendarZoomLevelIndex: number;
onCalendarZoomSliderInput: (event: Event) => void;
focusedCalendarEvent: CalendarEvent | null;
formatDay: (iso: string) => string;
formatTime: (iso: string) => string;
setCalendarContentWrapRef: (element: HTMLDivElement | null) => void;
shiftCalendar: (step: number) => void;
setCalendarContentScrollRef: (element: HTMLDivElement | null) => void;
onCalendarHierarchyWheel: (event: WheelEvent) => void;
setCalendarSceneRef: (element: HTMLDivElement | null) => void;
calendarViewportHeight: number;
normalizedCalendarView: string;
onCalendarSceneMouseLeave: () => void;
calendarView: string;
yearMonths: YearMonthItem[];
calendarCursorMonth: number;
calendarHoveredMonthIndex: number | null;
setCalendarHoveredMonthIndex: (value: number | null) => void;
calendarZoomPrimeToken: string;
calendarPrimeMonthToken: (monthIndex: number) => string;
calendarPrimeStyle: (token: string) => Record<string, string>;
zoomToMonth: (monthIndex: number) => void;
openThreadFromCalendarItem: (event: CalendarEvent) => void;
monthRows: MonthRow[];
calendarHoveredWeekStartKey: string;
setCalendarHoveredWeekStartKey: (value: string) => void;
calendarPrimeWeekToken: (startKey: string) => string;
selectedDateKey: string;
monthCellHasFocusedEvent: (events: CalendarEvent[]) => boolean;
calendarHoveredDayKey: string;
setCalendarHoveredDayKey: (value: string) => void;
pickDate: (key: string) => void;
monthCellEvents: (events: CalendarEvent[]) => CalendarEvent[];
isReviewHighlightedEvent: (eventId: string) => boolean;
weekDays: WeekDay[];
calendarPrimeDayToken: (dayKey: string) => string;
selectedDayEvents: CalendarEvent[];
calendarFlyVisible: boolean;
setCalendarFlyRectRef: (element: HTMLDivElement | null) => void;
}>();
</script>
<template>
<section
class="relative flex h-full min-h-0 flex-col gap-3"
:class="[
contextPickerEnabled ? 'context-scope-block context-scope-block-active' : '',
hasContextScope('calendar') ? 'context-scope-block-selected' : '',
]"
@click="toggleContextScope('calendar')"
>
<span
v-if="contextPickerEnabled"
class="context-scope-label"
>{{ contextScopeLabel('calendar') }}</span>
<div class="grid grid-cols-[auto_1fr_auto] items-center gap-2">
<div class="flex items-center gap-1">
<button class="btn btn-xs" @click="setToday">Today</button>
</div>
<div class="text-center text-sm font-medium">
{{ calendarPeriodLabel }}
</div>
<div class="justify-self-end calendar-zoom-inline" @click.stop>
<input
class="calendar-zoom-slider"
type="range"
min="0"
max="3"
step="1"
:value="calendarZoomLevelIndex"
aria-label="Calendar zoom level"
@input="onCalendarZoomSliderInput"
>
<div class="calendar-zoom-marks" aria-hidden="true">
<span
v-for="index in 4"
:key="`calendar-zoom-mark-${index}`"
class="calendar-zoom-mark"
:class="calendarZoomLevelIndex === index - 1 ? 'calendar-zoom-mark-active' : ''"
/>
</div>
</div>
</div>
<article
v-if="focusedCalendarEvent"
class="rounded-xl border border-success/50 bg-success/10 px-3 py-2"
>
<p class="text-xs font-semibold uppercase tracking-wide text-success/80">Review focus event</p>
<p class="text-sm font-medium text-base-content">{{ focusedCalendarEvent.title }}</p>
<p class="text-xs text-base-content/70">
{{ formatDay(focusedCalendarEvent.start) }} · {{ formatTime(focusedCalendarEvent.start) }} - {{ formatTime(focusedCalendarEvent.end) }}
</p>
<p class="mt-1 text-xs text-base-content/80">{{ focusedCalendarEvent.note || "No note" }}</p>
</article>
<div :ref="setCalendarContentWrapRef" class="calendar-content-wrap min-h-0 flex-1">
<button
class="calendar-side-nav calendar-side-nav-left"
type="button"
title="Previous period"
@click="shiftCalendar(-1)"
>
<span></span>
</button>
<button
class="calendar-side-nav calendar-side-nav-right"
type="button"
title="Next period"
@click="shiftCalendar(1)"
>
<span></span>
</button>
<!-- GSAP flying rect (zoom transition overlay) -->
<div
v-show="calendarFlyVisible"
:ref="setCalendarFlyRectRef"
class="calendar-fly-rect"
/>
<div
:ref="setCalendarContentScrollRef"
class="calendar-content-scroll min-h-0 h-full overflow-y-auto pr-1"
@wheel.prevent="onCalendarHierarchyWheel"
>
<div
:ref="setCalendarSceneRef"
:class="[
'calendar-scene',
normalizedCalendarView === 'day' ? 'cursor-zoom-out' : 'cursor-zoom-in',
]"
@mouseleave="onCalendarSceneMouseLeave"
>
<div
class="grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3 auto-rows-fr"
:style="calendarViewportHeight > 0 ? { minHeight: `${calendarView === 'year' ? Math.max(420, calendarViewportHeight) : calendarViewportHeight}px` } : undefined"
>
<article
v-for="item in yearMonths"
:key="`year-month-${item.monthIndex}`"
v-show="calendarView === 'year' || item.monthIndex === calendarCursorMonth"
class="group relative rounded-xl border border-base-300 p-3 text-left transition calendar-hover-targetable"
:class="[
calendarView === 'year'
? 'h-full hover:border-primary/50 hover:bg-primary/5 cursor-zoom-in'
: 'cursor-default bg-base-100 sm:col-span-2 xl:col-span-3 flex flex-col',
calendarHoveredMonthIndex === item.monthIndex ? 'calendar-hover-target' : '',
calendarZoomPrimeToken === calendarPrimeMonthToken(item.monthIndex) ? 'calendar-zoom-prime-active' : '',
]"
:style="{
...calendarPrimeStyle(calendarPrimeMonthToken(item.monthIndex)),
...(calendarView !== 'year' && item.monthIndex === calendarCursorMonth && calendarViewportHeight > 0
? { minHeight: `${calendarViewportHeight}px` }
: {}),
}"
:data-calendar-month-index="item.monthIndex"
@mouseenter="setCalendarHoveredMonthIndex(item.monthIndex)"
@click="calendarView === 'year' ? zoomToMonth(item.monthIndex) : undefined"
>
<p class="font-medium">{{ item.label }}</p>
<p class="text-xs text-base-content/60">{{ item.count }} events</p>
<button
v-if="calendarView === 'year' && item.first"
class="mt-1 block w-full text-left text-xs text-base-content/70 hover:underline"
@click.stop="openThreadFromCalendarItem(item.first)"
>
{{ formatDay(item.first.start) }} · {{ item.first.title }}
</button>
<div v-if="item.monthIndex === calendarCursorMonth" class="mt-3 calendar-depth-stack">
<div
class="space-y-1 calendar-depth-layer"
data-calendar-layer="month"
:class="calendarView === 'month' || calendarView === 'agenda' ? 'calendar-depth-layer-active' : 'calendar-depth-layer-hidden'"
>
<div class="flex items-center gap-1">
<span class="calendar-week-number" aria-hidden="true"></span>
<div class="grid grid-cols-7 gap-1 text-center text-xs font-semibold text-base-content/60 flex-1">
<span>Sun</span>
<span>Mon</span>
<span>Tue</span>
<span>Wed</span>
<span>Thu</span>
<span>Fri</span>
<span>Sat</span>
</div>
</div>
<div class="flex flex-1 flex-col gap-1">
<div
v-for="row in monthRows"
:key="row.key"
class="group relative flex-1 flex items-stretch gap-1 calendar-hover-targetable"
:class="[
calendarHoveredWeekStartKey === row.startKey ? 'calendar-hover-target' : '',
calendarZoomPrimeToken === calendarPrimeWeekToken(row.startKey) ? 'calendar-zoom-prime-active' : '',
]"
:style="calendarPrimeStyle(calendarPrimeWeekToken(row.startKey))"
:data-calendar-week-start-key="row.startKey"
@mouseenter="setCalendarHoveredWeekStartKey(row.startKey)"
>
<span class="calendar-week-number">{{ row.weekNumber }}</span>
<div class="grid grid-cols-7 gap-1 h-full flex-1">
<button
v-for="cell in row.cells"
:key="cell.key"
class="group relative rounded-lg border p-1 text-left"
:class="[
cell.inMonth ? 'border-base-300 bg-base-100' : 'border-base-200 bg-base-200/40 text-base-content/40',
selectedDateKey === cell.key ? 'border-primary bg-primary/5' : '',
monthCellHasFocusedEvent(cell.events) ? 'border-success/60 bg-success/10' : '',
]"
:data-calendar-day-key="cell.key"
@mouseenter="setCalendarHoveredDayKey(cell.key)"
@click="pickDate(cell.key)"
>
<p class="mb-1 text-xs font-semibold">{{ cell.day }}</p>
<button
v-for="event in monthCellEvents(cell.events)"
:key="event.id"
class="block w-full truncate rounded px-1 text-left text-[10px] text-base-content/70 transition hover:underline"
:class="isReviewHighlightedEvent(event.id) ? 'bg-success/20 text-success-content ring-1 ring-success/40' : ''"
@click.stop="openThreadFromCalendarItem(event)"
>
{{ formatTime(event.start) }} {{ event.title }}
</button>
</button>
</div>
</div>
</div>
</div>
<div
class="calendar-week-scroll h-full min-h-0 overflow-x-auto pb-1 calendar-depth-layer"
data-calendar-layer="week"
:class="calendarView === 'week' ? 'calendar-depth-layer-active' : 'calendar-depth-layer-hidden'"
>
<div class="calendar-week-grid">
<article
v-for="day in weekDays"
:key="day.key"
class="group relative flex h-full min-h-full flex-col rounded-xl border border-base-300 bg-base-100 p-2.5 cursor-zoom-in calendar-hover-targetable"
:class="[
selectedDateKey === day.key ? 'border-primary bg-primary/5' : '',
calendarHoveredDayKey === day.key ? 'calendar-hover-target' : '',
calendarZoomPrimeToken === calendarPrimeDayToken(day.key) ? 'calendar-zoom-prime-active' : '',
]"
:style="calendarPrimeStyle(calendarPrimeDayToken(day.key))"
:data-calendar-day-key="day.key"
@mouseenter="setCalendarHoveredDayKey(day.key)"
@click="pickDate(day.key)"
>
<div class="mb-2 flex items-start justify-between gap-2">
<p class="text-sm font-semibold leading-tight">{{ day.label }} {{ day.day }}</p>
</div>
<div class="space-y-1.5">
<button
v-for="event in day.events"
:key="event.id"
class="block w-full rounded-md px-2 py-1.5 text-left text-xs"
:class="isReviewHighlightedEvent(event.id) ? 'bg-success/20 ring-1 ring-success/45' : 'bg-base-200 hover:bg-base-300/80'"
@click.stop="openThreadFromCalendarItem(event)"
>
{{ formatTime(event.start) }} - {{ event.title }} ({{ event.contact }})
</button>
<p v-if="day.events.length === 0" class="pt-1 text-xs text-base-content/50">No events</p>
</div>
</article>
</div>
</div>
<div
class="space-y-2 calendar-depth-layer"
data-calendar-layer="day"
:class="calendarView === 'day' ? 'calendar-depth-layer-active' : 'calendar-depth-layer-hidden'"
>
<button
v-for="event in selectedDayEvents"
:key="event.id"
class="block w-full rounded-xl border border-base-300 p-3 text-left transition hover:bg-base-200/60"
:class="isReviewHighlightedEvent(event.id) ? 'border-success/60 bg-success/10' : ''"
@click="openThreadFromCalendarItem(event)"
>
<p class="font-medium">{{ event.title }}</p>
<p class="text-xs text-base-content/60">{{ event.contact }}</p>
<p class="text-xs text-base-content/60">{{ formatTime(event.start) }} - {{ formatTime(event.end) }}</p>
<p class="mt-1 text-sm text-base-content/80">{{ event.note }}</p>
</button>
<p v-if="selectedDayEvents.length === 0" class="text-sm text-base-content/60">No events on this day.</p>
</div>
</div>
</article>
</div>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.calendar-content-wrap {
position: relative;
padding-left: 40px;
padding-right: 40px;
}
.calendar-content-scroll {
height: 100%;
overscroll-behavior: contain;
}
.calendar-scene {
min-height: 100%;
min-width: 100%;
transform-origin: center center;
}
.calendar-scene.cursor-zoom-in,
.calendar-scene.cursor-zoom-in * {
cursor: zoom-in;
}
.calendar-scene.cursor-zoom-out,
.calendar-scene.cursor-zoom-out * {
cursor: zoom-out;
}
.calendar-week-grid {
display: grid;
grid-template-columns: repeat(7, minmax(165px, 1fr));
gap: 8px;
min-width: 1180px;
min-height: 100%;
align-items: stretch;
}
.calendar-depth-stack {
position: relative;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.calendar-depth-layer {
transition: opacity 260ms ease, transform 260ms ease;
}
.calendar-depth-layer-active {
opacity: 1;
transform: translateY(0);
position: relative;
z-index: 1;
pointer-events: auto;
flex: 1;
display: flex;
flex-direction: column;
}
.calendar-depth-layer-hidden {
opacity: 0;
transform: translateY(10px);
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
}
.calendar-side-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 4;
width: 28px;
height: 28px;
border: 1px solid color-mix(in oklab, var(--color-base-content) 16%, transparent);
border-radius: 999px;
background: color-mix(in oklab, var(--color-base-100) 88%, transparent);
color: color-mix(in oklab, var(--color-base-content) 86%, transparent);
display: inline-flex;
align-items: center;
justify-content: center;
transition: background-color 120ms ease, border-color 120ms ease, transform 120ms ease;
}
.calendar-side-nav:hover {
border-color: color-mix(in oklab, var(--color-primary) 50%, transparent);
background: color-mix(in oklab, var(--color-primary) 14%, var(--color-base-100));
transform: translateY(-50%) scale(1.03);
}
.calendar-side-nav-left {
left: 4px;
}
.calendar-side-nav-right {
right: 4px;
}
.calendar-week-number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
min-width: 24px;
font-size: 10px;
font-weight: 500;
color: color-mix(in oklab, var(--color-base-content) 40%, transparent);
user-select: none;
}
.calendar-hover-targetable {
transform-origin: center center;
transition: transform 320ms ease, box-shadow 320ms ease, outline-color 320ms ease;
}
.calendar-hover-target {
outline: 2px solid color-mix(in oklab, var(--color-primary) 66%, transparent);
outline-offset: 1px;
box-shadow: 0 0 0 1px color-mix(in oklab, var(--color-primary) 32%, transparent) inset;
}
.calendar-zoom-prime-active {
z-index: 2;
}
.calendar-fly-rect {
position: absolute;
z-index: 20;
pointer-events: none;
will-change: left, top, width, height;
}
.calendar-zoom-inline {
position: relative;
display: flex;
align-items: center;
width: 128px;
height: 22px;
padding: 0 10px;
}
.calendar-zoom-slider {
width: 100%;
height: 18px;
margin: 0;
background: transparent;
-webkit-appearance: none;
appearance: none;
cursor: pointer;
}
.calendar-zoom-slider:focus-visible {
outline: none;
}
.calendar-zoom-slider::-webkit-slider-runnable-track {
height: 2px;
border-radius: 999px;
background: color-mix(in oklab, var(--color-base-content) 22%, transparent);
}
.calendar-zoom-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 10px;
height: 10px;
margin-top: -4px;
border-radius: 999px;
background: color-mix(in oklab, var(--color-base-content) 88%, transparent);
border: 0;
}
.calendar-zoom-slider::-moz-range-track {
height: 2px;
border-radius: 999px;
background: color-mix(in oklab, var(--color-base-content) 22%, transparent);
}
.calendar-zoom-slider::-moz-range-progress {
height: 2px;
border-radius: 999px;
background: transparent;
}
.calendar-zoom-slider::-moz-range-thumb {
width: 10px;
height: 10px;
border: 0;
border-radius: 999px;
background: color-mix(in oklab, var(--color-base-content) 88%, transparent);
}
.calendar-zoom-marks {
position: absolute;
inset: 0 10px;
display: flex;
align-items: center;
justify-content: space-between;
pointer-events: none;
}
.calendar-zoom-mark {
width: 4px;
height: 4px;
border-radius: 999px;
background: color-mix(in oklab, var(--color-base-content) 35%, transparent);
}
.calendar-zoom-mark-active {
background: color-mix(in oklab, var(--color-base-content) 85%, transparent);
}
@media (max-width: 960px) {
.calendar-content-wrap {
padding-left: 32px;
padding-right: 32px;
}
.calendar-week-grid {
grid-template-columns: repeat(7, minmax(150px, 1fr));
min-width: 1060px;
}
.calendar-side-nav {
width: 24px;
height: 24px;
}
.calendar-zoom-inline {
width: 108px;
}
}
</style>
<style>
/* Non-scoped: fly-rect inner content is injected via innerHTML */
.calendar-fly-rect .calendar-fly-content {
display: flex;
flex-direction: column;
padding: 12px 16px;
height: 100%;
box-sizing: border-box;
overflow: hidden;
}
.calendar-fly-rect .calendar-fly-label {
font-size: 14px;
font-weight: 600;
color: var(--color-base-content);
margin: 0 0 12px 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.calendar-fly-rect .calendar-fly-skeleton {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.calendar-fly-rect .calendar-fly-skeleton-line {
height: 8px;
border-radius: 4px;
background: color-mix(in oklab, var(--color-base-content) 10%, transparent);
animation: calendar-fly-skeleton-pulse 0.8s ease-in-out infinite alternate;
}
@keyframes calendar-fly-skeleton-pulse {
from { opacity: 0.3; }
to { opacity: 0.7; }
}
</style>