mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
feat(vacay): pick a leave year and roll the grid over with it (#737)
Calendar, fiscal (month + day) or from the hire date, per user — it sits with the plan settings but belongs to the person, so fused members each keep their own and the grid follows whoever is looking at it. Both grids render twelve months from the window start with year rollover instead of a fixed Jan–Dec, and the mobile month navigation moves through slots of that window, so stepping past December lands in January of the same period rather than jumping back to the start of the calendar year. The month effect keys on the window's shape rather than the settings object, which the store replaces with an equal copy on every refresh — otherwise any sync would throw away the month the user had open. Known edge for a start day past the 1st: the cards are month-aligned, so the first days of the start month are drawn but count toward the previous period. Counting stays day-exact.
This commit is contained in:
@@ -2,10 +2,11 @@ import { useMemo, useState, useCallback, useEffect } from 'react'
|
||||
import { useVacayStore } from '../../store/vacayStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { isWeekend } from './holidays'
|
||||
import { inGridWindow, windowMonths } from '../../vacay/yearWindow'
|
||||
import { tripsApi } from '../../api/client'
|
||||
import VacayMonthCard from './VacayMonthCard'
|
||||
import type { VacayEntry } from '../../types'
|
||||
import { Building2, MousePointer2, Clock } from 'lucide-react'
|
||||
import { Building2, MousePointer2 } from 'lucide-react'
|
||||
|
||||
type VacayMode = 'vacation' | 'company'
|
||||
type HoverTip = { date: string; top: number; left: number }
|
||||
@@ -13,7 +14,7 @@ export type SharedDayMark = { color: string; name: string; fraction?: number; co
|
||||
|
||||
export default function VacayCalendar() {
|
||||
const { t, locale } = useTranslation()
|
||||
const { selectedYear, selectedUserId, entries, companyHolidays, toggleEntry, toggleCompanyHoliday, plan, users, holidays, sharedCalendars } = useVacayStore()
|
||||
const { selectedYear, selectedUserId, entries, companyHolidays, toggleEntry, toggleCompanyHoliday, plan, users, holidays, sharedCalendars, yearSettings } = useVacayStore()
|
||||
const [mode, setMode] = useState<VacayMode>('vacation')
|
||||
// Half-day is a per-person modifier on the vacation action, not a mode: with it
|
||||
// on, clicking a day logs (or converts) it as a 0.5 day for the selected person.
|
||||
@@ -36,17 +37,17 @@ export default function VacayCalendar() {
|
||||
const start = new Date(trip.start_date + 'T00:00:00')
|
||||
const end = new Date(trip.end_date + 'T00:00:00')
|
||||
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||||
const y = d.getFullYear()
|
||||
if (y === selectedYear) {
|
||||
dates.add(`${y}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`)
|
||||
}
|
||||
// Keep the days the grid shows, which is the leave-year window (#737)
|
||||
// rather than the calendar year once the window is shifted.
|
||||
const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
if (inGridWindow(date, selectedYear, yearSettings)) dates.add(date)
|
||||
}
|
||||
}
|
||||
if (!cancelled) setTripDates(dates)
|
||||
} catch { /* ignore */ }
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [selectedYear])
|
||||
}, [selectedYear, yearSettings])
|
||||
|
||||
const companyHolidaySet = useMemo(() => {
|
||||
const s = new Set<string>()
|
||||
@@ -106,6 +107,9 @@ export default function VacayCalendar() {
|
||||
}, [])
|
||||
|
||||
const selectedUser = users.find(u => u.id === selectedUserId)
|
||||
// The toolbar modifiers preview what a click will put on the day, so the comp
|
||||
// hatch carries the selected person's colour just like the grid segment does.
|
||||
const markerColor = selectedUser?.color || '#6366f1'
|
||||
const tipEntries = tip ? entryMap[tip.date] : undefined
|
||||
const tipShared = tip ? sharedMap[tip.date] : undefined
|
||||
const tipHolidayRaw = tip ? holidays[tip.date] : undefined
|
||||
@@ -122,11 +126,13 @@ export default function VacayCalendar() {
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-[18px]" style={{ paddingBottom: 'calc(var(--bottom-nav-h, 0px) + 80px)' }}>
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
{/* Twelve months from the window start (#737), rolling over the calendar
|
||||
year when the leave year is shifted — Jul 2026 – Jun 2027 and so on. */}
|
||||
{windowMonths(selectedYear, yearSettings).map(({ year, month }) => (
|
||||
<VacayMonthCard
|
||||
key={i}
|
||||
year={selectedYear}
|
||||
month={i}
|
||||
key={`${year}-${month}`}
|
||||
year={year}
|
||||
month={month}
|
||||
holidays={holidays}
|
||||
companyHolidaySet={companyHolidaySet}
|
||||
companyHolidaysEnabled={companyHolidaysEnabled}
|
||||
@@ -234,7 +240,14 @@ export default function VacayCalendar() {
|
||||
style={compDay
|
||||
? { background: 'var(--vg-ink)', color: 'var(--vg-bg)' }
|
||||
: { background: 'transparent', color: 'var(--vg-ink3)' }}>
|
||||
<Clock size={13} />
|
||||
{/* A hatched disc in the person's colour — the same diagonal fill a comp
|
||||
day gets in the grid (#1074), so the toolbar previews the marker. */}
|
||||
<span className="rounded-full shrink-0"
|
||||
style={{
|
||||
width: 12, height: 12,
|
||||
background: `repeating-linear-gradient(45deg, ${markerColor} 0 2px, transparent 2px 4px)`,
|
||||
boxShadow: `inset 0 0 0 1px ${markerColor}`,
|
||||
}} aria-hidden />
|
||||
{t('vacay.modeComp')}
|
||||
</button>
|
||||
|
||||
@@ -246,12 +259,10 @@ export default function VacayCalendar() {
|
||||
style={halfDay
|
||||
? { background: 'var(--vg-ink)', color: 'var(--vg-bg)' }
|
||||
: { background: 'transparent', color: 'var(--vg-ink3)' }}>
|
||||
<span className="flex items-center justify-center rounded-full shrink-0 transition-colors"
|
||||
style={{
|
||||
width: 15, height: 15, fontSize: 10, fontWeight: 800, lineHeight: 1,
|
||||
background: halfDay ? 'var(--vg-bg)' : 'color-mix(in srgb, var(--vg-ink3) 22%, transparent)',
|
||||
color: halfDay ? 'var(--vg-ink)' : 'var(--vg-ink2)',
|
||||
}} aria-hidden>½</span>
|
||||
{/* The same orange dot a half day carries in the grid (#552) — the
|
||||
toolbar showing the marker you are about to place reads quicker
|
||||
than a ½ glyph that appears nowhere on the calendar. */}
|
||||
<span className="rounded-full shrink-0 bg-[#f97316]" style={{ width: 12, height: 12 }} aria-hidden />
|
||||
{t('vacay.modeHalf')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe, Plus, Trash2, CalendarDays, GraduationCap } from 'lucide-react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe, Plus, Trash2, CalendarDays, CalendarRange, GraduationCap } from 'lucide-react'
|
||||
import { useVacayStore } from '../../store/vacayStore'
|
||||
import { getIntlLanguage, useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -7,7 +7,8 @@ import CustomSelect from '../shared/CustomSelect'
|
||||
import apiClient from '../../api/client'
|
||||
import { fetchRegionOptions, fetchSchoolHolidayRegionOptions } from './holidayRegions'
|
||||
import { SCHOOL_HOLIDAY_COUNTRY_CONFIG } from '../../vacay/schoolHolidayCountries'
|
||||
import type { VacayHolidayCalendar } from '../../types'
|
||||
import { windowMonths } from '../../vacay/yearWindow'
|
||||
import type { VacayHolidayCalendar, VacayYearSettings } from '../../types'
|
||||
|
||||
interface VacaySettingsProps {
|
||||
onClose: () => void
|
||||
@@ -133,6 +134,9 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
||||
value={plan.carry_over_enabled}
|
||||
onChange={() => toggle('carry_over_enabled')}
|
||||
/>
|
||||
|
||||
{/* Vacation year (#737) — personal, unlike the plan settings above it */}
|
||||
<YearTypePicker />
|
||||
</div>
|
||||
|
||||
{/* ── Column 2 · holidays ── */}
|
||||
@@ -292,6 +296,117 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Leave-year type (#737) ────────────────────────────────────────────────────
|
||||
/**
|
||||
* Per-user leave year: calendar (Jan–Dec), fiscal (a fixed month/day) or
|
||||
* anniversary (the hire date's month/day). This is the one setting in this panel
|
||||
* that belongs to the person rather than the plan, so fused members can each keep
|
||||
* their own — the grid then follows whoever is looking at it.
|
||||
*/
|
||||
function YearTypePicker() {
|
||||
const { t, locale } = useTranslation()
|
||||
const { yearSettings, updateYearSettings, selectedYear } = useVacayStore()
|
||||
const type = yearSettings.year_type
|
||||
|
||||
const monthNames = useMemo(() => {
|
||||
const fmt = new Intl.DateTimeFormat(locale, { month: 'long' })
|
||||
return Array.from({ length: 12 }, (_, i) => fmt.format(new Date(2026, i, 1)))
|
||||
}, [locale])
|
||||
|
||||
// February caps at 28 so a window start never lands on a date some years lack.
|
||||
const maxDay = daysInMonth(yearSettings.year_start_month)
|
||||
|
||||
const save = (patch: Partial<VacayYearSettings>) => {
|
||||
const next = { ...yearSettings, ...patch }
|
||||
updateYearSettings({
|
||||
year_type: next.year_type,
|
||||
year_start_month: next.year_start_month,
|
||||
year_start_day: Math.min(next.year_start_day, daysInMonth(next.year_start_month)),
|
||||
hire_date: next.hire_date,
|
||||
})
|
||||
}
|
||||
|
||||
const months = windowMonths(selectedYear, yearSettings)
|
||||
const shortMonth = new Intl.DateTimeFormat(locale, { month: 'short', year: 'numeric' })
|
||||
const windowLabel = `${shortMonth.format(new Date(months[0].year, months[0].month, 1))} – ${shortMonth.format(new Date(months[11].year, months[11].month, 1))}`
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<CalendarRange size={16} className="text-content-muted" style={{ flexShrink: 0 }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<span className="text-sm font-medium text-content">{t('vacay.yearType')}</span>
|
||||
<p className="text-xs mt-0.5 text-content-faint">{t('vacay.yearTypeHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ paddingLeft: 36, marginTop: 8 }} className="flex flex-wrap gap-1.5">
|
||||
{([
|
||||
{ value: 'calendar', label: t('vacay.yearTypeCalendar') },
|
||||
{ value: 'fiscal', label: t('vacay.yearTypeFiscal') },
|
||||
{ value: 'anniversary', label: t('vacay.yearTypeAnniversary') },
|
||||
] as const).map(({ value, label }) => (
|
||||
<button key={value} onClick={() => save({ year_type: value })}
|
||||
style={{
|
||||
padding: '4px 10px', borderRadius: 8, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer',
|
||||
fontFamily: 'inherit', border: '1px solid', transition: 'all 0.12s',
|
||||
background: type === value ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||
borderColor: type === value ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
color: type === value ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||
}}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{type === 'fiscal' && (
|
||||
<div style={{ paddingLeft: 36, marginTop: 8 }} className="flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<CustomSelect
|
||||
value={String(yearSettings.year_start_month)}
|
||||
onChange={v => save({ year_start_month: parseInt(String(v), 10) })}
|
||||
options={monthNames.map((label, i) => ({ value: String(i + 1), label }))}
|
||||
placeholder={t('vacay.yearStartMonth')}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[92px] shrink-0">
|
||||
<CustomSelect
|
||||
value={String(yearSettings.year_start_day)}
|
||||
onChange={v => save({ year_start_day: parseInt(String(v), 10) })}
|
||||
options={Array.from({ length: maxDay }, (_, i) => ({ value: String(i + 1), label: String(i + 1) }))}
|
||||
placeholder={t('vacay.yearStartDay')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === 'anniversary' && (
|
||||
<div style={{ paddingLeft: 36, marginTop: 8 }}>
|
||||
<input
|
||||
type="date"
|
||||
value={yearSettings.hire_date || ''}
|
||||
onChange={e => save({ hire_date: e.target.value || null })}
|
||||
aria-label={t('vacay.hireDate')}
|
||||
style={{ width: '100%', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, padding: '8px 14px', borderRadius: 10, background: 'var(--bg-input)', border: '1px solid var(--border-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }}
|
||||
/>
|
||||
{!yearSettings.hire_date && (
|
||||
<p className="text-[11px] mt-1 text-content-faint">{t('vacay.hireDateHint')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[11px] mt-2 text-content-faint" style={{ paddingLeft: 36 }}>
|
||||
{t('vacay.yearWindow', { year: selectedYear, window: windowLabel })}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Days a month can always offer — February caps at 28 so no window start is skipped in a common year. */
|
||||
function daysInMonth(month: number): number {
|
||||
if (month === 2) return 28
|
||||
return [4, 6, 9, 11].includes(month) ? 30 : 31
|
||||
}
|
||||
|
||||
interface SettingToggleProps {
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
|
||||
Reference in New Issue
Block a user