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
+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) {