diff --git a/client/src/components/Vacay/VacayMonthCard.tsx b/client/src/components/Vacay/VacayMonthCard.tsx index a012d0d..d47de05 100644 --- a/client/src/components/Vacay/VacayMonthCard.tsx +++ b/client/src/components/Vacay/VacayMonthCard.tsx @@ -10,6 +10,13 @@ const MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'] const MONTHS_ES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'] +function hexToRgba(hex: string, alpha: number): string { + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + return `rgba(${r},${g},${b},${alpha})` +} + interface VacayMonthCardProps { year: number month: number @@ -88,7 +95,7 @@ export default function VacayMonthCard({ onMouseEnter={e => { if (!isBlocked) e.currentTarget.style.background = 'var(--bg-hover)' }} onMouseLeave={e => { e.currentTarget.style.background = weekend ? 'var(--bg-secondary)' : 'transparent' }} > - {holiday &&
} + {holiday &&
} {isCompany &&
} {dayEntries.length === 1 && ( @@ -117,7 +124,7 @@ export default function VacayMonthCard({ )} 0 ? 700 : 500, }}> {day} diff --git a/client/src/components/Vacay/VacaySettings.tsx b/client/src/components/Vacay/VacaySettings.tsx index 626f29e..9c53c01 100644 --- a/client/src/components/Vacay/VacaySettings.tsx +++ b/client/src/components/Vacay/VacaySettings.tsx @@ -1,10 +1,11 @@ import { useState, useEffect } from 'react' -import { MapPin, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe } from 'lucide-react' +import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe, Plus, Trash2 } from 'lucide-react' import { useVacayStore } from '../../store/vacayStore' import { getIntlLanguage, useTranslation } from '../../i18n' import { useToast } from '../shared/Toast' import CustomSelect from '../shared/CustomSelect' import apiClient from '../../api/client' +import type { VacayHolidayCalendar } from '../../types' interface VacaySettingsProps { onClose: () => void @@ -13,10 +14,9 @@ interface VacaySettingsProps { export default function VacaySettings({ onClose }: VacaySettingsProps) { const { t } = useTranslation() const toast = useToast() - const { plan, updatePlan, isFused, dissolve, users } = useVacayStore() - const [countries, setCountries] = useState([]) - const [regions, setRegions] = useState([]) - const [loadingRegions, setLoadingRegions] = useState(false) + const { plan, updatePlan, addHolidayCalendar, updateHolidayCalendar, deleteHolidayCalendar, isFused, dissolve, users } = useVacayStore() + const [countries, setCountries] = useState<{ value: string; label: string }[]>([]) + const [showAddForm, setShowAddForm] = useState(false) const { language } = useTranslation() @@ -34,57 +34,9 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) { }).catch(() => {}) }, [language]) - // When country changes, check if it has regions - const selectedCountry = plan?.holidays_region?.split('-')[0] || '' - const selectedRegion = plan?.holidays_region?.includes('-') ? plan.holidays_region : '' - - useEffect(() => { - if (!selectedCountry || !plan?.holidays_enabled) { setRegions([]); return } - setLoadingRegions(true) - const year = new Date().getFullYear() - apiClient.get(`/addons/vacay/holidays/${year}/${selectedCountry}`).then(r => { - const allCounties = new Set() - r.data.forEach(h => { - if (h.counties) h.counties.forEach(c => allCounties.add(c)) - }) - if (allCounties.size > 0) { - let subdivisionNames - try { subdivisionNames = new Intl.DisplayNames([getIntlLanguage(language)], { type: 'region' }) } catch { /* */ } - const regionList = [...allCounties].sort().map(c => { - let label = c.split('-')[1] || c - // Try Intl for full subdivision name (not all browsers support subdivision codes) - // Fallback: use known mappings for DE - if (c.startsWith('DE-')) { - const deRegions = { BW:'Baden-Württemberg',BY:'Bayern',BE:'Berlin',BB:'Brandenburg',HB:'Bremen',HH:'Hamburg',HE:'Hessen',MV:'Mecklenburg-Vorpommern',NI:'Niedersachsen',NW:'Nordrhein-Westfalen',RP:'Rheinland-Pfalz',SL:'Saarland',SN:'Sachsen',ST:'Sachsen-Anhalt',SH:'Schleswig-Holstein',TH:'Thüringen' } - label = deRegions[c.split('-')[1]] || label - } else if (c.startsWith('CH-')) { - const chRegions = { AG:'Aargau',AI:'Appenzell Innerrhoden',AR:'Appenzell Ausserrhoden',BE:'Bern',BL:'Basel-Landschaft',BS:'Basel-Stadt',FR:'Freiburg',GE:'Genf',GL:'Glarus',GR:'Graubünden',JU:'Jura',LU:'Luzern',NE:'Neuenburg',NW:'Nidwalden',OW:'Obwalden',SG:'St. Gallen',SH:'Schaffhausen',SO:'Solothurn',SZ:'Schwyz',TG:'Thurgau',TI:'Tessin',UR:'Uri',VD:'Waadt',VS:'Wallis',ZG:'Zug',ZH:'Zürich' } - label = chRegions[c.split('-')[1]] || label - } - return { value: c, label } - }) - setRegions(regionList) - } else { - setRegions([]) - // If no regions, just set country code as region - if (plan.holidays_region !== selectedCountry) { - updatePlan({ holidays_region: selectedCountry }) - } - } - }).catch(() => setRegions([])).finally(() => setLoadingRegions(false)) - }, [selectedCountry, plan?.holidays_enabled]) - if (!plan) return null - const toggle = (key) => updatePlan({ [key]: !plan[key] }) - - const handleCountryChange = (countryCode) => { - updatePlan({ holidays_region: countryCode }) - } - - const handleRegionChange = (regionCode) => { - updatePlan({ holidays_region: regionCode }) - } + const toggle = (key: string) => updatePlan({ [key]: !plan[key] }) return (
@@ -136,21 +88,35 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) { /> {plan.holidays_enabled && (
- - {regions.length > 0 && ( - {t('vacay.noCalendars')}

+ )} + {(plan.holiday_calendars ?? []).map(cal => ( + updateHolidayCalendar(cal.id, data)} + onDelete={() => deleteHolidayCalendar(cal.id)} /> + ))} + {showAddForm ? ( + { await addHolidayCalendar(data); setShowAddForm(false) }} + onCancel={() => setShowAddForm(false)} + /> + ) : ( + )}
)} @@ -197,11 +163,11 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) { } interface SettingToggleProps { - icon: React.ComponentType<{ size?: number; className?: string; style?: React.CSSProperties }> + icon: LucideIcon label: string hint: string value: boolean - onChange: (value: boolean) => void + onChange: () => void } function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingToggleProps) { @@ -223,3 +189,184 @@ function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingTogg
) } + +// ── shared region-loading helper ───────────────────────────────────────────── +async function fetchRegionOptions(country: string): Promise<{ value: string; label: string }[]> { + try { + const year = new Date().getFullYear() + const r = await apiClient.get(`/addons/vacay/holidays/${year}/${country}`) + const allCounties = new Set() + r.data.forEach(h => { if (h.counties) h.counties.forEach(c => allCounties.add(c)) }) + if (allCounties.size === 0) return [] + return [...allCounties].sort().map(c => { + let label = c.split('-')[1] || c + if (c.startsWith('DE-')) { + const m: Record = { BW:'Baden-Württemberg',BY:'Bayern',BE:'Berlin',BB:'Brandenburg',HB:'Bremen',HH:'Hamburg',HE:'Hessen',MV:'Mecklenburg-Vorpommern',NI:'Niedersachsen',NW:'Nordrhein-Westfalen',RP:'Rheinland-Pfalz',SL:'Saarland',SN:'Sachsen',ST:'Sachsen-Anhalt',SH:'Schleswig-Holstein',TH:'Thüringen' } + label = m[c.split('-')[1]] || label + } else if (c.startsWith('CH-')) { + const m: Record = { AG:'Aargau',AI:'Appenzell Innerrhoden',AR:'Appenzell Ausserrhoden',BE:'Bern',BL:'Basel-Landschaft',BS:'Basel-Stadt',FR:'Freiburg',GE:'Genf',GL:'Glarus',GR:'Graubünden',JU:'Jura',LU:'Luzern',NE:'Neuenburg',NW:'Nidwalden',OW:'Obwalden',SG:'St. Gallen',SH:'Schaffhausen',SO:'Solothurn',SZ:'Schwyz',TG:'Thurgau',TI:'Tessin',UR:'Uri',VD:'Waadt',VS:'Wallis',ZG:'Zug',ZH:'Zürich' } + label = m[c.split('-')[1]] || label + } + return { value: c, label } + }) + } catch { + return [] + } +} + +// ── Existing calendar row (inline edit) ────────────────────────────────────── +function CalendarRow({ cal, countries, onUpdate, onDelete }: { + cal: VacayHolidayCalendar + countries: { value: string; label: string }[] + language: string + onUpdate: (data: { region?: string; color?: string; label?: string | null }) => void + onDelete: () => void +}) { + const { t } = useTranslation() + const [localColor, setLocalColor] = useState(cal.color) + const [localLabel, setLocalLabel] = useState(cal.label || '') + const [regions, setRegions] = useState<{ value: string; label: string }[]>([]) + + const selectedCountry = cal.region.split('-')[0] + const selectedRegion = cal.region.includes('-') ? cal.region : '' + + useEffect(() => { setLocalColor(cal.color) }, [cal.color]) + useEffect(() => { setLocalLabel(cal.label || '') }, [cal.label]) + + useEffect(() => { + if (!selectedCountry) { setRegions([]); return } + fetchRegionOptions(selectedCountry).then(setRegions) + }, [selectedCountry]) + + return ( +
+ setLocalColor(e.target.value)} + onBlur={() => { if (localColor !== cal.color) onUpdate({ color: localColor }) }} + className="w-7 h-7 shrink-0 rounded cursor-pointer p-0" + style={{ border: 'none', background: 'transparent' }} + title={t('vacay.calendarColor')} + /> +
+ setLocalLabel(e.target.value)} + onBlur={() => { const v = localLabel.trim() || null; if (v !== cal.label) onUpdate({ label: v }) }} + placeholder={t('vacay.calendarLabel')} + className="w-full text-xs px-2 py-1 rounded" + style={{ background: 'var(--bg-card)', border: '1px solid var(--border-primary)', color: 'var(--text-primary)' }} + /> + onUpdate({ region: v })} + options={countries} + placeholder={t('vacay.selectCountry')} + searchable + /> + {regions.length > 0 && ( + onUpdate({ region: v })} + options={regions} + placeholder={t('vacay.selectRegion')} + searchable + /> + )} +
+ +
+ ) +} + +// ── Add-new-calendar form ───────────────────────────────────────────────────── +function AddCalendarForm({ countries, onAdd, onCancel }: { + countries: { value: string; label: string }[] + language: string + onAdd: (data: { region: string; color: string; label: string | null }) => void + onCancel: () => void +}) { + const { t } = useTranslation() + const [region, setRegion] = useState('') + const [color, setColor] = useState('#fecaca') + const [label, setLabel] = useState('') + const [regions, setRegions] = useState<{ value: string; label: string }[]>([]) + const [loadingRegions, setLoadingRegions] = useState(false) + + const selectedCountry = region.split('-')[0] || '' + const selectedRegion = region.includes('-') ? region : '' + + useEffect(() => { + if (!selectedCountry) { setRegions([]); return } + setLoadingRegions(true) + fetchRegionOptions(selectedCountry).then(list => { setRegions(list) }).finally(() => setLoadingRegions(false)) + }, [selectedCountry]) + + const canAdd = selectedCountry && (regions.length === 0 || selectedRegion !== '') + + return ( +
+ setColor(e.target.value)} + className="w-7 h-7 shrink-0 rounded cursor-pointer p-0" + style={{ border: 'none', background: 'transparent' }} + title={t('vacay.calendarColor')} + /> +
+ setLabel(e.target.value)} + placeholder={t('vacay.calendarLabel')} + className="w-full text-xs px-2 py-1 rounded" + style={{ background: 'var(--bg-card)', border: '1px solid var(--border-primary)', color: 'var(--text-primary)' }} + /> + { setRegion(v); setRegions([]) }} + options={countries} + placeholder={t('vacay.selectCountry')} + searchable + /> + {regions.length > 0 && ( + setRegion(v)} + options={regions} + placeholder={t('vacay.selectRegion')} + searchable + /> + )} +
+ + +
+
+
+ ) +} diff --git a/client/src/i18n/TranslationContext.tsx b/client/src/i18n/TranslationContext.tsx index e0b059a..51e5c4f 100644 --- a/client/src/i18n/TranslationContext.tsx +++ b/client/src/i18n/TranslationContext.tsx @@ -8,7 +8,7 @@ import ru from './translations/ru' import zh from './translations/zh' import nl from './translations/nl' -type TranslationStrings = Record +type TranslationStrings = Record const translations: Record = { de, en, es, fr, ru, zh, nl } const LOCALES: Record = { de: 'de-DE', en: 'en-US', es: 'es-ES', fr: 'fr-FR', ru: 'ru-RU', zh: 'zh-CN', nl: 'nl-NL' } @@ -41,7 +41,7 @@ export function TranslationProvider({ children }: TranslationProviderProps) { const fallback = translations.en function t(key: string, params?: Record): string { - let val: string = strings[key] ?? fallback[key] ?? key + let val: string = (strings[key] ?? fallback[key] ?? key) as string if (params) { Object.entries(params).forEach(([k, v]) => { val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v)) diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 38d0203..b0268ef 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -1,4 +1,4 @@ -const de: Record = { +const de: Record = { // Allgemein 'common.save': 'Speichern', 'common.cancel': 'Abbrechen', @@ -411,6 +411,10 @@ const de: Record = { 'vacay.publicHolidaysHint': 'Feiertage im Kalender markieren', 'vacay.selectCountry': 'Land wählen', 'vacay.selectRegion': 'Region wählen (optional)', + 'vacay.addCalendar': 'Kalender hinzufügen', + 'vacay.calendarLabel': 'Bezeichnung (optional)', + 'vacay.calendarColor': 'Farbe', + 'vacay.noCalendars': 'Noch keine Feiertagskalender angelegt', 'vacay.companyHolidays': 'Betriebsferien', 'vacay.companyHolidaysHint': 'Erlaubt das Markieren von unternehmensweiten Feiertagen', 'vacay.companyHolidaysNoDeduct': 'Betriebsferien werden nicht vom Urlaubskontingent abgezogen.', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 1a58725..48fb3c4 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -1,4 +1,4 @@ -const en: Record = { +const en: Record = { // Common 'common.save': 'Save', 'common.cancel': 'Cancel', @@ -411,6 +411,10 @@ const en: Record = { 'vacay.publicHolidaysHint': 'Mark public holidays in the calendar', 'vacay.selectCountry': 'Select country', 'vacay.selectRegion': 'Select region (optional)', + 'vacay.addCalendar': 'Add calendar', + 'vacay.calendarLabel': 'Label (optional)', + 'vacay.calendarColor': 'Color', + 'vacay.noCalendars': 'No holiday calendars added yet', 'vacay.companyHolidays': 'Company Holidays', 'vacay.companyHolidaysHint': 'Allow marking company-wide holiday days', 'vacay.companyHolidaysNoDeduct': 'Company holidays do not count towards vacation days.', diff --git a/client/src/pages/VacayPage.tsx b/client/src/pages/VacayPage.tsx index ff2ac88..68efd34 100644 --- a/client/src/pages/VacayPage.tsx +++ b/client/src/pages/VacayPage.tsx @@ -104,7 +104,12 @@ export default function VacayPage(): React.ReactElement {
{t('vacay.legend')}
- {plan?.holidays_enabled && } + {plan?.holidays_enabled && (plan?.holiday_calendars ?? []).length === 0 && ( + + )} + {plan?.holidays_enabled && (plan?.holiday_calendars ?? []).map(cal => ( + + ))} {plan?.company_holidays_enabled && } {plan?.block_weekends && }
diff --git a/client/src/store/vacayStore.ts b/client/src/store/vacayStore.ts index c58e3a3..f1d7ef7 100644 --- a/client/src/store/vacayStore.ts +++ b/client/src/store/vacayStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand' import apiClient from '../api/client' import type { AxiosResponse } from 'axios' -import type { VacayPlan, VacayUser, VacayEntry, VacayStat, HolidaysMap, HolidayInfo } from '../types' +import type { VacayPlan, VacayUser, VacayEntry, VacayStat, HolidaysMap, HolidayInfo, VacayHolidayCalendar } from '../types' const ax = apiClient @@ -65,6 +65,9 @@ interface VacayApi { updateStats: (year: number, days: number, targetUserId?: number) => Promise getCountries: () => Promise<{ countries: string[] }> getHolidays: (year: number, country: string) => Promise + addHolidayCalendar: (data: { region: string; color?: string; label?: string | null }) => Promise<{ calendar: VacayHolidayCalendar }> + updateHolidayCalendar: (id: number, data: { region?: string; color?: string; label?: string | null }) => Promise<{ calendar: VacayHolidayCalendar }> + deleteHolidayCalendar: (id: number) => Promise } const api: VacayApi = { @@ -87,6 +90,9 @@ const api: VacayApi = { updateStats: (year, days, targetUserId) => ax.put(`/addons/vacay/stats/${year}`, { vacation_days: days, target_user_id: targetUserId }).then((r: AxiosResponse) => r.data), getCountries: () => ax.get('/addons/vacay/holidays/countries').then((r: AxiosResponse) => r.data), getHolidays: (year, country) => ax.get(`/addons/vacay/holidays/${year}/${country}`).then((r: AxiosResponse) => r.data), + addHolidayCalendar: (data) => ax.post('/addons/vacay/plan/holiday-calendars', data).then((r: AxiosResponse) => r.data), + updateHolidayCalendar: (id, data) => ax.put(`/addons/vacay/plan/holiday-calendars/${id}`, data).then((r: AxiosResponse) => r.data), + deleteHolidayCalendar: (id) => ax.delete(`/addons/vacay/plan/holiday-calendars/${id}`).then((r: AxiosResponse) => r.data), } interface VacayState { @@ -124,6 +130,9 @@ interface VacayState { loadStats: (year?: number) => Promise updateVacationDays: (year: number, days: number, targetUserId?: number) => Promise loadHolidays: (year?: number) => Promise + addHolidayCalendar: (data: { region: string; color?: string; label?: string | null }) => Promise + updateHolidayCalendar: (id: number, data: { region?: string; color?: string; label?: string | null }) => Promise + deleteHolidayCalendar: (id: number) => Promise loadAll: () => Promise } @@ -247,29 +256,47 @@ export const useVacayStore = create((set, get) => ({ loadHolidays: async (year?: number) => { const y = year || get().selectedYear const plan = get().plan - if (!plan?.holidays_enabled || !plan?.holidays_region) { + const calendars = plan?.holiday_calendars ?? [] + if (!plan?.holidays_enabled || calendars.length === 0) { set({ holidays: {} }) return } - const country = plan.holidays_region.split('-')[0] - const region = plan.holidays_region.includes('-') ? plan.holidays_region : null - try { - const data = await api.getHolidays(y, country) - const hasRegions = data.some((h: VacayHolidayRaw) => h.counties && h.counties.length > 0) - if (hasRegions && !region) { - set({ holidays: {} }) - return - } - const map: HolidaysMap = {} - data.forEach((h: VacayHolidayRaw) => { - if (h.global || !h.counties || (region && h.counties.includes(region))) { - map[h.date] = { name: h.name, localName: h.localName } - } - }) - set({ holidays: map }) - } catch { - set({ holidays: {} }) + const map: HolidaysMap = {} + for (const cal of calendars) { + const country = cal.region.split('-')[0] + const region = cal.region.includes('-') ? cal.region : null + try { + const data = await api.getHolidays(y, country) + const hasRegions = data.some((h: VacayHolidayRaw) => h.counties && h.counties.length > 0) + if (hasRegions && !region) continue + data.forEach((h: VacayHolidayRaw) => { + if (h.global || !h.counties || (region && h.counties.includes(region))) { + if (!map[h.date]) { + map[h.date] = { name: h.name, localName: h.localName, color: cal.color, label: cal.label } + } + } + }) + } catch { /* API error, skip */ } } + set({ holidays: map }) + }, + + addHolidayCalendar: async (data) => { + await api.addHolidayCalendar(data) + await get().loadPlan() + await get().loadHolidays() + }, + + updateHolidayCalendar: async (id, data) => { + await api.updateHolidayCalendar(id, data) + await get().loadPlan() + await get().loadHolidays() + }, + + deleteHolidayCalendar: async (id) => { + await api.deleteHolidayCalendar(id) + await get().loadPlan() + await get().loadHolidays() }, loadAll: async () => { diff --git a/client/src/types.ts b/client/src/types.ts index 761ae47..b0c7a32 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -281,10 +281,23 @@ export interface WebSocketEvent { } // Vacay types +export interface VacayHolidayCalendar { + id: number + plan_id: number + region: string + label: string | null + color: string + sort_order: number +} + export interface VacayPlan { id: number holidays_enabled: boolean holidays_region: string | null + holiday_calendars: VacayHolidayCalendar[] + block_weekends: boolean + carry_over_enabled: boolean + company_holidays_enabled: boolean name?: string year?: number owner_id?: number @@ -301,6 +314,9 @@ export interface VacayUser { export interface VacayEntry { date: string user_id: number + plan_id?: number + person_color?: string + person_name?: string } export interface VacayStat { @@ -312,6 +328,8 @@ export interface VacayStat { export interface HolidayInfo { name: string localName: string + color: string + label: string | null } export interface HolidaysMap { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 40f3d44..9670ecb 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -281,6 +281,15 @@ function createTables(db: Database.Database): void { UNIQUE(plan_id, date) ); + CREATE TABLE IF NOT EXISTS vacay_holiday_calendars ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, + region TEXT NOT NULL, + label TEXT, + color TEXT NOT NULL DEFAULT '#fecaca', + sort_order INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS day_accommodations ( id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, diff --git a/server/src/routes/vacay.ts b/server/src/routes/vacay.ts index 02b13d9..ba0cbe3 100644 --- a/server/src/routes/vacay.ts +++ b/server/src/routes/vacay.ts @@ -43,9 +43,59 @@ interface Holiday { counties?: string[] | null; } +interface VacayHolidayCalendar { + id: number; + plan_id: number; + region: string; + label: string | null; + color: string; + sort_order: number; +} + const holidayCache = new Map(); const CACHE_TTL = 24 * 60 * 60 * 1000; +async function applyHolidayCalendars(planId: number): Promise { + const plan = db.prepare('SELECT holidays_enabled FROM vacay_plans WHERE id = ?').get(planId) as { holidays_enabled: number } | undefined; + if (!plan?.holidays_enabled) return; + const calendars = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id').all(planId) as VacayHolidayCalendar[]; + if (calendars.length === 0) return; + const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ?').all(planId) as { year: number }[]; + for (const cal of calendars) { + const country = cal.region.split('-')[0]; + const region = cal.region.includes('-') ? cal.region : null; + for (const { year } of years) { + try { + const cacheKey = `${year}-${country}`; + let holidays = holidayCache.get(cacheKey)?.data as Holiday[] | undefined; + if (!holidays) { + const resp = await fetch(`https://date.nager.at/api/v3/PublicHolidays/${year}/${country}`); + holidays = await resp.json() as Holiday[]; + holidayCache.set(cacheKey, { data: holidays, time: Date.now() }); + } + const hasRegions = holidays.some((h: Holiday) => h.counties && h.counties.length > 0); + if (hasRegions && !region) continue; + for (const h of holidays) { + if (h.global || !h.counties || (region && h.counties.includes(region))) { + db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, h.date); + db.prepare('DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date = ?').run(planId, h.date); + } + } + } catch { /* API error, skip */ } + } + } +} + +async function migrateHolidayCalendars(planId: number, plan: VacayPlan): Promise { + const existing = db.prepare('SELECT id FROM vacay_holiday_calendars WHERE plan_id = ?').get(planId); + if (existing) return; + if (plan.holidays_enabled && plan.holidays_region) { + db.prepare( + 'INSERT INTO vacay_holiday_calendars (plan_id, region, label, color, sort_order) VALUES (?, ?, NULL, ?, 0)' + ).run(planId, plan.holidays_region, '#fecaca'); + } +} + const router = express.Router(); router.use(authenticate); @@ -124,6 +174,8 @@ router.get('/plan', (req: Request, res: Response) => { WHERE m.user_id = ? AND m.status = 'pending' `).all(authReq.user.id); + const holidayCalendars = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id').all(activePlanId) as VacayHolidayCalendar[]; + res.json({ plan: { ...plan, @@ -131,6 +183,7 @@ router.get('/plan', (req: Request, res: Response) => { holidays_enabled: !!plan.holidays_enabled, company_holidays_enabled: !!plan.company_holidays_enabled, carry_over_enabled: !!plan.carry_over_enabled, + holiday_calendars: holidayCalendars, }, users, pendingInvites, @@ -166,30 +219,8 @@ router.put('/plan', async (req: Request, res: Response) => { } const updatedPlan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan; - if (updatedPlan.holidays_enabled && updatedPlan.holidays_region) { - const country = updatedPlan.holidays_region.split('-')[0]; - const region = updatedPlan.holidays_region.includes('-') ? updatedPlan.holidays_region : null; - const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ?').all(planId) as { year: number }[]; - for (const { year } of years) { - try { - const cacheKey = `${year}-${country}`; - let holidays = holidayCache.get(cacheKey)?.data as Holiday[] | undefined; - if (!holidays) { - const resp = await fetch(`https://date.nager.at/api/v3/PublicHolidays/${year}/${country}`); - holidays = await resp.json() as Holiday[]; - holidayCache.set(cacheKey, { data: holidays, time: Date.now() }); - } - const hasRegions = (holidays as Holiday[]).some((h: Holiday) => h.counties && h.counties.length > 0); - if (hasRegions && !region) continue; - for (const h of holidays) { - if (h.global || !h.counties || (region && h.counties.includes(region))) { - db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, h.date); - db.prepare('DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date = ?').run(planId, h.date); - } - } - } catch { /* API error, skip */ } - } - } + await migrateHolidayCalendars(planId, updatedPlan); + await applyHolidayCalendars(planId); if (carry_over_enabled === false) { db.prepare('UPDATE vacay_user_years SET carried_over = 0 WHERE plan_id = ?').run(planId); @@ -217,11 +248,58 @@ router.put('/plan', async (req: Request, res: Response) => { notifyPlanUsers(planId, req.headers['x-socket-id'] as string, 'vacay:settings'); const updated = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan; + const updatedCalendars = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id').all(planId) as VacayHolidayCalendar[]; res.json({ - plan: { ...updated, block_weekends: !!updated.block_weekends, holidays_enabled: !!updated.holidays_enabled, company_holidays_enabled: !!updated.company_holidays_enabled, carry_over_enabled: !!updated.carry_over_enabled } + plan: { ...updated, block_weekends: !!updated.block_weekends, holidays_enabled: !!updated.holidays_enabled, company_holidays_enabled: !!updated.company_holidays_enabled, carry_over_enabled: !!updated.carry_over_enabled, holiday_calendars: updatedCalendars } }); }); +router.post('/plan/holiday-calendars', (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { region, label, color, sort_order } = req.body; + if (!region) return res.status(400).json({ error: 'region required' }); + const planId = getActivePlanId(authReq.user.id); + const result = db.prepare( + 'INSERT INTO vacay_holiday_calendars (plan_id, region, label, color, sort_order) VALUES (?, ?, ?, ?, ?)' + ).run(planId, region, label || null, color || '#fecaca', sort_order ?? 0); + const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ?').get(result.lastInsertRowid) as VacayHolidayCalendar; + notifyPlanUsers(planId, req.headers['x-socket-id'] as string, 'vacay:settings'); + res.json({ calendar: cal }); +}); + +router.put('/plan/holiday-calendars/:id', (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const id = parseInt(req.params.id); + const planId = getActivePlanId(authReq.user.id); + const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?').get(id, planId) as VacayHolidayCalendar | undefined; + if (!cal) return res.status(404).json({ error: 'Calendar not found' }); + const { region, label, color, sort_order } = req.body; + const updates: string[] = []; + const params: (string | number | null)[] = []; + if (region !== undefined) { updates.push('region = ?'); params.push(region); } + if (label !== undefined) { updates.push('label = ?'); params.push(label); } + if (color !== undefined) { updates.push('color = ?'); params.push(color); } + if (sort_order !== undefined) { updates.push('sort_order = ?'); params.push(sort_order); } + if (updates.length > 0) { + params.push(id); + db.prepare(`UPDATE vacay_holiday_calendars SET ${updates.join(', ')} WHERE id = ?`).run(...params); + } + const updated = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ?').get(id) as VacayHolidayCalendar; + notifyPlanUsers(planId, req.headers['x-socket-id'] as string, 'vacay:settings'); + res.json({ calendar: updated }); +}); + +router.delete('/plan/holiday-calendars/:id', (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const id = parseInt(req.params.id); + const planId = getActivePlanId(authReq.user.id); + const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?').get(id, planId); + if (!cal) return res.status(404).json({ error: 'Calendar not found' }); + db.prepare('DELETE FROM vacay_holiday_calendars WHERE id = ?').run(id); + notifyPlanUsers(planId, req.headers['x-socket-id'] as string, 'vacay:settings'); + res.json({ success: true }); +}); + router.put('/color', (req: Request, res: Response) => { const authReq = req as AuthRequest; const { color, target_user_id } = req.body;