From c475e5b04c2bd90ca1d5ef1201ef25fc0fd76e3d Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 24 Jul 2026 20:40:15 +0200 Subject: [PATCH] feat(vacay): comp/flex leave type (#1074) + configurable-year backend (#737) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- client/src/components/Vacay/VacayCalendar.tsx | 42 ++++-- .../src/components/Vacay/VacayMonthCard.tsx | 32 +++-- client/src/mobile/screens/vacay/MVacay.tsx | 16 ++- .../src/mobile/screens/vacay/MVacayMonth.tsx | 20 ++- client/src/mobile/screens/vacay/useMVacay.ts | 8 +- .../src/mobile/screens/vacay/vacayDayModel.ts | 22 +++- client/src/store/vacayStore.ts | 21 +-- client/src/types.ts | 8 +- server/src/db/migrations.ts | 22 ++++ server/src/mcp/tools/vacay.ts | 2 +- .../src/nest/plugins/host/create-rpc-host.ts | 2 +- server/src/nest/vacay/vacay.controller.ts | 4 +- server/src/nest/vacay/vacay.service.ts | 4 +- server/src/services/vacayService.ts | 121 +++++++++++++++--- shared/src/i18n/ar/vacay.ts | 3 + shared/src/i18n/br/vacay.ts | 3 + shared/src/i18n/ca/vacay.ts | 3 + shared/src/i18n/cs/vacay.ts | 3 + shared/src/i18n/de/vacay.ts | 3 + shared/src/i18n/en/vacay.ts | 3 + shared/src/i18n/es/vacay.ts | 3 + shared/src/i18n/fr/vacay.ts | 3 + shared/src/i18n/gr/vacay.ts | 3 + shared/src/i18n/hu/vacay.ts | 3 + shared/src/i18n/id/vacay.ts | 3 + shared/src/i18n/it/vacay.ts | 3 + shared/src/i18n/ja/vacay.ts | 3 + shared/src/i18n/ko/vacay.ts | 3 + shared/src/i18n/nl/vacay.ts | 3 + shared/src/i18n/pl/vacay.ts | 3 + shared/src/i18n/ru/vacay.ts | 3 + shared/src/i18n/sv/vacay.ts | 3 + shared/src/i18n/tr/vacay.ts | 3 + shared/src/i18n/uk/vacay.ts | 3 + shared/src/i18n/vi/vacay.ts | 3 + shared/src/i18n/zh-TW/vacay.ts | 3 + shared/src/i18n/zh/vacay.ts | 3 + shared/src/vacay/vacay.schema.ts | 3 + 38 files changed, 329 insertions(+), 67 deletions(-) diff --git a/client/src/components/Vacay/VacayCalendar.tsx b/client/src/components/Vacay/VacayCalendar.tsx index 971cd4da0..2938cf066 100644 --- a/client/src/components/Vacay/VacayCalendar.tsx +++ b/client/src/components/Vacay/VacayCalendar.tsx @@ -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>(new Set()) const [tip, setTip] = useState(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 (
@@ -145,13 +155,13 @@ export default function VacayCalendar() {
{tipDate}
{(tipEntries ?? []).map((e, i) => { - const isHalf = (e.fraction ?? 1) === 0.5 + const emphasized = (e.fraction ?? 1) === 0.5 || e.kind === 'comp' return (
{e.person_name} - - {isHalf ? t('vacay.modeHalf') : t('vacay.fullDay')} + + {dayTypeLabel(e.fraction, e.kind)}
) @@ -162,7 +172,7 @@ export default function VacayCalendar() { {m.name} - {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)}
))} @@ -213,9 +223,21 @@ export default function VacayCalendar() { )} - {/* Divider — the half-day switch is a modifier, not a mode. */} + {/* Divider — comp/flex and half-day are modifiers, not modes. */} + + )} - {/* 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. */} +