mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 }[]
|
||||
}
|
||||
|
||||
|
||||
@@ -3799,6 +3799,28 @@ function runMigrations(db: Database.Database): void {
|
||||
CREATE INDEX IF NOT EXISTS idx_reservation_travelers_user ON reservation_travelers(user_id);
|
||||
`);
|
||||
},
|
||||
// Comp/Flex days (#1074): a vacay entry is either a vacation day (counts toward
|
||||
// the entitlement) or a comp/flex day (kind='comp', costs 0 — flextime/overtime
|
||||
// offset). Orthogonal to fraction, so a half comp day is kind='comp' + fraction=0.5.
|
||||
() => {
|
||||
const hasKind = db.prepare("SELECT 1 FROM pragma_table_info('vacay_entries') WHERE name = 'kind'").get();
|
||||
if (!hasKind) db.exec("ALTER TABLE vacay_entries ADD COLUMN kind TEXT NOT NULL DEFAULT 'vacation'");
|
||||
},
|
||||
// Configurable vacation year (#737): per-user leave-year window. 'calendar' keeps
|
||||
// the Jan 1–Dec 31 default (unchanged for everyone); 'fiscal' starts on a fixed
|
||||
// month/day; 'anniversary' starts on the month/day of the hire date. The year
|
||||
// integer still names a period; the service resolves it to a [start,end) range.
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS vacay_user_settings (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
year_type TEXT NOT NULL DEFAULT 'calendar',
|
||||
year_start_month INTEGER NOT NULL DEFAULT 1,
|
||||
year_start_day INTEGER NOT NULL DEFAULT 1,
|
||||
hire_date TEXT
|
||||
);
|
||||
`);
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -254,7 +254,7 @@ export function registerVacayTools(server: McpServer, userId: number, scopes: st
|
||||
async ({ date }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const planId = getActivePlanId(userId);
|
||||
const result = toggleEntry(userId, planId, date, 1, undefined);
|
||||
const result = toggleEntry(userId, planId, date, 1, 'vacation', undefined);
|
||||
return ok(result);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -695,7 +695,7 @@ export function createRealRpcHost(id: string, granted: ReadonlySet<string>, rout
|
||||
},
|
||||
// --- Vacay write: the plan is the ACTING USER's active plan (resolved host-side);
|
||||
// the service broadcasts to plan users itself. ---
|
||||
vacayToggleEntry: (userId, date) => { requireAddon(ADDON_IDS.VACAY, 'vacay'); return vacayToggleEntrySvc(userId, getActivePlanId(userId), date, 1, undefined); },
|
||||
vacayToggleEntry: (userId, date) => { requireAddon(ADDON_IDS.VACAY, 'vacay'); return vacayToggleEntrySvc(userId, getActivePlanId(userId), date, 1, 'vacation', undefined); },
|
||||
vacayToggleCompanyHoliday: (userId, date, note) => {
|
||||
requireAddon(ADDON_IDS.VACAY, 'vacay');
|
||||
return vacayToggleCompanyHolidaySvc(getActivePlanId(userId), date, note, undefined);
|
||||
|
||||
@@ -185,7 +185,7 @@ export class VacayController {
|
||||
@HttpCode(200)
|
||||
toggleEntry(
|
||||
@CurrentUser() user: User,
|
||||
@Body() body: { date?: string; target_user_id?: number | string; fraction?: number },
|
||||
@Body() body: { date?: string; target_user_id?: number | string; fraction?: number; kind?: string },
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
if (!body.date) {
|
||||
@@ -200,7 +200,7 @@ export class VacayController {
|
||||
}
|
||||
userId = tid;
|
||||
}
|
||||
return this.vacay.toggleEntry(userId, planId, body.date, body.fraction, socketId);
|
||||
return this.vacay.toggleEntry(userId, planId, body.date, body.fraction, body.kind, socketId);
|
||||
}
|
||||
|
||||
@Post('entries/company-holiday')
|
||||
|
||||
@@ -86,8 +86,8 @@ export class VacayService {
|
||||
return svc.getEntries(planId, year);
|
||||
}
|
||||
|
||||
toggleEntry(userId: number, planId: number, date: string, fraction: unknown, socketId: string | undefined) {
|
||||
return svc.toggleEntry(userId, planId, date, fraction, socketId);
|
||||
toggleEntry(userId: number, planId: number, date: string, fraction: unknown, kind: unknown, socketId: string | undefined) {
|
||||
return svc.toggleEntry(userId, planId, date, fraction, kind, socketId);
|
||||
}
|
||||
|
||||
toggleCompanyHoliday(planId: number, date: string, note: string | undefined, socketId: string | undefined) {
|
||||
|
||||
@@ -77,10 +77,24 @@ const CACHE_TTL = 24 * 60 * 60 * 1000;
|
||||
* half day (#552) counts as 0.5 and a full day as 1. Entries predating the
|
||||
* feature have fraction = 1, so this matches the old COUNT(*) for them.
|
||||
*/
|
||||
function usedDays(userId: number, planId: number, yearPrefix: string): number {
|
||||
function usedDays(userId: number, planId: number, year: number): number {
|
||||
// Comp/Flex days (#1074) are free — kind='comp' contributes 0 to the entitlement,
|
||||
// vacation days contribute their fraction. Entries predating the column are
|
||||
// 'vacation' by default. The window (#737) is the user's leave-year period; for
|
||||
// 'calendar' it is Jan 1 – Dec 31, byte-identical to the old date-prefix match.
|
||||
const { start, end } = resolveYearWindow(userId, year);
|
||||
const row = db.prepare(
|
||||
'SELECT COALESCE(SUM(fraction), 0) AS used FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date LIKE ?'
|
||||
).get(userId, planId, yearPrefix) as { used: number };
|
||||
"SELECT COALESCE(SUM(CASE WHEN kind = 'comp' THEN 0 ELSE fraction END), 0) AS used FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date >= ? AND date < ?"
|
||||
).get(userId, planId, start, end) as { used: number };
|
||||
return row.used;
|
||||
}
|
||||
|
||||
/** Comp/Flex days (#1074) used in a user's leave-year period — SUM of fractions for kind='comp'. */
|
||||
function compUsedDays(userId: number, planId: number, year: number): number {
|
||||
const { start, end } = resolveYearWindow(userId, year);
|
||||
const row = db.prepare(
|
||||
"SELECT COALESCE(SUM(fraction), 0) AS used FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date >= ? AND date < ? AND kind = 'comp'"
|
||||
).get(userId, planId, start, end) as { used: number };
|
||||
return row.used;
|
||||
}
|
||||
|
||||
@@ -89,6 +103,75 @@ function normalizeFraction(value: unknown): number {
|
||||
return Number(value) === 0.5 ? 0.5 : 1;
|
||||
}
|
||||
|
||||
/** Coerce an arbitrary input to a supported leave type: comp/flex or vacation (#1074). */
|
||||
function normalizeKind(value: unknown): 'vacation' | 'comp' {
|
||||
return value === 'comp' ? 'comp' : 'vacation';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configurable vacation year (#737)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface VacayUserSettings {
|
||||
user_id: number;
|
||||
year_type: 'calendar' | 'fiscal' | 'anniversary';
|
||||
year_start_month: number;
|
||||
year_start_day: number;
|
||||
hire_date: string | null;
|
||||
}
|
||||
|
||||
export function getUserYearSettings(userId: number): VacayUserSettings | undefined {
|
||||
return db.prepare('SELECT * FROM vacay_user_settings WHERE user_id = ?').get(userId) as VacayUserSettings | undefined;
|
||||
}
|
||||
|
||||
const pad2 = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
/** Coerce an arbitrary input to a supported leave-year type (#737). */
|
||||
function normalizeYearType(value: unknown): 'calendar' | 'fiscal' | 'anniversary' {
|
||||
return value === 'fiscal' || value === 'anniversary' ? value : 'calendar';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a user's leave-year window for a period (#737). The `year` integer names
|
||||
* the period; 'calendar' returns Jan 1 – Dec 31 (the unchanged default, byte-identical
|
||||
* to the old `date LIKE 'YYYY-%'`), 'fiscal' starts on the configured month/day, and
|
||||
* 'anniversary' on the hire date's month/day. Window is [start, end) — start inclusive,
|
||||
* end exclusive. Because periods are consecutive, `year-1` is always the window that
|
||||
* ends where `year`'s begins, so the carry-over chains stay valid unchanged.
|
||||
*/
|
||||
export function resolveYearWindow(userId: number, year: number): { start: string; end: string } {
|
||||
const s = getUserYearSettings(userId);
|
||||
if (!s || s.year_type === 'calendar') {
|
||||
return { start: `${year}-01-01`, end: `${year + 1}-01-01` };
|
||||
}
|
||||
let month = s.year_start_month || 1;
|
||||
let day = s.year_start_day || 1;
|
||||
if (s.year_type === 'anniversary' && s.hire_date) {
|
||||
const parts = s.hire_date.split('-');
|
||||
month = parseInt(parts[1], 10) || 1;
|
||||
day = parseInt(parts[2], 10) || 1;
|
||||
}
|
||||
return { start: `${year}-${pad2(month)}-${pad2(day)}`, end: `${year + 1}-${pad2(month)}-${pad2(day)}` };
|
||||
}
|
||||
|
||||
/** Upsert a user's leave-year settings (#737). */
|
||||
export function updateUserYearSettings(
|
||||
userId: number,
|
||||
data: { year_type?: unknown; year_start_month?: unknown; year_start_day?: unknown; hire_date?: unknown },
|
||||
): VacayUserSettings {
|
||||
const type = normalizeYearType(data.year_type);
|
||||
const month = Math.min(12, Math.max(1, parseInt(String(data.year_start_month ?? 1), 10) || 1));
|
||||
const day = Math.min(31, Math.max(1, parseInt(String(data.year_start_day ?? 1), 10) || 1));
|
||||
const hire = typeof data.hire_date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(data.hire_date) ? data.hire_date : null;
|
||||
db.prepare(`
|
||||
INSERT INTO vacay_user_settings (user_id, year_type, year_start_month, year_start_day, hire_date)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET year_type = excluded.year_type, year_start_month = excluded.year_start_month,
|
||||
year_start_day = excluded.year_start_day, hire_date = excluded.hire_date
|
||||
`).run(userId, type, month, day, hire);
|
||||
return getUserYearSettings(userId)!;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Color palette for auto-assign
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -298,7 +381,7 @@ export async function updatePlan(planId: number, body: UpdatePlanBody, socketId:
|
||||
const yr = years[i].year;
|
||||
const nextYr = years[i + 1].year;
|
||||
for (const u of users) {
|
||||
const used = usedDays(u.id, planId, `${yr}-%`);
|
||||
const used = usedDays(u.id, planId, yr);
|
||||
const config = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, yr) as VacayUserYear | undefined;
|
||||
const total = (config ? config.vacation_days : 30) + (config ? config.carried_over : 0);
|
||||
const carry = Math.max(0, total - used);
|
||||
@@ -713,7 +796,7 @@ export function getSharedCalendars(viewerId: number, year: string) {
|
||||
return { share_id: s.id, owner_id: s.owner_id, owner_name: s.username, color, hidden: !!s.hidden, entries: [], companyHolidays: [] };
|
||||
}
|
||||
const entries = db.prepare(
|
||||
'SELECT date, fraction FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date LIKE ? ORDER BY date'
|
||||
'SELECT date, fraction, kind FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date LIKE ? ORDER BY date'
|
||||
).all(plan.id, s.owner_id, `${year}-%`);
|
||||
// Company holidays are plan-wide context for "when is this person off";
|
||||
// only exposed while the owner has the feature enabled, and dates only —
|
||||
@@ -745,7 +828,7 @@ export function addYear(planId: number, year: number, socketId: string | undefin
|
||||
if (carryOverEnabled) {
|
||||
const prevConfig = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, year - 1) as VacayUserYear | undefined;
|
||||
if (prevConfig) {
|
||||
const used = usedDays(u.id, planId, `${year - 1}-%`);
|
||||
const used = usedDays(u.id, planId, year - 1);
|
||||
const total = prevConfig.vacation_days + prevConfig.carried_over;
|
||||
carriedOver = Math.max(0, total - used);
|
||||
}
|
||||
@@ -776,7 +859,7 @@ export function deleteYear(planId: number, year: number, socketId: string | unde
|
||||
if (carryOverEnabled && prevYear) {
|
||||
const prevConfig = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, prevYear.year) as VacayUserYear | undefined;
|
||||
if (prevConfig) {
|
||||
const used = usedDays(u.id, planId, `${prevYear.year}-%`);
|
||||
const used = usedDays(u.id, planId, prevYear.year);
|
||||
const total = prevConfig.vacation_days + prevConfig.carried_over;
|
||||
carry = Math.max(0, total - used);
|
||||
}
|
||||
@@ -805,24 +888,25 @@ export function getEntries(planId: number, year: string) {
|
||||
return { entries, companyHolidays };
|
||||
}
|
||||
|
||||
export function toggleEntry(userId: number, planId: number, date: string, fraction?: unknown, socketId?: string): { action: string; fraction?: number } {
|
||||
export function toggleEntry(userId: number, planId: number, date: string, fraction?: unknown, kind?: unknown, socketId?: string): { action: string; fraction?: number; kind?: string } {
|
||||
const frac = normalizeFraction(fraction);
|
||||
const existing = db.prepare('SELECT id, fraction FROM vacay_entries WHERE user_id = ? AND date = ? AND plan_id = ?').get(userId, date, planId) as { id: number; fraction: number } | undefined;
|
||||
const knd = normalizeKind(kind);
|
||||
const existing = db.prepare('SELECT id, fraction, kind FROM vacay_entries WHERE user_id = ? AND date = ? AND plan_id = ?').get(userId, date, planId) as { id: number; fraction: number; kind: string | null } | undefined;
|
||||
if (existing) {
|
||||
// Clicking the same kind of day again clears it; clicking the other kind
|
||||
// (full over a half, or vice versa) converts it in place.
|
||||
if (existing.fraction === frac) {
|
||||
// Clicking the exact same day again (same type AND same fraction) clears it;
|
||||
// clicking a different type or fraction converts it in place (#552/#1074).
|
||||
if (existing.fraction === frac && (existing.kind || 'vacation') === knd) {
|
||||
db.prepare('DELETE FROM vacay_entries WHERE id = ?').run(existing.id);
|
||||
notifyPlanUsers(planId, socketId);
|
||||
return { action: 'removed' };
|
||||
}
|
||||
db.prepare('UPDATE vacay_entries SET fraction = ? WHERE id = ?').run(frac, existing.id);
|
||||
db.prepare('UPDATE vacay_entries SET fraction = ?, kind = ? WHERE id = ?').run(frac, knd, existing.id);
|
||||
notifyPlanUsers(planId, socketId);
|
||||
return { action: 'updated', fraction: frac };
|
||||
return { action: 'updated', fraction: frac, kind: knd };
|
||||
}
|
||||
db.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note, fraction) VALUES (?, ?, ?, ?, ?)').run(planId, userId, date, '', frac);
|
||||
db.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note, fraction, kind) VALUES (?, ?, ?, ?, ?, ?)').run(planId, userId, date, '', frac, knd);
|
||||
notifyPlanUsers(planId, socketId);
|
||||
return { action: 'added', fraction: frac };
|
||||
return { action: 'added', fraction: frac, kind: knd };
|
||||
}
|
||||
|
||||
export function toggleCompanyHoliday(planId: number, date: string, note: string | undefined, socketId: string | undefined): { action: string } {
|
||||
@@ -849,7 +933,8 @@ export function getStats(planId: number, year: number) {
|
||||
const users = getPlanUsers(planId);
|
||||
|
||||
return users.map(u => {
|
||||
const used = usedDays(u.id, planId, `${year}-%`);
|
||||
const used = usedDays(u.id, planId, year);
|
||||
const compUsed = compUsedDays(u.id, planId, year);
|
||||
const config = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, year) as VacayUserYear | undefined;
|
||||
const vacationDays = config ? config.vacation_days : 30;
|
||||
const carriedOver = carryOverEnabled ? (config ? config.carried_over : 0) : 0;
|
||||
@@ -869,7 +954,7 @@ export function getStats(planId: number, year: number) {
|
||||
return {
|
||||
user_id: u.id, person_name: u.username, person_color: colorRow?.color || '#6366f1',
|
||||
year, vacation_days: vacationDays, carried_over: carriedOver,
|
||||
total_available: total, used, remaining,
|
||||
total_available: total, used, remaining, comp_used: compUsed,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ const vacay: TranslationStrings = {
|
||||
'vacay.modeHalf': 'نصف يوم',
|
||||
'vacay.modeHalfHint': 'تسجيل أنصاف أيام الإجازة',
|
||||
'vacay.fullDay': 'يوم كامل',
|
||||
'vacay.modeComp': 'تعويض / مرن',
|
||||
'vacay.modeCompHint': 'تسجيل أيام التعويض / الوقت المرن (مجانًا — دون استخدام إجازة)',
|
||||
'vacay.compHalf': 'نصف يوم تعويضي',
|
||||
'vacay.entitlement': 'الاستحقاق',
|
||||
'vacay.entitlementDays': 'الأيام',
|
||||
'vacay.used': 'المستخدم',
|
||||
|
||||
@@ -31,6 +31,9 @@ const vacay: TranslationStrings = {
|
||||
'vacay.modeHalf': 'Meio dia',
|
||||
'vacay.modeHalfHint': 'Registrar meio dia de férias',
|
||||
'vacay.fullDay': 'Dia inteiro',
|
||||
'vacay.modeComp': 'Banco de horas',
|
||||
'vacay.modeCompHint': 'Registrar dias de banco de horas (grátis — sem usar férias)',
|
||||
'vacay.compHalf': 'Meio dia de banco de horas',
|
||||
'vacay.entitlement': 'Direito',
|
||||
'vacay.entitlementDays': 'Dias',
|
||||
'vacay.used': 'Usados',
|
||||
|
||||
@@ -30,6 +30,9 @@ const vacay: TranslationStrings = {
|
||||
'vacay.modeHalf': 'Mitja jornada',
|
||||
'vacay.modeHalfHint': 'Registra mitges jornades de vacances',
|
||||
'vacay.fullDay': 'Jornada completa',
|
||||
'vacay.modeComp': 'Comp. / Flexi',
|
||||
'vacay.modeCompHint': 'Registra dies de compensació/flexibilitat (gratis — no gasta vacances)',
|
||||
'vacay.compHalf': 'Mig dia de compensació',
|
||||
'vacay.entitlement': 'Dret',
|
||||
'vacay.entitlementDays': 'Dies',
|
||||
'vacay.used': 'Utilitzats',
|
||||
|
||||
@@ -30,6 +30,9 @@ const vacay: TranslationStrings = {
|
||||
'vacay.modeHalf': 'Půlden',
|
||||
'vacay.modeHalfHint': 'Zapsat půldenní dovolenou',
|
||||
'vacay.fullDay': 'Celý den',
|
||||
'vacay.modeComp': 'Náhradní volno',
|
||||
'vacay.modeCompHint': 'Zapsat náhradní / pružné volno (zdarma — bez čerpání dovolené)',
|
||||
'vacay.compHalf': 'Půlden náhradního volna',
|
||||
'vacay.entitlement': 'Nárok',
|
||||
'vacay.entitlementDays': 'Dní',
|
||||
'vacay.used': 'Vyčerpáno',
|
||||
|
||||
@@ -30,6 +30,9 @@ const vacay: TranslationStrings = {
|
||||
'vacay.modeHalf': 'Halbtag',
|
||||
'vacay.modeHalfHint': 'Halbe Urlaubstage eintragen',
|
||||
'vacay.fullDay': 'Ganzer Tag',
|
||||
'vacay.modeComp': 'Ausgleich',
|
||||
'vacay.modeCompHint': 'Ausgleichs-/Gleittage eintragen (kostenlos — kein Urlaub)',
|
||||
'vacay.compHalf': 'Halber Ausgleichstag',
|
||||
'vacay.entitlement': 'Urlaubsanspruch',
|
||||
'vacay.entitlementDays': 'Tage',
|
||||
'vacay.used': 'Weg',
|
||||
|
||||
@@ -30,6 +30,9 @@ const vacay: TranslationStrings = {
|
||||
'vacay.modeHalf': 'Half day',
|
||||
'vacay.modeHalfHint': 'Log half vacation days',
|
||||
'vacay.fullDay': 'Full day',
|
||||
'vacay.modeComp': 'Comp / Flex',
|
||||
'vacay.modeCompHint': 'Log comp / flex days (free — no vacation used)',
|
||||
'vacay.compHalf': 'Half comp day',
|
||||
'vacay.entitlement': 'Entitlement',
|
||||
'vacay.entitlementDays': 'Days',
|
||||
'vacay.used': 'Used',
|
||||
|
||||
@@ -30,6 +30,9 @@ const vacay: TranslationStrings = {
|
||||
'vacay.modeHalf': 'Medio día',
|
||||
'vacay.modeHalfHint': 'Registrar medios días de vacaciones',
|
||||
'vacay.fullDay': 'Día completo',
|
||||
'vacay.modeComp': 'Compensación',
|
||||
'vacay.modeCompHint': 'Registrar días de compensación/flexibles (gratis — sin usar vacaciones)',
|
||||
'vacay.compHalf': 'Medio día de compensación',
|
||||
'vacay.entitlement': 'Derecho',
|
||||
'vacay.entitlementDays': 'Días',
|
||||
'vacay.used': 'Usados',
|
||||
|
||||
@@ -31,6 +31,9 @@ const vacay: TranslationStrings = {
|
||||
'vacay.modeHalf': 'Demi-journée',
|
||||
'vacay.modeHalfHint': 'Enregistrer des demi-journées de congé',
|
||||
'vacay.fullDay': 'Journée complète',
|
||||
'vacay.modeComp': 'Récup / RTT',
|
||||
'vacay.modeCompHint': 'Enregistrer les jours de récupération / RTT (gratuit — aucun congé utilisé)',
|
||||
'vacay.compHalf': 'Demi-journée de récupération',
|
||||
'vacay.entitlement': 'Droits',
|
||||
'vacay.entitlementDays': 'Jours',
|
||||
'vacay.used': 'Utilisés',
|
||||
|
||||
@@ -31,6 +31,9 @@ const vacay: TranslationStrings = {
|
||||
'vacay.modeHalf': 'Μισή μέρα',
|
||||
'vacay.modeHalfHint': 'Καταχώριση μισής ημέρας άδειας',
|
||||
'vacay.fullDay': 'Ολόκληρη μέρα',
|
||||
'vacay.modeComp': 'Ρεπό',
|
||||
'vacay.modeCompHint': 'Καταχώριση ημερών ρεπό/ευέλικτου ωραρίου (δωρεάν — χωρίς χρήση αδείας)',
|
||||
'vacay.compHalf': 'Μισή μέρα ρεπό',
|
||||
'vacay.entitlement': 'Δικαίωμα',
|
||||
'vacay.entitlementDays': 'Ημέρες',
|
||||
'vacay.used': 'Χρησιμοποιημένες',
|
||||
|
||||
@@ -30,6 +30,9 @@ const vacay: TranslationStrings = {
|
||||
'vacay.modeHalf': 'Fél nap',
|
||||
'vacay.modeHalfHint': 'Fél szabadnapok rögzítése',
|
||||
'vacay.fullDay': 'Egész nap',
|
||||
'vacay.modeComp': 'Csúsztatás',
|
||||
'vacay.modeCompHint': 'Csúsztatás / rugalmas napok rögzítése (ingyenes — nincs szabadságfelhasználás)',
|
||||
'vacay.compHalf': 'Fél csúsztatásnap',
|
||||
'vacay.entitlement': 'Szabadságkeret',
|
||||
'vacay.entitlementDays': 'nap',
|
||||
'vacay.used': 'Felhasznált',
|
||||
|
||||
@@ -30,6 +30,9 @@ const vacay: TranslationStrings = {
|
||||
'vacay.modeHalf': 'Setengah hari',
|
||||
'vacay.modeHalfHint': 'Catat setengah hari cuti',
|
||||
'vacay.fullDay': 'Sehari penuh',
|
||||
'vacay.modeComp': 'Pengganti / Fleksi',
|
||||
'vacay.modeCompHint': 'Catat hari pengganti / fleksi (gratis — tanpa memotong cuti)',
|
||||
'vacay.compHalf': 'Setengah hari pengganti',
|
||||
'vacay.entitlement': 'Jatah Cuti',
|
||||
'vacay.entitlementDays': 'Hari',
|
||||
'vacay.used': 'Terpakai',
|
||||
|
||||
@@ -31,6 +31,9 @@ const vacay: TranslationStrings = {
|
||||
'vacay.modeHalf': 'Mezza giornata',
|
||||
'vacay.modeHalfHint': 'Registra mezze giornate di ferie',
|
||||
'vacay.fullDay': 'Giornata intera',
|
||||
'vacay.modeComp': 'Recupero / Flex',
|
||||
'vacay.modeCompHint': 'Registra giorni di recupero / flex (gratis — nessuna ferie usata)',
|
||||
'vacay.compHalf': 'Mezza giornata di recupero',
|
||||
'vacay.entitlement': 'Disponibilità',
|
||||
'vacay.entitlementDays': 'Giorni',
|
||||
'vacay.used': 'Usati',
|
||||
|
||||
@@ -30,6 +30,9 @@ const vacay: TranslationStrings = {
|
||||
'vacay.modeHalf': '半休',
|
||||
'vacay.modeHalfHint': '半休を登録',
|
||||
'vacay.fullDay': '全休',
|
||||
'vacay.modeComp': '代休・フレックス',
|
||||
'vacay.modeCompHint': '代休・フレックス日を登録(休暇消費なし)',
|
||||
'vacay.compHalf': '半代休',
|
||||
'vacay.entitlement': '付与日数',
|
||||
'vacay.entitlementDays': '日',
|
||||
'vacay.used': '使用済み',
|
||||
|
||||
@@ -30,6 +30,9 @@ const vacay: TranslationStrings = {
|
||||
'vacay.modeHalf': '반차',
|
||||
'vacay.modeHalfHint': '반차 등록',
|
||||
'vacay.fullDay': '종일',
|
||||
'vacay.modeComp': '대체휴무',
|
||||
'vacay.modeCompHint': '대체/보상 휴무 등록 (무료 — 휴가 차감 없음)',
|
||||
'vacay.compHalf': '반일 대체휴무',
|
||||
'vacay.entitlement': '휴가 일수',
|
||||
'vacay.entitlementDays': '일',
|
||||
'vacay.used': '사용',
|
||||
|
||||
@@ -30,6 +30,9 @@ const vacay: TranslationStrings = {
|
||||
'vacay.modeHalf': 'Halve dag',
|
||||
'vacay.modeHalfHint': 'Halve vakantiedagen invoeren',
|
||||
'vacay.fullDay': 'Hele dag',
|
||||
'vacay.modeComp': 'Compensatie',
|
||||
'vacay.modeCompHint': 'Compensatie-/flexdagen invoeren (gratis — geen vakantie gebruikt)',
|
||||
'vacay.compHalf': 'Halve compensatiedag',
|
||||
'vacay.entitlement': 'Recht',
|
||||
'vacay.entitlementDays': 'Dagen',
|
||||
'vacay.used': 'Gebruikt',
|
||||
|
||||
@@ -30,6 +30,9 @@ const vacay: TranslationStrings = {
|
||||
'vacay.modeHalf': 'Pół dnia',
|
||||
'vacay.modeHalfHint': 'Zapisz pół dnia urlopu',
|
||||
'vacay.fullDay': 'Cały dzień',
|
||||
'vacay.modeComp': 'Odbiór',
|
||||
'vacay.modeCompHint': 'Zapisz dni odbioru / czasu wolnego (za darmo — bez urlopu)',
|
||||
'vacay.compHalf': 'Pół dnia odbioru',
|
||||
'vacay.entitlement': 'Wymiar',
|
||||
'vacay.entitlementDays': 'Dni',
|
||||
'vacay.used': 'Wykorzystane',
|
||||
|
||||
@@ -30,6 +30,9 @@ const vacay: TranslationStrings = {
|
||||
'vacay.modeHalf': 'Полдня',
|
||||
'vacay.modeHalfHint': 'Отмечать полдня отпуска',
|
||||
'vacay.fullDay': 'Полный день',
|
||||
'vacay.modeComp': 'Отгул',
|
||||
'vacay.modeCompHint': 'Отмечать отгулы (бесплатно — отпуск не расходуется)',
|
||||
'vacay.compHalf': 'Полдня отгула',
|
||||
'vacay.entitlement': 'Право на отпуск',
|
||||
'vacay.entitlementDays': 'Дни',
|
||||
'vacay.used': 'Использовано',
|
||||
|
||||
@@ -30,6 +30,9 @@ const vacay: TranslationStrings = {
|
||||
'vacay.modeHalf': 'Halvdag',
|
||||
'vacay.modeHalfHint': 'Registrera halva semesterdagar',
|
||||
'vacay.fullDay': 'Heldag',
|
||||
'vacay.modeComp': 'Komp / Flex',
|
||||
'vacay.modeCompHint': 'Registrera komp-/flexdagar (gratis — ingen semester används)',
|
||||
'vacay.compHalf': 'Halv kompdag',
|
||||
'vacay.entitlement': 'Rättighet',
|
||||
'vacay.entitlementDays': 'Dagar',
|
||||
'vacay.used': 'Använt',
|
||||
|
||||
@@ -30,6 +30,9 @@ const vacay: TranslationStrings = {
|
||||
'vacay.modeHalf': 'Yarım gün',
|
||||
'vacay.modeHalfHint': 'Yarım izin günü ekle',
|
||||
'vacay.fullDay': 'Tam gün',
|
||||
'vacay.modeComp': 'Serbest zaman',
|
||||
'vacay.modeCompHint': 'Serbest zaman / esnek gün ekle (ücretsiz — izin kullanılmaz)',
|
||||
'vacay.compHalf': 'Yarım serbest zaman günü',
|
||||
'vacay.entitlement': 'Yetki',
|
||||
'vacay.entitlementDays': 'Günler',
|
||||
'vacay.used': 'Kullanılmış',
|
||||
|
||||
@@ -30,6 +30,9 @@ const vacay: TranslationStrings = {
|
||||
'vacay.modeHalf': 'Пів дня',
|
||||
'vacay.modeHalfHint': 'Позначати пів дня відпустки',
|
||||
'vacay.fullDay': 'Повний день',
|
||||
'vacay.modeComp': 'Відгул',
|
||||
'vacay.modeCompHint': 'Позначати дні відгулу (безкоштовно — відпустка не використовується)',
|
||||
'vacay.compHalf': 'Пів дня відгулу',
|
||||
'vacay.entitlement': 'Право на відпустку',
|
||||
'vacay.entitlementDays': 'Дні',
|
||||
'vacay.used': 'Використано',
|
||||
|
||||
@@ -30,6 +30,9 @@ const vacay: TranslationStrings = {
|
||||
'vacay.modeHalf': 'Nửa ngày',
|
||||
'vacay.modeHalfHint': 'Ghi nửa ngày nghỉ',
|
||||
'vacay.fullDay': 'Cả ngày',
|
||||
'vacay.modeComp': 'Nghỉ bù',
|
||||
'vacay.modeCompHint': 'Ghi ngày nghỉ bù (miễn phí — không dùng phép)',
|
||||
'vacay.compHalf': 'Nửa ngày nghỉ bù',
|
||||
'vacay.entitlement': 'Quyền lợi',
|
||||
'vacay.entitlementDays': 'Ngày',
|
||||
'vacay.used': 'Đã dùng',
|
||||
|
||||
@@ -30,6 +30,9 @@ const vacay: TranslationStrings = {
|
||||
'vacay.modeHalf': '半天',
|
||||
'vacay.modeHalfHint': '記錄半天假期',
|
||||
'vacay.fullDay': '全天',
|
||||
'vacay.modeComp': '補休 / 彈性',
|
||||
'vacay.modeCompHint': '記錄補休/彈性假(免費 — 不佔用年假)',
|
||||
'vacay.compHalf': '半天補休',
|
||||
'vacay.entitlement': '年假額度',
|
||||
'vacay.entitlementDays': '天',
|
||||
'vacay.used': '已用',
|
||||
|
||||
@@ -30,6 +30,9 @@ const vacay: TranslationStrings = {
|
||||
'vacay.modeHalf': '半天',
|
||||
'vacay.modeHalfHint': '记录半天假期',
|
||||
'vacay.fullDay': '全天',
|
||||
'vacay.modeComp': '调休',
|
||||
'vacay.modeCompHint': '记录调休/弹性假(免费 — 不占用年假)',
|
||||
'vacay.compHalf': '半天调休',
|
||||
'vacay.entitlement': '年假额度',
|
||||
'vacay.entitlementDays': '天',
|
||||
'vacay.used': '已用',
|
||||
|
||||
@@ -51,6 +51,9 @@ export const vacayToggleEntryRequestSchema = z.object({
|
||||
target_user_id: z.union([z.number(), z.string()]).optional(),
|
||||
// Half vacation days (#552): 0.5 logs a half day, 1 (or omitted) a full day.
|
||||
fraction: z.union([z.literal(0.5), z.literal(1)]).optional(),
|
||||
// Leave type (#1074): 'comp' logs a flex/comp day (does not touch the
|
||||
// entitlement), 'vacation' (or omitted) a regular vacation day.
|
||||
kind: z.enum(['vacation', 'comp']).optional(),
|
||||
});
|
||||
export type VacayToggleEntryRequest = z.infer<typeof vacayToggleEntryRequestSchema>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user