Update calendar with multi-select
This commit is contained in:
@@ -6,6 +6,10 @@ export interface CalendarDayProps {
|
||||
isCurrentMonth?: boolean | undefined
|
||||
onClick?: (() => void) | undefined
|
||||
isToday?: boolean | undefined
|
||||
isSelected?: boolean | undefined
|
||||
onPointerDown?: ((date: string) => void) | undefined
|
||||
onPointerEnter?: ((date: string) => void) | undefined
|
||||
onActivate?: (() => void) | undefined
|
||||
}
|
||||
|
||||
export function CalendarDay({
|
||||
@@ -14,9 +18,23 @@ export function CalendarDay({
|
||||
isCurrentMonth = true,
|
||||
onClick,
|
||||
isToday = false,
|
||||
isSelected = false,
|
||||
onPointerDown,
|
||||
onPointerEnter,
|
||||
onActivate,
|
||||
}: CalendarDayProps): ReactNode {
|
||||
const dayNum = new Date(date + 'T00:00:00Z').getUTCDate()
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
if (onActivate) {
|
||||
onActivate()
|
||||
} else {
|
||||
onClick?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="calendar-day"
|
||||
@@ -24,12 +42,10 @@ export function CalendarDay({
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${date}${events.length > 0 ? ` with ${events.length} event(s)` : ''}`}
|
||||
className={`calendar-day ${!isCurrentMonth ? 'calendar-day--disabled' : ''} ${events.length > 0 ? 'calendar-day--has-events' : ''} ${isToday ? 'calendar-day--today' : ''}`}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onClick?.()
|
||||
}
|
||||
}}
|
||||
className={`calendar-day ${!isCurrentMonth ? 'calendar-day--disabled' : ''} ${events.length > 0 ? 'calendar-day--has-events' : ''} ${isToday ? 'calendar-day--today' : ''} ${isSelected ? 'calendar-day--selected' : ''}`}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPointerDown={() => onPointerDown?.(date)}
|
||||
onPointerEnter={() => onPointerEnter?.(date)}
|
||||
>
|
||||
<div className="calendar-day__number">{dayNum}</div>
|
||||
{events.length > 0 && (
|
||||
|
||||
@@ -5,6 +5,8 @@ export interface CalendarEventProps {
|
||||
color: string
|
||||
title?: string
|
||||
onDismiss?: () => void
|
||||
isDubbeldag?: boolean | undefined
|
||||
parentLabel?: string | undefined
|
||||
}
|
||||
|
||||
export function CalendarEvent({
|
||||
@@ -12,7 +14,11 @@ export function CalendarEvent({
|
||||
color,
|
||||
title,
|
||||
onDismiss,
|
||||
isDubbeldag = false,
|
||||
parentLabel,
|
||||
}: CalendarEventProps): ReactNode {
|
||||
const displayLabel = parentLabel ? `${parentLabel} · ${leaveType}` : leaveType
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="calendar-event"
|
||||
@@ -20,8 +26,9 @@ export function CalendarEvent({
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
<span className="calendar-event__label" title={title ?? leaveType}>
|
||||
{leaveType}
|
||||
{displayLabel}
|
||||
</span>
|
||||
{isDubbeldag && <span className="calendar-event__badge">2×</span>}
|
||||
{onDismiss && (
|
||||
<button
|
||||
className="calendar-event__dismiss"
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import { ReactNode, useMemo } from 'react'
|
||||
import type { PlannedLeaveDay } from '@/types'
|
||||
import { getColorForType, getLabelForType } from '@/utils/leaveTypes'
|
||||
import { getISOWeekNumber } from '@/utils/dates'
|
||||
import { CalendarDay } from './CalendarDay'
|
||||
import { CalendarEvent } from './CalendarEvent'
|
||||
|
||||
export interface CalendarWeekProps {
|
||||
weekDates: string[]
|
||||
plannedDays: PlannedLeaveDay[]
|
||||
selectedDates: Set<string>
|
||||
parentName: string
|
||||
personalDataUuid: string
|
||||
isCurrentMonth: boolean
|
||||
showWeekNumbers: boolean
|
||||
onDayClick: (date: string) => void
|
||||
onPointerDown: (date: string) => void
|
||||
onPointerEnter: (date: string) => void
|
||||
}
|
||||
|
||||
interface BarData {
|
||||
startCol: number
|
||||
endCol: number
|
||||
parentUuid: string
|
||||
dayType: string
|
||||
isDubbeldag: boolean
|
||||
lane: number
|
||||
}
|
||||
|
||||
function buildBarsForWeek(
|
||||
weekDates: string[],
|
||||
plannedDays: PlannedLeaveDay[]
|
||||
): BarData[] {
|
||||
const dayToEntries = new Map<string, PlannedLeaveDay[]>()
|
||||
|
||||
for (const date of weekDates) {
|
||||
dayToEntries.set(date, plannedDays.filter((pd) => pd.date === date))
|
||||
}
|
||||
|
||||
const series = new Map<
|
||||
string,
|
||||
{ bars: Array<{ startIdx: number; endIdx: number }> }
|
||||
>()
|
||||
|
||||
for (const pd of plannedDays) {
|
||||
if (!weekDates.includes(pd.date)) continue
|
||||
|
||||
const key = `${pd.parentPersonalDataUuid}|${pd.dayType}|${pd.isDubbeldag}`
|
||||
if (!series.has(key)) {
|
||||
series.set(key, { bars: [] })
|
||||
}
|
||||
}
|
||||
|
||||
const result: Array<{
|
||||
startCol: number
|
||||
endCol: number
|
||||
parentUuid: string
|
||||
dayType: string
|
||||
isDubbeldag: boolean
|
||||
}> = []
|
||||
|
||||
for (const [key] of series) {
|
||||
const parts = key.split('|')
|
||||
const parentUuid = parts[0] ?? ''
|
||||
const dayType = parts[1] ?? ''
|
||||
const isDubbeldag = parts[2] ?? 'false'
|
||||
const isDoubleStr = isDubbeldag === 'true'
|
||||
|
||||
let startIdx = -1
|
||||
|
||||
for (let i = 0; i < weekDates.length; i++) {
|
||||
const date = weekDates[i]
|
||||
const hasEntry = plannedDays.some(
|
||||
(pd) =>
|
||||
pd.date === date &&
|
||||
pd.parentPersonalDataUuid === parentUuid &&
|
||||
pd.dayType === dayType &&
|
||||
pd.isDubbeldag === isDoubleStr
|
||||
)
|
||||
|
||||
if (hasEntry) {
|
||||
if (startIdx === -1) {
|
||||
startIdx = i
|
||||
}
|
||||
} else {
|
||||
if (startIdx !== -1) {
|
||||
result.push({
|
||||
startCol: startIdx,
|
||||
endCol: i - 1,
|
||||
parentUuid,
|
||||
dayType,
|
||||
isDubbeldag: isDoubleStr,
|
||||
})
|
||||
startIdx = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (startIdx !== -1) {
|
||||
result.push({
|
||||
startCol: startIdx,
|
||||
endCol: weekDates.length - 1,
|
||||
parentUuid,
|
||||
dayType,
|
||||
isDubbeldag: isDoubleStr,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const barLanes = assignLanes(result)
|
||||
return barLanes
|
||||
}
|
||||
|
||||
function assignLanes(
|
||||
bars: Array<{ startCol: number; endCol: number; parentUuid: string; dayType: string; isDubbeldag: boolean }>
|
||||
): BarData[] {
|
||||
const sorted = [...bars].sort((a, b) => {
|
||||
if (a.startCol !== b.startCol) return a.startCol - b.startCol
|
||||
return b.endCol - a.endCol - (a.endCol - a.startCol) - (b.endCol - b.startCol)
|
||||
})
|
||||
|
||||
const lanes: BarData[][] = []
|
||||
|
||||
for (const bar of sorted) {
|
||||
let assignedLane = -1
|
||||
|
||||
for (let laneIdx = 0; laneIdx < lanes.length; laneIdx++) {
|
||||
const lane = lanes[laneIdx]
|
||||
if (lane && lane.every((b) => !(b.startCol <= bar.endCol && b.endCol >= bar.startCol))) {
|
||||
assignedLane = laneIdx
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (assignedLane === -1) {
|
||||
assignedLane = lanes.length
|
||||
lanes.push([])
|
||||
}
|
||||
|
||||
const targetLane = lanes[assignedLane]
|
||||
if (targetLane) {
|
||||
targetLane.push({
|
||||
startCol: bar.startCol,
|
||||
endCol: bar.endCol,
|
||||
parentUuid: bar.parentUuid,
|
||||
dayType: bar.dayType,
|
||||
isDubbeldag: bar.isDubbeldag,
|
||||
lane: assignedLane,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return lanes.flat()
|
||||
}
|
||||
|
||||
export function CalendarWeek({
|
||||
weekDates,
|
||||
plannedDays,
|
||||
selectedDates,
|
||||
parentName,
|
||||
personalDataUuid,
|
||||
isCurrentMonth,
|
||||
showWeekNumbers,
|
||||
onDayClick,
|
||||
onPointerDown,
|
||||
onPointerEnter,
|
||||
}: CalendarWeekProps): ReactNode {
|
||||
const bars = useMemo(() => buildBarsForWeek(weekDates, plannedDays), [weekDates, plannedDays])
|
||||
|
||||
const maxLane = bars.length > 0 ? Math.max(...bars.map((b) => b.lane)) : 0
|
||||
const barHeight = 28
|
||||
const laneGap = 4
|
||||
const weekMinHeight = 84 + (maxLane + 1) * (barHeight + laneGap)
|
||||
|
||||
const firstDate = weekDates[0]
|
||||
const weekNumber = getISOWeekNumber(new Date(firstDate + 'T00:00:00Z'))
|
||||
|
||||
return (
|
||||
<div
|
||||
className="calendar-week"
|
||||
style={{
|
||||
display: 'contents',
|
||||
minHeight: `${weekMinHeight}px`,
|
||||
}}
|
||||
>
|
||||
{showWeekNumbers && (
|
||||
<div className="calendar__week-number">
|
||||
<span className="calendar__week-number-text">v.{weekNumber}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{weekDates.map((date) => {
|
||||
const weekDateObj = new Date(weekDates[0] + 'T00:00:00Z')
|
||||
const dateObj = new Date(date + 'T00:00:00Z')
|
||||
const dayIsCurrentMonth = dateObj.getMonth() === weekDateObj.getMonth()
|
||||
|
||||
return (
|
||||
<div key={date} className="calendar-week__day-cell" style={{ position: 'relative' }}>
|
||||
<CalendarDay
|
||||
date={date}
|
||||
isCurrentMonth={isCurrentMonth && dayIsCurrentMonth}
|
||||
isToday={date === getTodayString()}
|
||||
isSelected={selectedDates.has(date)}
|
||||
onClick={() => {
|
||||
if (isCurrentMonth && dayIsCurrentMonth) {
|
||||
onDayClick(date)
|
||||
}
|
||||
}}
|
||||
onPointerDown={(d) => {
|
||||
if (isCurrentMonth && dayIsCurrentMonth) {
|
||||
onPointerDown(d)
|
||||
}
|
||||
}}
|
||||
onPointerEnter={(d) => {
|
||||
if (isCurrentMonth && dayIsCurrentMonth) {
|
||||
onPointerEnter(d)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="calendar-week__day-bars"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '80%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{bars
|
||||
.filter((b) => {
|
||||
const dateIdx = weekDates.indexOf(date)
|
||||
return dateIdx >= b.startCol && dateIdx <= b.endCol
|
||||
})
|
||||
.map((bar, idx) => {
|
||||
const parentUuid = bar.parentUuid
|
||||
const label = parentUuid === personalDataUuid ? parentName : 'Partner'
|
||||
const typeLabel = getLabelForType(bar.dayType)
|
||||
const color = getColorForType(bar.dayType)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="calendar-bar"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
height: barHeight,
|
||||
marginBottom: laneGap,
|
||||
}}
|
||||
data-testid={`calendar-bar-${idx}`}
|
||||
>
|
||||
<CalendarEvent
|
||||
leaveType={typeLabel}
|
||||
color={color}
|
||||
isDubbeldag={bar.isDubbeldag}
|
||||
parentLabel={label}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getTodayString(): string {
|
||||
const today = new Date()
|
||||
const year = today.getFullYear()
|
||||
const month = String(today.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(today.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
Reference in New Issue
Block a user