refactor(vacay): share the settings logic between desktop and phone

The holiday countries, the calendars split by type, the weekend day toggle, dissolving a fusion, the leave year save and window label, the region options and the add check now live in useVacaySettings. The phone sheet still loads countries only while open.
This commit is contained in:
Maurice
2026-10-09 10:39:07 +02:00
parent e4179dbc11
commit 88a2479627
4 changed files with 400 additions and 177 deletions
+20 -90
View File
@@ -1,14 +1,13 @@
import { useState, useEffect, useMemo } from 'react'
import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe, Loader2, Plus, Trash2, CalendarDays, CalendarRange, GraduationCap } from 'lucide-react'
import { useVacayStore } from '../../store/vacayStore'
import { getIntlLanguage, useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import CustomSelect from '../shared/CustomSelect'
import apiClient from '../../api/client'
import { fetchRegionOptions, fetchSchoolHolidayRegionOptions } from './holidayRegions'
import { useSchoolHolidayCountries } from './useSchoolHolidayCountries'
import { windowMonths } from '../../vacay/yearWindow'
import type { VacayHolidayCalendar, VacayYearSettings } from '../../types'
import type { VacayHolidayCalendar } from '../../types'
import {
canAddHolidayCalendar, monthDayCap, parseHolidayRegion, useLeaveYear, useRegionOptions, useVacaySettings,
} from './useVacaySettings'
interface VacaySettingsProps {
onClose: () => void
@@ -16,35 +15,22 @@ interface VacaySettingsProps {
export default function VacaySettings({ onClose }: VacaySettingsProps) {
const { t } = useTranslation()
const toast = useToast()
const { plan, updatePlan, addHolidayCalendar, updateHolidayCalendar, deleteHolidayCalendar, isFused, dissolve, users } = useVacayStore()
const [countries, setCountries] = useState<{ value: string; label: string }[]>([])
const { plan, updatePlan, addHolidayCalendar, updateHolidayCalendar, deleteHolidayCalendar, isFused, users } = useVacayStore()
const [showAddForm, setShowAddForm] = useState(false)
const [showAddSchoolForm, setShowAddSchoolForm] = useState(false)
const { language } = useTranslation()
// Load available countries with localized names
useEffect(() => {
apiClient.get('/addons/vacay/holidays/countries').then(r => {
let displayNames
try { displayNames = new Intl.DisplayNames([getIntlLanguage(language)], { type: 'region' }) } catch { /* */ }
const list = r.data.map(c => ({
value: c.countryCode,
label: displayNames ? (displayNames.of(c.countryCode) || c.name) : c.name,
}))
list.sort((a, b) => a.label.localeCompare(b.label))
setCountries(list)
}).catch(() => {})
}, [language])
// Available countries with localized names, the calendars by type, weekend days, dissolving
const {
countries, publicHolidayCalendars, schoolHolidayCalendars, weekendDays, toggleWeekendDay, dissolveFusion,
} = useVacaySettings({ language, loadCountries: true })
const { countries: schoolHolidayCountries, error: schoolCountryError } = useSchoolHolidayCountries()
if (!plan) return null
const toggle = (key: string) => updatePlan({ [key]: !plan[key] })
const publicHolidayCalendars = (plan.holiday_calendars ?? []).filter(cal => (cal.type ?? 'public_holiday') === 'public_holiday')
const schoolHolidayCalendars = (plan.holiday_calendars ?? []).filter(cal => cal.type === 'school_holiday')
return (
<div className="space-y-4">
@@ -74,13 +60,9 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
{ day: 6, label: t('vacay.sat') },
{ day: 0, label: t('vacay.sun') },
].map(({ day, label }) => {
const current: number[] = plan.weekend_days ? String(plan.weekend_days).split(',').map(Number) : [0, 6]
const active = current.includes(day)
const active = weekendDays.includes(day)
return (
<button type="button" key={day} onClick={() => {
const next = active ? current.filter(d => d !== day) : [...current, day]
updatePlan({ weekend_days: next.join(',') })
}}
<button type="button" key={day} onClick={() => toggleWeekendDay(day)}
style={{
padding: '4px 10px', borderRadius: 8, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer',
fontFamily: 'inherit', border: '1px solid', transition: 'all 0.12s',
@@ -281,11 +263,7 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
</div>
<div className="px-4 py-3 border-t border-t-[rgba(239,68,68,0.1)]">
<button type="button"
onClick={async () => {
await dissolve()
toast.success(t('vacay.dissolved'))
onClose()
}}
onClick={() => dissolveFusion(onClose)}
className="w-full px-3 py-2 text-xs font-medium bg-red-500 hover:bg-red-600 text-white rounded-lg transition-colors"
>
{t('vacay.dissolveAction')}
@@ -307,8 +285,7 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
*/
function YearTypePicker() {
const { t, locale } = useTranslation()
const { yearSettings, updateYearSettings, selectedYear } = useVacayStore()
const type = yearSettings.year_type
const { yearSettings, type, selectedYear, save, windowLabel } = useLeaveYear(locale)
const monthNames = useMemo(() => {
const fmt = new Intl.DateTimeFormat(locale, { month: 'long' })
@@ -316,21 +293,7 @@ function YearTypePicker() {
}, [locale])
// February caps at 28 so a window start never lands on a date some years lack.
const maxDay = daysInMonth(yearSettings.year_start_month)
const save = (patch: Partial<VacayYearSettings>) => {
const next = { ...yearSettings, ...patch }
updateYearSettings({
year_type: next.year_type,
year_start_month: next.year_start_month,
year_start_day: Math.min(next.year_start_day, daysInMonth(next.year_start_month)),
hire_date: next.hire_date,
})
}
const months = windowMonths(selectedYear, yearSettings)
const shortMonth = new Intl.DateTimeFormat(locale, { month: 'short', year: 'numeric' })
const windowLabel = `${shortMonth.format(new Date(months[0].year, months[0].month, 1))} – ${shortMonth.format(new Date(months[11].year, months[11].month, 1))}`
const maxDay = monthDayCap(yearSettings.year_start_month)
return (
<div>
@@ -403,12 +366,6 @@ function YearTypePicker() {
)
}
/** Days a month can always offer — February caps at 28 so no window start is skipped in a common year. */
function daysInMonth(month: number): number {
if (month === 2) return 28
return [4, 6, 9, 11].includes(month) ? 30 : 31
}
interface SettingToggleProps {
icon: LucideIcon
label: string
@@ -436,30 +393,6 @@ function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingTogg
)
}
/**
* Region options for a holiday-calendar country.
*
* The loaded list is tagged with the country it belongs to, so switching country
* drops the previous list in the same render instead of leaving it selectable while
* the next request is still running (#1813: picking a German region for the
* Netherlands was possible that way). `loadingRegions` tells "not answered yet"
* apart from "this country has no regions"; on the list alone both are empty.
*/
function useRegionOptions(country: string, calendarType: 'public_holiday' | 'school_holiday') {
const [loaded, setLoaded] = useState<{ country: string; options: { value: string; label: string }[]; failed?: boolean }>({ country: '', options: [] })
useEffect(() => {
if (!country) return
const load = calendarType === 'school_holiday' ? fetchSchoolHolidayRegionOptions : fetchRegionOptions
let stale = false
load(country).then(options => { if (!stale) setLoaded({ country, options }) }).catch(() => { if (!stale) setLoaded({ country, options: [], failed: true }) })
return () => { stale = true }
}, [calendarType, country])
const ready = loaded.country === country
return { regions: ready ? loaded.options : [], regionError: ready && loaded.failed, loadingRegions: Boolean(country) && !ready }
}
// ── Existing calendar row (inline edit) ──────────────────────────────────────
function CalendarRow({ cal, countries, calendarType, onUpdate, onDelete }: {
cal: VacayHolidayCalendar
@@ -473,9 +406,7 @@ function CalendarRow({ cal, countries, calendarType, onUpdate, onDelete }: {
const [localColor, setLocalColor] = useState(cal.color)
const [localLabel, setLocalLabel] = useState(cal.label || '')
const [baseRegion] = cal.region.split('|')
const selectedCountry = baseRegion.split('-')[0]
const selectedRegion = cal.region.includes('|group:') || baseRegion.includes('-') ? cal.region : ''
const { country: selectedCountry, region: selectedRegion } = parseHolidayRegion(cal.region)
const { regions, regionError } = useRegionOptions(selectedCountry, calendarType)
useEffect(() => { setLocalColor(cal.color) }, [cal.color])
@@ -555,15 +486,14 @@ function AddCalendarForm({ countries, calendarType, onAdd, onCancel, defaultColo
const [color, setColor] = useState(defaultColor)
const [label, setLabel] = useState('')
const [baseRegion] = region.split('|')
const selectedCountry = baseRegion.split('-')[0] || ''
const selectedRegion = region.includes('|group:') || baseRegion.includes('-') ? region : ''
const { regions, loadingRegions, regionError } = useRegionOptions(selectedCountry, calendarType)
const { country: selectedCountry, region: selectedRegion } = parseHolidayRegion(region)
const regionOptions = useRegionOptions(selectedCountry, calendarType)
const { regions, loadingRegions, regionError } = regionOptions
// Adding is blocked while the region list is on its way: an empty list would
// otherwise pass as "this country needs no region" and create a calendar that
// draws nothing (#1813). The button shows a spinner so the wait is visible.
const canAdd = Boolean(selectedCountry) && !loadingRegions && !regionError && (regions.length === 0 || regions.some(option => option.value === region))
const canAdd = canAddHolidayCalendar(selectedCountry, region, regionOptions)
const PRESET_COLORS = ['#fecaca', '#fed7aa', '#fde68a', '#bbf7d0', '#a5f3fc', '#c7d2fe', '#e9d5ff', '#fda4af', '#6366f1', '#ef4444', '#22c55e', '#3b82f6']
const [showColorPicker, setShowColorPicker] = useState(false)
@@ -0,0 +1,196 @@
// FE-COMP-VACAYSET-001 to -011: the vacay settings logic the desktop panel and the phone sheet share.
import { act, renderHook, waitFor } from '@testing-library/react';
import apiClient from '../../api/client';
import { useVacayStore } from '../../store/vacayStore';
import type { VacayPlan, VacayYearSettings } from '../../types';
import {
canAddHolidayCalendar,
monthDayCap,
parseHolidayRegion,
useLeaveYear,
useRegionOptions,
useVacaySettings,
} from './useVacaySettings';
const toast = { success: vi.fn(), error: vi.fn() };
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
vi.mock('../../i18n', () => ({
useTranslation: () => ({ t: (k: string) => k }),
getIntlLanguage: (language: string) => language,
}));
const regionsApi = vi.hoisted(() => ({
fetchRegionOptions: vi.fn<(country: string) => Promise<{ value: string; label: string }[]>>(),
fetchSchoolHolidayRegionOptions: vi.fn<(country: string) => Promise<{ value: string; label: string }[]>>(),
}));
vi.mock('./holidayRegions', () => regionsApi);
const updatePlan = vi.fn(async () => {});
const dissolve = vi.fn(async () => {});
const updateYearSettings = vi.fn(async () => {});
const CALENDAR: VacayYearSettings = { year_type: 'calendar', year_start_month: 1, year_start_day: 1, hire_date: null };
function plan(over: Partial<VacayPlan> = {}): VacayPlan {
return { id: 1, block_weekends: true, holiday_calendars: [], ...over } as VacayPlan;
}
beforeEach(() => {
updatePlan.mockClear();
dissolve.mockClear();
updateYearSettings.mockClear();
toast.success.mockReset();
useVacayStore.setState({
plan: plan(),
updatePlan,
dissolve,
updateYearSettings,
yearSettings: CALENDAR,
selectedYear: 2026,
} as never);
});
afterEach(() => vi.restoreAllMocks());
describe('useVacaySettings', () => {
it('FE-COMP-VACAYSET-001: loads the countries sorted by their localized name', async () => {
const get = vi.spyOn(apiClient, 'get').mockResolvedValue({
data: [
{ countryCode: 'DE', name: 'Germany' },
{ countryCode: 'AT', name: 'Austria' },
],
});
const { result } = renderHook(() => useVacaySettings({ language: 'en', loadCountries: true }));
await waitFor(() => expect(result.current.countries).toHaveLength(2));
expect(get).toHaveBeenCalledWith('/addons/vacay/holidays/countries');
expect(result.current.countries).toEqual([
{ value: 'AT', label: 'Austria' },
{ value: 'DE', label: 'Germany' },
]);
});
it('FE-COMP-VACAYSET-002: holds the country request back until the sheet opens', async () => {
const get = vi.spyOn(apiClient, 'get').mockResolvedValue({ data: [] });
const { rerender } = renderHook(
(p: { open: boolean }) => useVacaySettings({ language: 'en', loadCountries: p.open }),
{
initialProps: { open: false },
}
);
expect(get).not.toHaveBeenCalled();
rerender({ open: true });
await waitFor(() => expect(get).toHaveBeenCalledTimes(1));
});
it('FE-COMP-VACAYSET-003: splits the calendars by type, an untyped one counting as public', () => {
vi.spyOn(apiClient, 'get').mockResolvedValue({ data: [] });
useVacayStore.setState({
plan: plan({
holiday_calendars: [
{ id: 1, region: 'DE' },
{ id: 2, region: 'FR', type: 'public_holiday' },
{ id: 3, region: 'DE-BY', type: 'school_holiday' },
],
} as never),
});
const { result } = renderHook(() => useVacaySettings({ language: 'en', loadCountries: false }));
expect(result.current.publicHolidayCalendars.map((c) => c.id)).toEqual([1, 2]);
expect(result.current.schoolHolidayCalendars.map((c) => c.id)).toEqual([3]);
});
it('FE-COMP-VACAYSET-004: toggles a weekend day in and out of the default Saturday and Sunday', () => {
const { result, rerender } = renderHook(() => useVacaySettings({ language: 'en', loadCountries: false }));
expect(result.current.weekendDays).toEqual([0, 6]);
act(() => result.current.toggleWeekendDay(5));
expect(updatePlan).toHaveBeenLastCalledWith({ weekend_days: '0,6,5' });
act(() => result.current.toggleWeekendDay(0));
expect(updatePlan).toHaveBeenLastCalledWith({ weekend_days: '6' });
useVacayStore.setState({ plan: plan({ weekend_days: '5,6' }) });
rerender();
expect(result.current.weekendDays).toEqual([5, 6]);
});
it('FE-COMP-VACAYSET-005: dissolving toasts and then hands over', async () => {
const order: string[] = [];
toast.success.mockImplementation(() => order.push('toast'));
const { result } = renderHook(() => useVacaySettings({ language: 'en', loadCountries: false }));
await act(() => result.current.dissolveFusion(() => order.push('done')));
expect(dissolve).toHaveBeenCalled();
expect(toast.success).toHaveBeenCalledWith('vacay.dissolved');
expect(order).toEqual(['toast', 'done']);
});
it('FE-COMP-VACAYSET-006: works without a plan loaded yet', () => {
useVacayStore.setState({ plan: null });
const { result } = renderHook(() => useVacaySettings({ language: 'en', loadCountries: false }));
expect(result.current.publicHolidayCalendars).toEqual([]);
expect(result.current.weekendDays).toEqual([0, 6]);
});
});
describe('useLeaveYear', () => {
it('FE-COMP-VACAYSET-007: saves the whole year settings with the start day clamped to the month', () => {
useVacayStore.setState({ yearSettings: { ...CALENDAR, year_type: 'fiscal', year_start_day: 31 } });
const { result } = renderHook(() => useLeaveYear('en-GB'));
expect(result.current.type).toBe('fiscal');
act(() => result.current.save({ year_start_month: 2 }));
expect(updateYearSettings).toHaveBeenCalledWith({
year_type: 'fiscal',
year_start_month: 2,
year_start_day: 28,
hire_date: null,
});
});
it('FE-COMP-VACAYSET-008: labels the window the selected year spans', () => {
useVacayStore.setState({ yearSettings: { ...CALENDAR, year_type: 'fiscal', year_start_month: 7 } });
const { result } = renderHook(() => useLeaveYear('en-GB'));
expect(result.current.windowLabel).toBe('Jul 2026 – Jun 2027');
expect(result.current.selectedYear).toBe(2026);
});
});
describe('useRegionOptions', () => {
it('FE-COMP-VACAYSET-009: loads the regions per country and type and flags a failure', async () => {
const pub = regionsApi.fetchRegionOptions.mockResolvedValue([{ value: 'DE-BY', label: 'Bavaria' }]);
const school = regionsApi.fetchSchoolHolidayRegionOptions.mockRejectedValue(new Error('down'));
const empty = renderHook(() => useRegionOptions('', 'public_holiday'));
expect(empty.result.current).toMatchObject({ regions: [], loadingRegions: false });
expect(empty.result.current.regionError).toBeFalsy();
expect(pub).not.toHaveBeenCalled();
const publicHook = renderHook(() => useRegionOptions('DE', 'public_holiday'));
expect(publicHook.result.current.loadingRegions).toBe(true);
await waitFor(() => expect(publicHook.result.current.regions).toEqual([{ value: 'DE-BY', label: 'Bavaria' }]));
expect(pub).toHaveBeenCalledWith('DE');
const schoolHook = renderHook(() => useRegionOptions('DE', 'school_holiday'));
await waitFor(() => expect(schoolHook.result.current.regionError).toBe(true));
expect(school).toHaveBeenCalledWith('DE');
expect(schoolHook.result.current.loadingRegions).toBe(false);
});
});
describe('settings helpers', () => {
it('FE-COMP-VACAYSET-010: reads the country and region off a stored region', () => {
expect(parseHolidayRegion('')).toEqual({ country: '', region: '' });
expect(parseHolidayRegion('DE')).toEqual({ country: 'DE', region: '' });
expect(parseHolidayRegion('DE-BY')).toEqual({ country: 'DE', region: 'DE-BY' });
expect(parseHolidayRegion('NL|group:north')).toEqual({ country: 'NL', region: 'NL|group:north' });
expect(monthDayCap(2)).toBe(28);
expect(monthDayCap(4)).toBe(30);
expect(monthDayCap(12)).toBe(31);
});
it('FE-COMP-VACAYSET-011: a calendar can be added once the regions answered and the pick fits them', () => {
const regions = [{ value: 'DE-BY', label: 'Bavaria' }];
const ready = { regions, loadingRegions: false, regionError: false };
expect(canAddHolidayCalendar('', '', { regions: [], loadingRegions: false, regionError: false })).toBe(false);
expect(canAddHolidayCalendar('DE', 'DE', { ...ready, loadingRegions: true })).toBe(false);
expect(canAddHolidayCalendar('DE', 'DE', { regions: [], loadingRegions: false, regionError: true })).toBe(false);
expect(canAddHolidayCalendar('DE', 'DE', ready)).toBe(false);
expect(canAddHolidayCalendar('DE', 'DE-BY', ready)).toBe(true);
expect(canAddHolidayCalendar('FR', 'FR', { regions: [], loadingRegions: false, regionError: false })).toBe(true);
});
});
@@ -0,0 +1,167 @@
import { useEffect, useState } from 'react';
import apiClient from '../../api/client';
import { getIntlLanguage, useTranslation } from '../../i18n';
import { useVacayStore } from '../../store/vacayStore';
import type { VacayYearSettings } from '../../types';
import { windowMonths } from '../../vacay/yearWindow';
import { useToast } from '../shared/Toast';
import { fetchRegionOptions, fetchSchoolHolidayRegionOptions } from './holidayRegions';
interface RegionOption {
value: string;
label: string;
}
type CalendarType = 'public_holiday' | 'school_holiday';
/**
* The plan side of the vacay settings, shared by the desktop panel and the phone
* sheet: the public-holiday countries, the plan's calendars split by type, the
* weekend days and dissolving a fusion. `loadCountries` holds the country request
* back while the phone sheet is closed.
*/
export function useVacaySettings({ language, loadCountries }: { language: string; loadCountries: boolean }) {
const { t } = useTranslation();
const toast = useToast();
const plan = useVacayStore((s) => s.plan);
const updatePlan = useVacayStore((s) => s.updatePlan);
const dissolve = useVacayStore((s) => s.dissolve);
const [countries, setCountries] = useState<RegionOption[]>([]);
// Available countries with localized names
useEffect(() => {
if (!loadCountries) return;
apiClient
.get('/addons/vacay/holidays/countries')
.then((r) => {
let displayNames: Intl.DisplayNames | undefined;
try {
displayNames = new Intl.DisplayNames([getIntlLanguage(language)], { type: 'region' });
} catch {
/* */
}
const list: RegionOption[] = r.data.map((c: { countryCode: string; name: string }) => ({
value: c.countryCode,
label: displayNames ? displayNames.of(c.countryCode) || c.name : c.name,
}));
list.sort((a, b) => a.label.localeCompare(b.label));
setCountries(list);
})
.catch(() => {});
}, [loadCountries, language]);
// Public and school calendars live in the same holiday_calendars list, split by type
// (a null type predates the school-holiday feature, so it counts as a public holiday).
const calendars = plan?.holiday_calendars ?? [];
const publicHolidayCalendars = calendars.filter((cal) => (cal.type ?? 'public_holiday') === 'public_holiday');
const schoolHolidayCalendars = calendars.filter((cal) => cal.type === 'school_holiday');
const weekendDays: number[] = plan?.weekend_days ? String(plan.weekend_days).split(',').map(Number) : [0, 6];
const toggleWeekendDay = (day: number) => {
const next = weekendDays.includes(day) ? weekendDays.filter((d) => d !== day) : [...weekendDays, day];
updatePlan({ weekend_days: next.join(',') });
};
const dissolveFusion = async (onDone: () => void) => {
await dissolve();
toast.success(t('vacay.dissolved'));
onDone();
};
return { countries, publicHolidayCalendars, schoolHolidayCalendars, weekendDays, toggleWeekendDay, dissolveFusion };
}
/** Days a month can always offer: February caps at 28 so no window start is skipped in a common year. */
export function monthDayCap(month: number): number {
if (month === 2) return 28;
return [4, 6, 9, 11].includes(month) ? 30 : 31;
}
/**
* The viewer's own leave year (#737): calendar, fiscal or anniversary, saved with
* the start day clamped to the month, and the window it spans for `selectedYear`.
*/
export function useLeaveYear(locale: string) {
const yearSettings = useVacayStore((s) => s.yearSettings);
const updateYearSettings = useVacayStore((s) => s.updateYearSettings);
const selectedYear = useVacayStore((s) => s.selectedYear);
const save = (patch: Partial<VacayYearSettings>) => {
const next = { ...yearSettings, ...patch };
updateYearSettings({
year_type: next.year_type,
year_start_month: next.year_start_month,
year_start_day: Math.min(next.year_start_day, monthDayCap(next.year_start_month)),
hire_date: next.hire_date,
});
};
const months = windowMonths(selectedYear, yearSettings);
const shortMonth = new Intl.DateTimeFormat(locale, { month: 'short', year: 'numeric' });
const windowLabel = `${shortMonth.format(new Date(months[0].year, months[0].month, 1))} – ${shortMonth.format(new Date(months[11].year, months[11].month, 1))}`;
return { yearSettings, type: yearSettings.year_type, selectedYear, save, windowLabel };
}
/** A calendar's stored region split into the country and the region within it (or ''). */
export function parseHolidayRegion(stored: string): { country: string; region: string } {
const [baseRegion] = stored.split('|');
return {
country: baseRegion.split('-')[0] || '',
region: stored.includes('|group:') || baseRegion.includes('-') ? stored : '',
};
}
/**
* Region options for a holiday-calendar country.
*
* The loaded list is tagged with the country it belongs to, so switching country
* drops the previous list in the same render instead of leaving it selectable while
* the next request is still running (#1813: picking a German region for the
* Netherlands was possible that way). `loadingRegions` tells "not answered yet"
* apart from "this country has no regions"; on the list alone both are empty.
*/
export function useRegionOptions(country: string, calendarType: CalendarType) {
const [loaded, setLoaded] = useState<{ country: string; options: RegionOption[]; failed?: boolean }>({
country: '',
options: [],
});
useEffect(() => {
if (!country) return;
const load = calendarType === 'school_holiday' ? fetchSchoolHolidayRegionOptions : fetchRegionOptions;
let stale = false;
load(country)
.then((options) => {
if (!stale) setLoaded({ country, options });
})
.catch(() => {
if (!stale) setLoaded({ country, options: [], failed: true });
});
return () => {
stale = true;
};
}, [calendarType, country]);
const ready = loaded.country === country;
return {
regions: ready ? loaded.options : [],
regionError: ready && loaded.failed,
loadingRegions: Boolean(country) && !ready,
};
}
/** A new calendar can be added once its country's regions answered and the pick is one of them (or none exist). */
export function canAddHolidayCalendar(
country: string,
picked: string,
{ regions, loadingRegions, regionError }: ReturnType<typeof useRegionOptions>
): boolean {
return (
Boolean(country) &&
!loadingRegions &&
!regionError &&
(regions.length === 0 || regions.some((option) => option.value === picked))
);
}
@@ -4,14 +4,13 @@ import MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn'
import MToggle from '../../components/MToggle'
import { useVacayStore } from '../../../store/vacayStore'
import { getIntlLanguage, useTranslation } from '../../../i18n'
import { useToast } from '../../../components/shared/Toast'
import apiClient from '../../../api/client'
import { fetchRegionOptions, fetchSchoolHolidayRegionOptions } from '../../../components/Vacay/holidayRegions'
import { useTranslation } from '../../../i18n'
import { useSchoolHolidayCountries } from '../../../components/Vacay/useSchoolHolidayCountries'
import { windowMonths } from '../../../vacay/yearWindow'
import {
canAddHolidayCalendar, monthDayCap, parseHolidayRegion, useLeaveYear, useRegionOptions, useVacaySettings,
} from '../../../components/Vacay/useVacaySettings'
import { FALLBACK_PERSON_COLOR } from './vacayDayModel'
import type { VacayHolidayCalendar, VacayYearSettings } from '../../../types'
import type { VacayHolidayCalendar } from '../../../types'
const CALENDAR_COLORS = ['#fecaca', '#fed7aa', '#fde68a', '#bbf7d0', '#a5f3fc', '#c7d2fe', '#e9d5ff', '#fda4af']
@@ -29,36 +28,17 @@ interface MVacaySettingsSheetProps {
*/
export default function MVacaySettingsSheet({ open, onClose }: MVacaySettingsSheetProps) {
const { t, language } = useTranslation()
const toast = useToast()
const { plan, updatePlan, addHolidayCalendar, updateHolidayCalendar, deleteHolidayCalendar, isFused, dissolve, users } = useVacayStore()
const [countries, setCountries] = useState<Option[]>([])
const { plan, updatePlan, addHolidayCalendar, updateHolidayCalendar, deleteHolidayCalendar, isFused, users } = useVacayStore()
const [showAddForm, setShowAddForm] = useState(false)
const [showAddSchoolForm, setShowAddSchoolForm] = useState(false)
useEffect(() => {
if (!open) return
apiClient.get('/addons/vacay/holidays/countries').then(r => {
let displayNames: Intl.DisplayNames | undefined
try { displayNames = new Intl.DisplayNames([getIntlLanguage(language)], { type: 'region' }) } catch { /* */ }
const list: Option[] = r.data.map((c: { countryCode: string; name: string }) => ({
value: c.countryCode,
label: displayNames ? (displayNames.of(c.countryCode) || c.name) : c.name,
}))
list.sort((a, b) => a.label.localeCompare(b.label))
setCountries(list)
}).catch(() => {})
}, [open, language])
const {
countries, publicHolidayCalendars, schoolHolidayCalendars, weekendDays, toggleWeekendDay, dissolveFusion,
} = useVacaySettings({ language, loadCountries: open })
const { countries: schoolHolidayCountries, error: schoolCountryError } = useSchoolHolidayCountries(open)
if (!plan) return null
// Public and school calendars live in the same holiday_calendars list, split by type
// (a null type predates the school-holiday feature, so it counts as a public holiday).
const publicHolidayCalendars = (plan.holiday_calendars ?? []).filter(cal => (cal.type ?? 'public_holiday') === 'public_holiday')
const schoolHolidayCalendars = (plan.holiday_calendars ?? []).filter(cal => cal.type === 'school_holiday')
const weekendDays: number[] = plan.weekend_days ? String(plan.weekend_days).split(',').map(Number) : [0, 6]
const weekdayChips = [
{ day: 1, label: t('vacay.mon') },
{ day: 2, label: t('vacay.tue') },
@@ -100,10 +80,7 @@ export default function MVacaySettingsSheet({ open, onClose }: MVacaySettingsShe
<button
key={day}
type="button"
onClick={() => {
const next = active ? weekendDays.filter(d => d !== day) : [...weekendDays, day]
updatePlan({ weekend_days: next.join(',') })
}}
onClick={() => toggleWeekendDay(day)}
className={`flex-1 rounded-[10px] py-[7px] text-center text-[0.65625rem] font-semibold ${
active ? 'bg-m-act text-m-actfg' : 'border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-ink'
}`}
@@ -274,7 +251,7 @@ export default function MVacaySettingsSheet({ open, onClose }: MVacaySettingsShe
</div>
<button
type="button"
onClick={async () => { await dissolve(); toast.success(t('vacay.dissolved')); onClose() }}
onClick={() => dissolveFusion(onClose)}
className="mt-[10px] w-full rounded-xl bg-[#D6273B] p-[11px] text-center text-[0.78125rem] font-bold text-white"
>
{t('vacay.dissolveAction')}
@@ -293,27 +270,13 @@ export default function MVacaySettingsSheet({ open, onClose }: MVacaySettingsShe
*/
function YearTypeRows() {
const { t, locale } = useTranslation()
const { yearSettings, updateYearSettings, selectedYear } = useVacayStore()
const type = yearSettings.year_type
const { yearSettings, type, selectedYear, save, windowLabel } = useLeaveYear(locale)
const monthOptions: Option[] = useMemo(() => {
const fmt = new Intl.DateTimeFormat(locale, { month: 'long' })
return Array.from({ length: 12 }, (_, i) => ({ value: String(i + 1), label: fmt.format(new Date(2026, i, 1)) }))
}, [locale])
const save = (patch: Partial<VacayYearSettings>) => {
const next = { ...yearSettings, ...patch }
updateYearSettings({
year_type: next.year_type,
year_start_month: next.year_start_month,
year_start_day: Math.min(next.year_start_day, monthDayCap(next.year_start_month)),
hire_date: next.hire_date,
})
}
const months = windowMonths(selectedYear, yearSettings)
const fmtShort = new Intl.DateTimeFormat(locale, { month: 'short', year: 'numeric' })
const windowLabel = `${fmtShort.format(new Date(months[0].year, months[0].month, 1))} – ${fmtShort.format(new Date(months[11].year, months[11].month, 1))}`
return (
<>
@@ -383,12 +346,6 @@ function YearTypeRows() {
)
}
/** Days a month always has — February caps at 28 so a start date never goes missing in a common year. */
function monthDayCap(month: number): number {
if (month === 2) return 28
return [4, 6, 9, 11].includes(month) ? 30 : 31
}
/** Styled native select in the sheet's row look (label + chevron). */
function SelectRow({ value, placeholder, options, onChange }: {
value: string
@@ -444,30 +401,6 @@ function ColorSwatch({ color, onPick }: { color: string; onPick: (color: string)
)
}
/**
* Region options for a holiday-calendar country.
*
* The loaded list is tagged with the country it belongs to, so switching country
* drops the previous list in the same render instead of leaving it selectable while
* the next request is still running (#1813: picking a German region for the
* Netherlands was possible that way). `loadingRegions` tells "not answered yet"
* apart from "this country has no regions"; on the list alone both are empty.
*/
function useRegionOptions(country: string, calendarType: 'public_holiday' | 'school_holiday') {
const [loaded, setLoaded] = useState<{ country: string; options: Option[]; failed?: boolean }>({ country: '', options: [] })
useEffect(() => {
if (!country) return
const load = calendarType === 'school_holiday' ? fetchSchoolHolidayRegionOptions : fetchRegionOptions
let stale = false
load(country).then(options => { if (!stale) setLoaded({ country, options }) }).catch(() => { if (!stale) setLoaded({ country, options: [], failed: true }) })
return () => { stale = true }
}, [country, calendarType])
const ready = loaded.country === country
return { regions: ready ? loaded.options : [], regionError: ready && loaded.failed, loadingRegions: Boolean(country) && !ready }
}
function CalendarEditor({ cal, countries, calendarType = 'public_holiday', onUpdate, onDelete }: {
cal: VacayHolidayCalendar
countries: Option[]
@@ -479,9 +412,7 @@ function CalendarEditor({ cal, countries, calendarType = 'public_holiday', onUpd
const [label, setLabel] = useState(cal.label || '')
// A school-holiday group carries a `|group:…` suffix — strip it before the
// country is read off, otherwise the region lookup finds nothing.
const [baseRegion] = cal.region.split('|')
const country = baseRegion.split('-')[0]
const region = cal.region.includes('|group:') || baseRegion.includes('-') ? cal.region : ''
const { country, region } = parseHolidayRegion(cal.region)
const { regions, regionError } = useRegionOptions(country, calendarType)
useEffect(() => { setLabel(cal.label || '') }, [cal.label])
@@ -522,15 +453,14 @@ function AddCalendarDraft({ countries, calendarType = 'public_holiday', onAdd, o
const [color, setColor] = useState(CALENDAR_COLORS[0])
const [label, setLabel] = useState('')
const [baseRegion] = region.split('|')
const country = baseRegion.split('-')[0] || ''
const selectedRegion = region.includes('|group:') || baseRegion.includes('-') ? region : ''
const { regions, loadingRegions, regionError } = useRegionOptions(country, calendarType)
const { country, region: selectedRegion } = parseHolidayRegion(region)
const regionOptions = useRegionOptions(country, calendarType)
const { regions, loadingRegions, regionError } = regionOptions
// Adding is blocked while the region list is on its way: an empty list would
// otherwise pass as "this country needs no region" and create a calendar that
// draws nothing (#1813). The button shows a spinner so the wait is visible.
const canAdd = Boolean(country) && !loadingRegions && !regionError && (regions.length === 0 || regions.some(option => option.value === region))
const canAdd = canAddHolidayCalendar(country, region, regionOptions)
return (
<div className="mt-2 rounded-[14px] border-[1.5px] border-dashed border-[color:var(--m-rowbr)] p-3">