mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
Comp/Flex days (#1074) — complete. A second leave type that does not deduct from the vacation entitlement (flextime / overtime offset). New kind column on vacay_entries, excluded from usedDays via a CASE sum; toggleEntry carries kind through service/nest/store with change-detection; desktop and mobile get a comp toolbar toggle next to the half-day one (orthogonal, so a half comp day is free); comp segments render as a diagonal hatch of the person colour, per-segment for split days (Version B). comp_used is surfaced separately in getStats. Translated into all 23 locales. Configurable vacation year (#737) — backend only. Per-user vacay_user_settings (calendar / fiscal / anniversary), resolveYearWindow, and usedDays / compUsedDays / getStats / carry-over now computed over the leave-year [start,end) window. 'calendar' resolves byte-identically to the old date-prefix behaviour, so nothing changes for existing users and the feature is inert until the settings UI lands. Frontend (settings selector + calendar window rollover), the settings endpoint, window-aware getEntries and holiday loading are still to do — see the PR description.
This commit is contained in:
@@ -5,11 +5,11 @@ import { isWeekend } from './holidays'
|
||||
import { tripsApi } from '../../api/client'
|
||||
import VacayMonthCard from './VacayMonthCard'
|
||||
import type { VacayEntry } from '../../types'
|
||||
import { Building2, MousePointer2 } from 'lucide-react'
|
||||
import { Building2, MousePointer2, Clock } from 'lucide-react'
|
||||
|
||||
type VacayMode = 'vacation' | 'company'
|
||||
type HoverTip = { date: string; top: number; left: number }
|
||||
export type SharedDayMark = { color: string; name: string; fraction?: number; company?: boolean }
|
||||
export type SharedDayMark = { color: string; name: string; fraction?: number; company?: boolean; kind?: 'vacation' | 'comp' }
|
||||
|
||||
export default function VacayCalendar() {
|
||||
const { t, locale } = useTranslation()
|
||||
@@ -18,6 +18,9 @@ export default function VacayCalendar() {
|
||||
// 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.
|
||||
const [halfDay, setHalfDay] = useState(false)
|
||||
// Comp/Flex day is a second per-person modifier (#1074), orthogonal to half-day:
|
||||
// with it on, clicking a day logs it as kind='comp' (does not cost the entitlement).
|
||||
const [compDay, setCompDay] = useState(false)
|
||||
const companyMode = mode === 'company'
|
||||
const [tripDates, setTripDates] = useState<Set<string>>(new Set())
|
||||
const [tip, setTip] = useState<HoverTip | null>(null)
|
||||
@@ -68,7 +71,7 @@ export default function VacayCalendar() {
|
||||
sharedCalendars.filter(c => !c.hidden).forEach(cal => {
|
||||
cal.entries.forEach(e => {
|
||||
if (!map[e.date]) map[e.date] = []
|
||||
map[e.date].push({ color: cal.color, name: cal.owner_name, fraction: e.fraction })
|
||||
map[e.date].push({ color: cal.color, name: cal.owner_name, fraction: e.fraction, kind: e.kind })
|
||||
})
|
||||
cal.companyHolidays.forEach(h => {
|
||||
if (!map[h.date]) map[h.date] = []
|
||||
@@ -90,8 +93,8 @@ export default function VacayCalendar() {
|
||||
}
|
||||
if (blockWeekends && isWeekend(dateStr, weekendDays)) return
|
||||
if (companyHolidaysEnabled && companyHolidaySet.has(dateStr)) return
|
||||
await toggleEntry(dateStr, selectedUserId || undefined, halfDay ? 0.5 : 1)
|
||||
}, [mode, halfDay, toggleEntry, toggleCompanyHoliday, companyHolidaySet, blockWeekends, weekendDays, companyHolidaysEnabled, selectedUserId])
|
||||
await toggleEntry(dateStr, selectedUserId || undefined, halfDay ? 0.5 : 1, compDay ? 'comp' : 'vacation')
|
||||
}, [mode, halfDay, compDay, toggleEntry, toggleCompanyHoliday, companyHolidaySet, blockWeekends, weekendDays, companyHolidaysEnabled, selectedUserId])
|
||||
|
||||
// Cells with a half day or a shared overlay report a hover, so the tooltip
|
||||
// appears exactly when there's something to explain. Fixed-positioned at the
|
||||
@@ -109,6 +112,13 @@ export default function VacayCalendar() {
|
||||
const tipSchool = (Array.isArray(tipHolidayRaw) ? tipHolidayRaw : tipHolidayRaw ? [tipHolidayRaw] : []).filter(h => h.type === 'school_holiday')
|
||||
const tipDate = tip ? new Intl.DateTimeFormat(locale, { weekday: 'short', day: 'numeric', month: 'long' }).format(new Date(tip.date + 'T00:00:00')) : ''
|
||||
|
||||
// Label for a day's leave type + fraction (#552/#1074): full/half vacation or comp/flex.
|
||||
const dayTypeLabel = (fraction: number | undefined, kind: string | undefined) => {
|
||||
const half = (fraction ?? 1) === 0.5
|
||||
if (kind === 'comp') return half ? t('vacay.compHalf') : t('vacay.modeComp')
|
||||
return half ? t('vacay.modeHalf') : t('vacay.fullDay')
|
||||
}
|
||||
|
||||
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)' }}>
|
||||
@@ -145,13 +155,13 @@ export default function VacayCalendar() {
|
||||
<div style={{ padding: '8px 11px', minWidth: 132 }}>
|
||||
<div className="capitalize" style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.02em', color: 'var(--vg-ink3)', marginBottom: 5 }}>{tipDate}</div>
|
||||
{(tipEntries ?? []).map((e, i) => {
|
||||
const isHalf = (e.fraction ?? 1) === 0.5
|
||||
const emphasized = (e.fraction ?? 1) === 0.5 || e.kind === 'comp'
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-2" style={{ marginTop: i ? 4 : 0 }}>
|
||||
<span className="w-2 h-2 rounded-full shrink-0" style={{ background: e.person_color || '#6366f1' }} />
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--vg-ink)' }}>{e.person_name}</span>
|
||||
<span style={{ marginLeft: 'auto', paddingLeft: 12, fontSize: 11, fontWeight: 700, color: isHalf ? 'var(--vg-ink)' : 'var(--vg-ink3)' }}>
|
||||
{isHalf ? t('vacay.modeHalf') : t('vacay.fullDay')}
|
||||
<span style={{ marginLeft: 'auto', paddingLeft: 12, fontSize: 11, fontWeight: 700, color: emphasized ? 'var(--vg-ink)' : 'var(--vg-ink3)' }}>
|
||||
{dayTypeLabel(e.fraction, e.kind)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
@@ -162,7 +172,7 @@ export default function VacayCalendar() {
|
||||
<span className="w-2 h-2 rounded-full shrink-0" style={{ border: `2px solid ${m.color}` }} />
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--vg-ink)' }}>{m.name}</span>
|
||||
<span style={{ marginLeft: 'auto', paddingLeft: 12, fontSize: 11, fontWeight: 700, color: 'var(--vg-ink3)' }}>
|
||||
{m.company ? t('vacay.companyHoliday') : (m.fraction ?? 1) === 0.5 ? t('vacay.modeHalf') : t('vacay.fullDay')}
|
||||
{m.company ? t('vacay.companyHoliday') : dayTypeLabel(m.fraction, m.kind)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -213,9 +223,21 @@ export default function VacayCalendar() {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Divider — the half-day switch is a modifier, not a mode. */}
|
||||
{/* Divider — comp/flex and half-day are modifiers, not modes. */}
|
||||
<span className="w-px self-stretch my-0.5" style={{ background: 'var(--vg-line)' }} aria-hidden />
|
||||
|
||||
<button
|
||||
onClick={() => setCompDay(v => !v)}
|
||||
title={t('vacay.modeCompHint')}
|
||||
aria-pressed={compDay}
|
||||
className="flex items-center gap-1.5 pl-2 pr-2.5 sm:pl-2.5 sm:pr-3 py-1.5 rounded-full text-[11px] sm:text-xs font-semibold transition-[background-color,color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={compDay
|
||||
? { background: 'var(--vg-ink)', color: 'var(--vg-bg)' }
|
||||
: { background: 'transparent', color: 'var(--vg-ink3)' }}>
|
||||
<Clock size={13} />
|
||||
{t('vacay.modeComp')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setHalfDay(v => !v)}
|
||||
title={t('vacay.modeHalfHint')}
|
||||
|
||||
@@ -58,6 +58,10 @@ export default function VacayMonthCard({
|
||||
// Soften the person colour a touch so the filled days don't look overly loud
|
||||
// against the light glass surface (the raw colour reads very saturated).
|
||||
const fill = (c?: string) => `color-mix(in srgb, ${pc(c)} 78%, transparent)`
|
||||
// Comp/Flex days (#1074) render as a diagonal hatch of the person colour (Version B),
|
||||
// so a comp segment reads as "reserved but free" — distinct from a solid vacation fill.
|
||||
const hatch = (c?: string) => `repeating-linear-gradient(45deg, ${fill(c)} 0 3px, transparent 3px 6px)`
|
||||
const segFill = (e: VacayEntry) => (e.kind === 'comp' ? hatch(e.person_color) : fill(e.person_color))
|
||||
|
||||
const todayStr = useMemo(() => {
|
||||
const d = new Date()
|
||||
@@ -120,8 +124,9 @@ export default function VacayMonthCard({
|
||||
|
||||
// Cell fill — people win, then company, then holiday (keeps each calendar's own colour).
|
||||
let background = 'transparent'
|
||||
if (dayEntries.length === 1) background = fill(dayEntries[0].person_color)
|
||||
else if (dayEntries.length === 2) background = `linear-gradient(135deg, ${fill(dayEntries[0].person_color)} 50%, ${fill(dayEntries[1].person_color)} 50%)`
|
||||
if (dayEntries.length === 1) background = segFill(dayEntries[0])
|
||||
// 2 people render via the diagonal overlay below, so each half can be a solid
|
||||
// vacation fill or a hatched comp fill independently (#1074).
|
||||
else if (dayEntries.length === 0 && isCompany) background = 'rgba(245,158,11,0.22)'
|
||||
else if (dayEntries.length === 0 && publicHoliday) background = `color-mix(in srgb, ${publicHoliday.color} 22%, transparent)`
|
||||
// A plain school-break day gets a soft wash of its calendar colour so a run of
|
||||
@@ -165,20 +170,27 @@ export default function VacayMonthCard({
|
||||
if (hasEntries || sharedColors.length > 0 || schoolHolidayMarkers.length > 0) onCellHover?.(null, null)
|
||||
}}
|
||||
>
|
||||
{/* 3+ people: quadrant overlay at full colour (1 & 2 use the cell background). */}
|
||||
{/* 2 people: diagonal split — each half solid (vacation) or hatched (comp) (#1074). */}
|
||||
{dayEntries.length === 2 && (
|
||||
<div className="absolute inset-0 overflow-hidden" style={{ borderRadius: 10 }}>
|
||||
<div className="absolute inset-0" style={{ background: segFill(dayEntries[0]), clipPath: 'polygon(0 0, 100% 0, 0 100%)' }} />
|
||||
<div className="absolute inset-0" style={{ background: segFill(dayEntries[1]), clipPath: 'polygon(100% 0, 100% 100%, 0 100%)' }} />
|
||||
</div>
|
||||
)}
|
||||
{/* 3+ people: quadrant overlay (1 uses the cell background, 2 the diagonal above). */}
|
||||
{dayEntries.length === 3 && (
|
||||
<div className="absolute inset-0 overflow-hidden" style={{ borderRadius: 10 }}>
|
||||
<div className="absolute top-0 left-0 w-1/2 h-full" style={{ backgroundColor: fill(dayEntries[0].person_color) }} />
|
||||
<div className="absolute top-0 right-0 w-1/2 h-1/2" style={{ backgroundColor: fill(dayEntries[1].person_color) }} />
|
||||
<div className="absolute bottom-0 right-0 w-1/2 h-1/2" style={{ backgroundColor: fill(dayEntries[2].person_color) }} />
|
||||
<div className="absolute top-0 left-0 w-1/2 h-full" style={{ background: segFill(dayEntries[0]) }} />
|
||||
<div className="absolute top-0 right-0 w-1/2 h-1/2" style={{ background: segFill(dayEntries[1]) }} />
|
||||
<div className="absolute bottom-0 right-0 w-1/2 h-1/2" style={{ background: segFill(dayEntries[2]) }} />
|
||||
</div>
|
||||
)}
|
||||
{dayEntries.length >= 4 && (
|
||||
<div className="absolute inset-0 overflow-hidden" style={{ borderRadius: 10 }}>
|
||||
<div className="absolute top-0 left-0 w-1/2 h-1/2" style={{ backgroundColor: fill(dayEntries[0].person_color) }} />
|
||||
<div className="absolute top-0 right-0 w-1/2 h-1/2" style={{ backgroundColor: fill(dayEntries[1].person_color) }} />
|
||||
<div className="absolute bottom-0 left-0 w-1/2 h-1/2" style={{ backgroundColor: fill(dayEntries[2].person_color) }} />
|
||||
<div className="absolute bottom-0 right-0 w-1/2 h-1/2" style={{ backgroundColor: fill(dayEntries[3].person_color) }} />
|
||||
<div className="absolute top-0 left-0 w-1/2 h-1/2" style={{ background: segFill(dayEntries[0]) }} />
|
||||
<div className="absolute top-0 right-0 w-1/2 h-1/2" style={{ background: segFill(dayEntries[1]) }} />
|
||||
<div className="absolute bottom-0 left-0 w-1/2 h-1/2" style={{ background: segFill(dayEntries[2]) }} />
|
||||
<div className="absolute bottom-0 right-0 w-1/2 h-1/2" style={{ background: segFill(dayEntries[3]) }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user