feat(vacay): comp/flex leave type (#1074) + configurable-year backend (#737)

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:
Maurice
2026-07-24 20:40:15 +02:00
committed by Maurice
parent c61203e607
commit c475e5b04c
38 changed files with 329 additions and 67 deletions
+32 -10
View File
@@ -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')}
+22 -10
View File
@@ -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>
)}
+14 -2
View File
@@ -1,4 +1,4 @@
import { Building2, ChevronLeft, ChevronRight, Eye, Minus, PenLine, Pencil, Plus, Settings2, Share2, ShieldCheck, Trash2, Unlink, UserPlus } from 'lucide-react'
import { Building2, ChevronLeft, ChevronRight, Clock, Eye, Minus, PenLine, Pencil, Plus, Settings2, Share2, ShieldCheck, Trash2, Unlink, UserPlus } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn'
@@ -261,8 +261,20 @@ export default function MVacay() {
{t('vacay.modeCompany')}
</button>
)}
{/* Divider — the half-day switch modifies the selected person's logging, it isn't a mode. */}
{/* Divider — comp/flex and half-day modify the selected person's logging, they aren't modes. */}
<span className="mx-[1px] h-5 w-px self-center bg-[color:var(--m-shbr)]" aria-hidden />
<button
type="button"
onClick={() => v.setCompDay(c => !c)}
aria-pressed={v.compDay}
aria-label={t('vacay.modeComp')}
title={t('vacay.modeCompHint')}
className={`flex h-9 w-9 flex-none items-center justify-center rounded-full ${
v.compDay ? 'bg-m-act text-m-actfg' : 'bg-[color:var(--m-ic)] text-m-muted'
}`}
>
<Clock size={16} strokeWidth={2.2} />
</button>
<button
type="button"
onClick={() => v.setHalfDay(h => !h)}
@@ -1,5 +1,5 @@
import { schoolHolidayBand } from '../../../components/Vacay/holidayVisual'
import { dayVisual, localDateStr, monthLead, type DayVisualContext } from './vacayDayModel'
import { dayVisual, hatchTint, localDateStr, monthLead, type DayVisualContext } from './vacayDayModel'
interface MVacayMonthProps {
year: number
@@ -45,7 +45,23 @@ export default function MVacayMonth({
}`}
style={{ background: visual.background, color: visual.numColor, boxShadow: visual.boxShadow }}
>
{day}
{/* 2+ people: equal-width segment overlays, each solid (vacation) or hatched (comp) (#1074). */}
{visual.segments && visual.segments.length > 1 && (
<div className="absolute inset-0 overflow-hidden" style={{ borderRadius: 'inherit' }} aria-hidden>
{visual.segments.map((seg, si) => (
<div
key={si}
className="absolute top-0 h-full"
style={{
left: `${(si * 100) / visual.segments!.length}%`,
width: `${100 / visual.segments!.length}%`,
background: seg.comp ? hatchTint(seg.color) : seg.color,
}}
/>
))}
</div>
)}
<span className="relative z-[1]">{day}</span>
{tripDates.has(dateStr) && (
<span
aria-hidden
+5 -3
View File
@@ -42,6 +42,8 @@ export function useMVacay() {
const [mode, setMode] = useState<MVacayMode>('vacation')
// Half-day modifier: when on, taps log the selected person's day as 0.5 (#552).
const [halfDay, setHalfDay] = useState(false)
// Comp/Flex day modifier (#1074): when on, taps log the day as kind='comp' (free).
const [compDay, setCompDay] = useState(false)
const [sheet, setSheet] = useState<MVacaySheet>(null)
const [tripDates, setTripDates] = useState<Set<string>>(new Set())
@@ -155,8 +157,8 @@ export function useMVacay() {
}
if (blockWeekends && isWeekend(dateStr, weekendDays)) return
if (companyHolidaysEnabled && companyHolidaySet.has(dateStr)) return
await toggleEntry(dateStr, selectedUserId || undefined, halfDay ? 0.5 : 1)
}, [view, mode, halfDay, companyHolidaysEnabled, blockWeekends, weekendDays, companyHolidaySet, toggleEntry, toggleCompanyHoliday, selectedUserId])
await toggleEntry(dateStr, selectedUserId || undefined, halfDay ? 0.5 : 1, compDay ? 'comp' : 'vacation')
}, [view, mode, halfDay, compDay, companyHolidaysEnabled, blockWeekends, weekendDays, companyHolidaySet, toggleEntry, toggleCompanyHoliday, selectedUserId])
// Entitlement stepper: never below what is already used this year
// (carried-over days cover the difference when used > entitlement).
@@ -198,7 +200,7 @@ export function useMVacay() {
users, isFused, currentUser,
incomingInvites, acceptInvite, declineInvite,
incomingShares, toggleShareHidden,
view, month, mode, halfDay, setHalfDay, sheet, setSheet, setMode, setMonth,
view, month, mode, halfDay, setHalfDay, compDay, setCompDay, sheet, setSheet, setMode, setMonth,
tripDates, tripDotColor,
blockWeekends, companyHolidaysEnabled, holidaysEnabled, weekStart, weekendDays,
dayCtx, monthNamesShort, monthNameLong,
@@ -11,6 +11,10 @@ export interface DayVisual {
boxShadow?: string
// At least one person logged this day as a half day (#552) — the cell shows a ½ badge.
half?: boolean
// Per-person fill segments for this day (#1074): each carries the person's tint and
// whether they logged a comp/flex day (hatched, kind='comp') vs. a vacation day
// (solid). Set only when someone is off; the cell renders overlays for 2+ people.
segments?: { color: string; comp: boolean }[]
// Colours of the school-holiday calendars covering this day — drawn as a rounded
// accent band under the number, on top of whatever fill the cell already has.
school?: string[]
@@ -72,6 +76,11 @@ export function holidayInk(color: string): string {
return `hsl(${hsl.h} ${s}% 42%)`
}
/** Diagonal hatch of a tint for comp/flex days (#1074), matching the desktop card. */
export function hatchTint(color: string): string {
return `repeating-linear-gradient(45deg, ${color} 0 2.5px, transparent 2.5px 5px)`
}
/** Hard split background when several persons logged the same day. */
export function splitBackground(colors: string[]): string {
const n = colors.length
@@ -104,11 +113,14 @@ function baseDayVisual(dateStr: string, dayOfWeek: number, ctx: DayVisualContext
}
const entries = ctx.entryMap[dateStr]
if (entries && entries.length > 0) {
const tints = entries.map(e => personTint(e.person_color || FALLBACK_PERSON_COLOR))
// The fill still shows WHO is off; half days (#552) keep it and add a corner
// dot, so a half day never looks like a two-person split. Only set `half` when
// true so full days keep their exact { background, numColor } shape.
const visual: DayVisual = { background: tints.length === 1 ? tints[0] : splitBackground(tints), numColor: '#101013' }
// The fill still shows WHO is off; half days (#552) keep it and add a corner dot.
// Comp/flex days (#1074) hatch their segment. 1 person fills the cell directly
// (solid or hatched); several render segment overlays, so the background stays
// transparent underneath them.
const segments = entries.map(e => ({ color: personTint(e.person_color || FALLBACK_PERSON_COLOR), comp: e.kind === 'comp' }))
const single = segments.length === 1
const background = single ? (segments[0].comp ? hatchTint(segments[0].color) : segments[0].color) : 'transparent'
const visual: DayVisual = { background, numColor: '#101013', segments }
if (entries.some(e => (e.fraction ?? 1) === 0.5)) visual.half = true
return withSchool(visual)
}
+11 -10
View File
@@ -75,7 +75,7 @@ interface VacayApi {
addYear: (year: number) => Promise<VacayYearsResponse>
removeYear: (year: number) => Promise<VacayYearsResponse>
getEntries: (year: number) => Promise<VacayEntriesResponse>
toggleEntry: (date: string, targetUserId?: number, fraction?: 0.5 | 1) => Promise<unknown>
toggleEntry: (date: string, targetUserId?: number, fraction?: 0.5 | 1, kind?: 'vacation' | 'comp') => Promise<unknown>
toggleCompanyHoliday: (date: string) => Promise<unknown>
getStats: (year: number) => Promise<VacayStatsResponse>
updateStats: (year: number, days: number, targetUserId?: number) => Promise<unknown>
@@ -107,7 +107,7 @@ const api: VacayApi = {
addYear: (year) => ax.post('/addons/vacay/years', { year } satisfies VacayAddYearRequest).then((r: AxiosResponse) => r.data),
removeYear: (year) => ax.delete(`/addons/vacay/years/${year}`).then((r: AxiosResponse) => r.data),
getEntries: (year) => ax.get(`/addons/vacay/entries/${year}`).then((r: AxiosResponse) => r.data),
toggleEntry: (date, targetUserId, fraction) => ax.post('/addons/vacay/entries/toggle', { date, target_user_id: targetUserId, fraction } satisfies VacayToggleEntryRequest).then((r: AxiosResponse) => r.data),
toggleEntry: (date, targetUserId, fraction, kind) => ax.post('/addons/vacay/entries/toggle', { date, target_user_id: targetUserId, fraction, kind } satisfies VacayToggleEntryRequest).then((r: AxiosResponse) => r.data),
toggleCompanyHoliday: (date) => ax.post('/addons/vacay/entries/company-holiday', { date } satisfies VacayCompanyHolidayRequest).then((r: AxiosResponse) => r.data),
getStats: (year) => ax.get(`/addons/vacay/stats/${year}`).then((r: AxiosResponse) => r.data),
updateStats: (year, days, targetUserId) => ax.put(`/addons/vacay/stats/${year}`, { vacation_days: days, target_user_id: targetUserId } satisfies VacayUpdateStatsRequest).then((r: AxiosResponse) => r.data),
@@ -199,7 +199,7 @@ interface VacayState {
addYear: (year: number) => Promise<void>
removeYear: (year: number) => Promise<void>
loadEntries: (year?: number) => Promise<void>
toggleEntry: (date: string, targetUserId?: number, fraction?: 0.5 | 1) => Promise<void>
toggleEntry: (date: string, targetUserId?: number, fraction?: 0.5 | 1, kind?: 'vacation' | 'comp') => Promise<void>
toggleCompanyHoliday: (date: string) => Promise<void>
loadStats: (year?: number) => Promise<void>
updateVacationDays: (year: number, days: number, targetUserId?: number) => Promise<void>
@@ -320,26 +320,27 @@ export const useVacayStore = create<VacayState>((set, get) => ({
set({ entries: data.entries, companyHolidays: data.companyHolidays })
},
toggleEntry: async (date: string, targetUserId?: number, fraction: 0.5 | 1 = 1) => {
toggleEntry: async (date: string, targetUserId?: number, fraction: 0.5 | 1 = 1, kind: 'vacation' | 'comp' = 'vacation') => {
// Optimistic: mirror the server's toggle locally so the cell reacts instantly,
// then reconcile (stats are server-computed). Same fraction clears the day, a
// different one converts it (full ↔ half); an empty day gets the new entry.
// then reconcile (stats are server-computed). Same type AND fraction clears the
// day, a different type or fraction converts it (#552/#1074); an empty day gets
// the new entry.
const userId = targetUserId ?? useAuthStore.getState().user?.id
const prevEntries = get().entries
const prevStats = get().stats
if (userId != null) {
const existing = prevEntries.find(e => e.date === date && e.user_id === userId)
if (existing && (existing.fraction ?? 1) === fraction) {
if (existing && (existing.fraction ?? 1) === fraction && (existing.kind ?? 'vacation') === kind) {
set({ entries: prevEntries.filter(e => !(e.date === date && e.user_id === userId)) })
} else if (existing) {
set({ entries: prevEntries.map(e => (e.date === date && e.user_id === userId ? { ...e, fraction } : e)) })
set({ entries: prevEntries.map(e => (e.date === date && e.user_id === userId ? { ...e, fraction, kind } : e)) })
} else {
const u = get().users.find(x => x.id === userId)
set({ entries: [...prevEntries, { date, user_id: userId, fraction, person_color: u?.color || undefined, person_name: u?.username }] })
set({ entries: [...prevEntries, { date, user_id: userId, fraction, kind, person_color: u?.color || undefined, person_name: u?.username }] })
}
}
try {
await api.toggleEntry(date, targetUserId, fraction)
await api.toggleEntry(date, targetUserId, fraction, kind)
await get().loadEntries()
await get().loadStats()
} catch (e) {
+7 -1
View File
@@ -307,6 +307,9 @@ export interface VacayEntry {
// Portion of a vacation day this entry counts as: 1 = full day, 0.5 = half
// day (#552). Absent on legacy entries, which are treated as full days.
fraction?: number
// Leave type (#1074): 'comp' = flex/comp day (does not touch the entitlement),
// 'vacation' (or absent, for legacy entries) = a regular vacation day.
kind?: 'vacation' | 'comp'
}
// Vacay per-user stats row as returned by getStats
@@ -321,6 +324,9 @@ export interface VacayStat {
total_available: number
used: number
remaining: number
// Comp/flex days used this year (#1074) — informational, not deducted from the
// entitlement. Absent on older server builds.
comp_used?: number
}
export interface HolidayInfo {
@@ -356,7 +362,7 @@ export interface SharedVacayCalendar {
owner_name: string
color: string
hidden: boolean
entries: { date: string; fraction?: number }[]
entries: { date: string; fraction?: number; kind?: 'vacation' | 'comp' }[]
companyHolidays: { date: string; note?: string }[]
}