Merge PR #68: multiple holiday calendars per vacay plan (closes #36)

This commit is contained in:
Maurice
2026-03-29 01:33:06 +01:00
10 changed files with 421 additions and 122 deletions

View File

@@ -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 && <div className="absolute inset-0.5 rounded" style={{ background: 'rgba(239,68,68,0.12)' }} />}
{holiday && <div className="absolute inset-0.5 rounded" style={{ background: hexToRgba(holiday.color, 0.12) }} />}
{isCompany && <div className="absolute inset-0.5 rounded" style={{ background: 'rgba(245,158,11,0.15)' }} />}
{dayEntries.length === 1 && (
@@ -117,7 +124,7 @@ export default function VacayMonthCard({
)}
<span className="relative z-[1] text-[11px] font-medium" style={{
color: holiday ? '#dc2626' : weekend ? 'var(--text-faint)' : 'var(--text-primary)',
color: holiday ? holiday.color : weekend ? 'var(--text-faint)' : 'var(--text-primary)',
fontWeight: dayEntries.length > 0 ? 700 : 500,
}}>
{day}

View File

@@ -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 (
<div className="space-y-5">
@@ -136,21 +88,35 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
/>
{plan.holidays_enabled && (
<div className="ml-7 mt-2 space-y-2">
<CustomSelect
value={selectedCountry}
onChange={handleCountryChange}
options={countries}
placeholder={t('vacay.selectCountry')}
searchable
/>
{regions.length > 0 && (
<CustomSelect
value={selectedRegion}
onChange={handleRegionChange}
options={regions}
placeholder={t('vacay.selectRegion')}
searchable
{(plan.holiday_calendars ?? []).length === 0 && (
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('vacay.noCalendars')}</p>
)}
{(plan.holiday_calendars ?? []).map(cal => (
<CalendarRow
key={cal.id}
cal={cal}
countries={countries}
language={language}
onUpdate={(data) => updateHolidayCalendar(cal.id, data)}
onDelete={() => deleteHolidayCalendar(cal.id)}
/>
))}
{showAddForm ? (
<AddCalendarForm
countries={countries}
language={language}
onAdd={async (data) => { await addHolidayCalendar(data); setShowAddForm(false) }}
onCancel={() => setShowAddForm(false)}
/>
) : (
<button
onClick={() => setShowAddForm(true)}
className="flex items-center gap-1.5 text-xs px-2 py-1.5 rounded-md transition-colors"
style={{ color: 'var(--text-muted)', background: 'var(--bg-secondary)' }}
>
<Plus size={12} />
{t('vacay.addCalendar')}
</button>
)}
</div>
)}
@@ -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
</div>
)
}
// ── 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<string>()
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<string, string> = { 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<string, string> = { 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 (
<div className="flex gap-2 items-start p-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
<input
type="color"
value={localColor}
onChange={e => 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')}
/>
<div className="flex-1 min-w-0 space-y-1.5">
<input
type="text"
value={localLabel}
onChange={e => 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)' }}
/>
<CustomSelect
value={selectedCountry}
onChange={v => onUpdate({ region: v })}
options={countries}
placeholder={t('vacay.selectCountry')}
searchable
/>
{regions.length > 0 && (
<CustomSelect
value={selectedRegion}
onChange={v => onUpdate({ region: v })}
options={regions}
placeholder={t('vacay.selectRegion')}
searchable
/>
)}
</div>
<button
onClick={onDelete}
className="shrink-0 p-1.5 rounded-md transition-colors"
style={{ color: 'var(--text-faint)' }}
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = 'rgba(239,68,68,0.1)' }}
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
<Trash2 size={13} />
</button>
</div>
)
}
// ── 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 (
<div className="flex gap-2 items-start p-2 rounded-lg border border-dashed" style={{ borderColor: 'var(--border-primary)' }}>
<input
type="color"
value={color}
onChange={e => 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')}
/>
<div className="flex-1 min-w-0 space-y-1.5">
<input
type="text"
value={label}
onChange={e => 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)' }}
/>
<CustomSelect
value={selectedCountry}
onChange={v => { setRegion(v); setRegions([]) }}
options={countries}
placeholder={t('vacay.selectCountry')}
searchable
/>
{regions.length > 0 && (
<CustomSelect
value={selectedRegion}
onChange={v => setRegion(v)}
options={regions}
placeholder={t('vacay.selectRegion')}
searchable
/>
)}
<div className="flex gap-1.5 pt-0.5">
<button
disabled={!canAdd}
onClick={() => onAdd({ region: region || selectedCountry, color, label: label.trim() || null })}
className="flex-1 text-xs px-2 py-1.5 rounded-md font-medium transition-colors disabled:opacity-40"
style={{ background: 'var(--text-primary)', color: 'var(--bg-card)' }}
>
{t('vacay.add')}
</button>
<button
onClick={onCancel}
className="text-xs px-2 py-1.5 rounded-md transition-colors"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
>
</button>
</div>
</div>
</div>
)
}

View File

@@ -8,7 +8,7 @@ import ru from './translations/ru'
import zh from './translations/zh'
import nl from './translations/nl'
type TranslationStrings = Record<string, string>
type TranslationStrings = Record<string, string | { name: string; category: string }[]>
const translations: Record<string, TranslationStrings> = { de, en, es, fr, ru, zh, nl }
const LOCALES: Record<string, string> = { 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, string | number>): 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))

View File

@@ -1,4 +1,4 @@
const de: Record<string, string> = {
const de: Record<string, string | { name: string; category: string }[]> = {
// Allgemein
'common.save': 'Speichern',
'common.cancel': 'Abbrechen',
@@ -411,6 +411,10 @@ const de: Record<string, string> = {
'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.',

View File

@@ -1,4 +1,4 @@
const en: Record<string, string> = {
const en: Record<string, string | { name: string; category: string }[]> = {
// Common
'common.save': 'Save',
'common.cancel': 'Cancel',
@@ -411,6 +411,10 @@ const en: Record<string, string> = {
'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.',

View File

@@ -104,7 +104,12 @@ export default function VacayPage(): React.ReactElement {
<div className="rounded-xl border p-3" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<span className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}>{t('vacay.legend')}</span>
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1.5">
{plan?.holidays_enabled && <LegendItem color="#fecaca" label={t('vacay.publicHoliday')} />}
{plan?.holidays_enabled && (plan?.holiday_calendars ?? []).length === 0 && (
<LegendItem color="#fecaca" label={t('vacay.publicHoliday')} />
)}
{plan?.holidays_enabled && (plan?.holiday_calendars ?? []).map(cal => (
<LegendItem key={cal.id} color={cal.color} label={cal.label || cal.region} />
))}
{plan?.company_holidays_enabled && <LegendItem color="#fde68a" label={t('vacay.companyHoliday')} />}
{plan?.block_weekends && <LegendItem color="#e5e7eb" label={t('vacay.weekend')} />}
</div>

View File

@@ -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<unknown>
getCountries: () => Promise<{ countries: string[] }>
getHolidays: (year: number, country: string) => Promise<VacayHolidayRaw[]>
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<unknown>
}
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<void>
updateVacationDays: (year: number, days: number, targetUserId?: number) => Promise<void>
loadHolidays: (year?: number) => Promise<void>
addHolidayCalendar: (data: { region: string; color?: string; label?: string | null }) => Promise<void>
updateHolidayCalendar: (id: number, data: { region?: string; color?: string; label?: string | null }) => Promise<void>
deleteHolidayCalendar: (id: number) => Promise<void>
loadAll: () => Promise<void>
}
@@ -247,29 +256,47 @@ export const useVacayStore = create<VacayState>((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 () => {

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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<string, { data: unknown; time: number }>();
const CACHE_TTL = 24 * 60 * 60 * 1000;
async function applyHolidayCalendars(planId: number): Promise<void> {
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<void> {
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;