feat: multiple holiday calendars per vacay plan
- Add vacay_holiday_calendars table (region, label, color, sort_order) - Lazy migration of existing holidays_region to first calendar row - Extract applyHolidayCalendars() helper; replace inline holiday logic - GET /vacay/plan now includes holiday_calendars array - Add POST/PUT/DELETE /vacay/plan/holiday-calendars/:id endpoints - Client VacayPlan/VacayEntry/HolidayInfo types updated - loadHolidays() loops over all calendars; per-calendar color on HolidayInfo - VacayMonthCard uses holiday.color instead of hardcoded red - VacaySettings replaced single country picker with calendar list UI - VacayPage legend renders one item per calendar - i18n: addCalendar, calendarLabel, calendarColor, noCalendars (en + de) - Fix pre-existing TS errors: VacayPlan/VacayEntry missing fields, SettingToggleProps icon/onChange types, packing.suggestions.items array type Closes #36
This commit is contained in:
@@ -8,6 +8,13 @@ const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
|||||||
const MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
|
const MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
|
||||||
const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
|
const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
|
||||||
|
|
||||||
|
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 {
|
interface VacayMonthCardProps {
|
||||||
year: number
|
year: number
|
||||||
month: number
|
month: number
|
||||||
@@ -86,7 +93,7 @@ export default function VacayMonthCard({
|
|||||||
onMouseEnter={e => { if (!isBlocked) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
onMouseEnter={e => { if (!isBlocked) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||||
onMouseLeave={e => { e.currentTarget.style.background = weekend ? 'var(--bg-secondary)' : 'transparent' }}
|
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)' }} />}
|
{isCompany && <div className="absolute inset-0.5 rounded" style={{ background: 'rgba(245,158,11,0.15)' }} />}
|
||||||
|
|
||||||
{dayEntries.length === 1 && (
|
{dayEntries.length === 1 && (
|
||||||
@@ -115,7 +122,7 @@ export default function VacayMonthCard({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<span className="relative z-[1] text-[11px] font-medium" style={{
|
<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,
|
fontWeight: dayEntries.length > 0 ? 700 : 500,
|
||||||
}}>
|
}}>
|
||||||
{day}
|
{day}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useState, useEffect } from 'react'
|
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 { useVacayStore } from '../../store/vacayStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import apiClient from '../../api/client'
|
import apiClient from '../../api/client'
|
||||||
|
import type { VacayHolidayCalendar } from '../../types'
|
||||||
|
|
||||||
interface VacaySettingsProps {
|
interface VacaySettingsProps {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -13,10 +14,9 @@ interface VacaySettingsProps {
|
|||||||
export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { plan, updatePlan, isFused, dissolve, users } = useVacayStore()
|
const { plan, updatePlan, addHolidayCalendar, updateHolidayCalendar, deleteHolidayCalendar, isFused, dissolve, users } = useVacayStore()
|
||||||
const [countries, setCountries] = useState([])
|
const [countries, setCountries] = useState<{ value: string; label: string }[]>([])
|
||||||
const [regions, setRegions] = useState([])
|
const [showAddForm, setShowAddForm] = useState(false)
|
||||||
const [loadingRegions, setLoadingRegions] = useState(false)
|
|
||||||
|
|
||||||
const { language } = useTranslation()
|
const { language } = useTranslation()
|
||||||
|
|
||||||
@@ -34,57 +34,9 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
|||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}, [language])
|
}, [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([language === 'de' ? 'de' : 'en'], { 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
|
if (!plan) return null
|
||||||
|
|
||||||
const toggle = (key) => updatePlan({ [key]: !plan[key] })
|
const toggle = (key: string) => updatePlan({ [key]: !plan[key] })
|
||||||
|
|
||||||
const handleCountryChange = (countryCode) => {
|
|
||||||
updatePlan({ holidays_region: countryCode })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRegionChange = (regionCode) => {
|
|
||||||
updatePlan({ holidays_region: regionCode })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
@@ -136,21 +88,35 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
|||||||
/>
|
/>
|
||||||
{plan.holidays_enabled && (
|
{plan.holidays_enabled && (
|
||||||
<div className="ml-7 mt-2 space-y-2">
|
<div className="ml-7 mt-2 space-y-2">
|
||||||
<CustomSelect
|
{(plan.holiday_calendars ?? []).length === 0 && (
|
||||||
value={selectedCountry}
|
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('vacay.noCalendars')}</p>
|
||||||
onChange={handleCountryChange}
|
)}
|
||||||
options={countries}
|
{(plan.holiday_calendars ?? []).map(cal => (
|
||||||
placeholder={t('vacay.selectCountry')}
|
<CalendarRow
|
||||||
searchable
|
key={cal.id}
|
||||||
/>
|
cal={cal}
|
||||||
{regions.length > 0 && (
|
countries={countries}
|
||||||
<CustomSelect
|
language={language}
|
||||||
value={selectedRegion}
|
onUpdate={(data) => updateHolidayCalendar(cal.id, data)}
|
||||||
onChange={handleRegionChange}
|
onDelete={() => deleteHolidayCalendar(cal.id)}
|
||||||
options={regions}
|
|
||||||
placeholder={t('vacay.selectRegion')}
|
|
||||||
searchable
|
|
||||||
/>
|
/>
|
||||||
|
))}
|
||||||
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -197,11 +163,11 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SettingToggleProps {
|
interface SettingToggleProps {
|
||||||
icon: React.ComponentType<{ size?: number; className?: string; style?: React.CSSProperties }>
|
icon: LucideIcon
|
||||||
label: string
|
label: string
|
||||||
hint: string
|
hint: string
|
||||||
value: boolean
|
value: boolean
|
||||||
onChange: (value: boolean) => void
|
onChange: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingToggleProps) {
|
function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingToggleProps) {
|
||||||
@@ -223,3 +189,184 @@ function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingTogg
|
|||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useSettingsStore } from '../store/settingsStore'
|
|||||||
import de from './translations/de'
|
import de from './translations/de'
|
||||||
import en from './translations/en'
|
import en from './translations/en'
|
||||||
|
|
||||||
type TranslationStrings = Record<string, string>
|
type TranslationStrings = Record<string, string | { name: string; category: string }[]>
|
||||||
|
|
||||||
const translations: Record<string, TranslationStrings> = { de, en }
|
const translations: Record<string, TranslationStrings> = { de, en }
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ export function TranslationProvider({ children }: TranslationProviderProps) {
|
|||||||
const fallback = translations.de
|
const fallback = translations.de
|
||||||
|
|
||||||
function t(key: string, params?: Record<string, string | number>): string {
|
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) {
|
if (params) {
|
||||||
Object.entries(params).forEach(([k, v]) => {
|
Object.entries(params).forEach(([k, v]) => {
|
||||||
val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v))
|
val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v))
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const de: Record<string, string> = {
|
const de: Record<string, string | { name: string; category: string }[]> = {
|
||||||
// Allgemein
|
// Allgemein
|
||||||
'common.save': 'Speichern',
|
'common.save': 'Speichern',
|
||||||
'common.cancel': 'Abbrechen',
|
'common.cancel': 'Abbrechen',
|
||||||
@@ -390,6 +390,10 @@ const de: Record<string, string> = {
|
|||||||
'vacay.publicHolidaysHint': 'Feiertage im Kalender markieren',
|
'vacay.publicHolidaysHint': 'Feiertage im Kalender markieren',
|
||||||
'vacay.selectCountry': 'Land wählen',
|
'vacay.selectCountry': 'Land wählen',
|
||||||
'vacay.selectRegion': 'Region wählen (optional)',
|
'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.companyHolidays': 'Betriebsferien',
|
||||||
'vacay.companyHolidaysHint': 'Erlaubt das Markieren von unternehmensweiten Feiertagen',
|
'vacay.companyHolidaysHint': 'Erlaubt das Markieren von unternehmensweiten Feiertagen',
|
||||||
'vacay.companyHolidaysNoDeduct': 'Betriebsferien werden nicht vom Urlaubskontingent abgezogen.',
|
'vacay.companyHolidaysNoDeduct': 'Betriebsferien werden nicht vom Urlaubskontingent abgezogen.',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const en: Record<string, string> = {
|
const en: Record<string, string | { name: string; category: string }[]> = {
|
||||||
// Common
|
// Common
|
||||||
'common.save': 'Save',
|
'common.save': 'Save',
|
||||||
'common.cancel': 'Cancel',
|
'common.cancel': 'Cancel',
|
||||||
@@ -390,6 +390,10 @@ const en: Record<string, string> = {
|
|||||||
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
|
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
|
||||||
'vacay.selectCountry': 'Select country',
|
'vacay.selectCountry': 'Select country',
|
||||||
'vacay.selectRegion': 'Select region (optional)',
|
'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.companyHolidays': 'Company Holidays',
|
||||||
'vacay.companyHolidaysHint': 'Allow marking company-wide holiday days',
|
'vacay.companyHolidaysHint': 'Allow marking company-wide holiday days',
|
||||||
'vacay.companyHolidaysNoDeduct': 'Company holidays do not count towards vacation days.',
|
'vacay.companyHolidaysNoDeduct': 'Company holidays do not count towards vacation days.',
|
||||||
|
|||||||
@@ -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)' }}>
|
<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>
|
<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">
|
<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?.company_holidays_enabled && <LegendItem color="#fde68a" label={t('vacay.companyHoliday')} />}
|
||||||
{plan?.block_weekends && <LegendItem color="#e5e7eb" label={t('vacay.weekend')} />}
|
{plan?.block_weekends && <LegendItem color="#e5e7eb" label={t('vacay.weekend')} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import apiClient from '../api/client'
|
import apiClient from '../api/client'
|
||||||
import type { AxiosResponse } from 'axios'
|
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
|
const ax = apiClient
|
||||||
|
|
||||||
@@ -65,6 +65,9 @@ interface VacayApi {
|
|||||||
updateStats: (year: number, days: number, targetUserId?: number) => Promise<unknown>
|
updateStats: (year: number, days: number, targetUserId?: number) => Promise<unknown>
|
||||||
getCountries: () => Promise<{ countries: string[] }>
|
getCountries: () => Promise<{ countries: string[] }>
|
||||||
getHolidays: (year: number, country: string) => Promise<VacayHolidayRaw[]>
|
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 = {
|
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),
|
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),
|
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),
|
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 {
|
interface VacayState {
|
||||||
@@ -124,6 +130,9 @@ interface VacayState {
|
|||||||
loadStats: (year?: number) => Promise<void>
|
loadStats: (year?: number) => Promise<void>
|
||||||
updateVacationDays: (year: number, days: number, targetUserId?: number) => Promise<void>
|
updateVacationDays: (year: number, days: number, targetUserId?: number) => Promise<void>
|
||||||
loadHolidays: (year?: 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>
|
loadAll: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,29 +256,47 @@ export const useVacayStore = create<VacayState>((set, get) => ({
|
|||||||
loadHolidays: async (year?: number) => {
|
loadHolidays: async (year?: number) => {
|
||||||
const y = year || get().selectedYear
|
const y = year || get().selectedYear
|
||||||
const plan = get().plan
|
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: {} })
|
set({ holidays: {} })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const country = plan.holidays_region.split('-')[0]
|
const map: HolidaysMap = {}
|
||||||
const region = plan.holidays_region.includes('-') ? plan.holidays_region : null
|
for (const cal of calendars) {
|
||||||
try {
|
const country = cal.region.split('-')[0]
|
||||||
const data = await api.getHolidays(y, country)
|
const region = cal.region.includes('-') ? cal.region : null
|
||||||
const hasRegions = data.some((h: VacayHolidayRaw) => h.counties && h.counties.length > 0)
|
try {
|
||||||
if (hasRegions && !region) {
|
const data = await api.getHolidays(y, country)
|
||||||
set({ holidays: {} })
|
const hasRegions = data.some((h: VacayHolidayRaw) => h.counties && h.counties.length > 0)
|
||||||
return
|
if (hasRegions && !region) continue
|
||||||
}
|
data.forEach((h: VacayHolidayRaw) => {
|
||||||
const map: HolidaysMap = {}
|
if (h.global || !h.counties || (region && h.counties.includes(region))) {
|
||||||
data.forEach((h: VacayHolidayRaw) => {
|
if (!map[h.date]) {
|
||||||
if (h.global || !h.counties || (region && h.counties.includes(region))) {
|
map[h.date] = { name: h.name, localName: h.localName, color: cal.color, label: cal.label }
|
||||||
map[h.date] = { name: h.name, localName: h.localName }
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
set({ holidays: map })
|
} catch { /* API error, skip */ }
|
||||||
} catch {
|
|
||||||
set({ holidays: {} })
|
|
||||||
}
|
}
|
||||||
|
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 () => {
|
loadAll: async () => {
|
||||||
|
|||||||
@@ -281,10 +281,23 @@ export interface WebSocketEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Vacay types
|
// Vacay types
|
||||||
|
export interface VacayHolidayCalendar {
|
||||||
|
id: number
|
||||||
|
plan_id: number
|
||||||
|
region: string
|
||||||
|
label: string | null
|
||||||
|
color: string
|
||||||
|
sort_order: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface VacayPlan {
|
export interface VacayPlan {
|
||||||
id: number
|
id: number
|
||||||
holidays_enabled: boolean
|
holidays_enabled: boolean
|
||||||
holidays_region: string | null
|
holidays_region: string | null
|
||||||
|
holiday_calendars: VacayHolidayCalendar[]
|
||||||
|
block_weekends: boolean
|
||||||
|
carry_over_enabled: boolean
|
||||||
|
company_holidays_enabled: boolean
|
||||||
name?: string
|
name?: string
|
||||||
year?: number
|
year?: number
|
||||||
owner_id?: number
|
owner_id?: number
|
||||||
@@ -301,6 +314,9 @@ export interface VacayUser {
|
|||||||
export interface VacayEntry {
|
export interface VacayEntry {
|
||||||
date: string
|
date: string
|
||||||
user_id: number
|
user_id: number
|
||||||
|
plan_id?: number
|
||||||
|
person_color?: string
|
||||||
|
person_name?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VacayStat {
|
export interface VacayStat {
|
||||||
@@ -312,6 +328,8 @@ export interface VacayStat {
|
|||||||
export interface HolidayInfo {
|
export interface HolidayInfo {
|
||||||
name: string
|
name: string
|
||||||
localName: string
|
localName: string
|
||||||
|
color: string
|
||||||
|
label: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HolidaysMap {
|
export interface HolidaysMap {
|
||||||
|
|||||||
@@ -281,6 +281,15 @@ function createTables(db: Database.Database): void {
|
|||||||
UNIQUE(plan_id, date)
|
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 (
|
CREATE TABLE IF NOT EXISTS day_accommodations (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||||
|
|||||||
@@ -43,9 +43,59 @@ interface Holiday {
|
|||||||
counties?: string[] | null;
|
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 holidayCache = new Map<string, { data: unknown; time: number }>();
|
||||||
const CACHE_TTL = 24 * 60 * 60 * 1000;
|
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();
|
const router = express.Router();
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
|
|
||||||
@@ -124,6 +174,8 @@ router.get('/plan', (req: Request, res: Response) => {
|
|||||||
WHERE m.user_id = ? AND m.status = 'pending'
|
WHERE m.user_id = ? AND m.status = 'pending'
|
||||||
`).all(authReq.user.id);
|
`).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({
|
res.json({
|
||||||
plan: {
|
plan: {
|
||||||
...plan,
|
...plan,
|
||||||
@@ -131,6 +183,7 @@ router.get('/plan', (req: Request, res: Response) => {
|
|||||||
holidays_enabled: !!plan.holidays_enabled,
|
holidays_enabled: !!plan.holidays_enabled,
|
||||||
company_holidays_enabled: !!plan.company_holidays_enabled,
|
company_holidays_enabled: !!plan.company_holidays_enabled,
|
||||||
carry_over_enabled: !!plan.carry_over_enabled,
|
carry_over_enabled: !!plan.carry_over_enabled,
|
||||||
|
holiday_calendars: holidayCalendars,
|
||||||
},
|
},
|
||||||
users,
|
users,
|
||||||
pendingInvites,
|
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;
|
const updatedPlan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan;
|
||||||
if (updatedPlan.holidays_enabled && updatedPlan.holidays_region) {
|
await migrateHolidayCalendars(planId, updatedPlan);
|
||||||
const country = updatedPlan.holidays_region.split('-')[0];
|
await applyHolidayCalendars(planId);
|
||||||
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 */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (carry_over_enabled === false) {
|
if (carry_over_enabled === false) {
|
||||||
db.prepare('UPDATE vacay_user_years SET carried_over = 0 WHERE plan_id = ?').run(planId);
|
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');
|
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 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({
|
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) => {
|
router.put('/color', (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { color, target_user_id } = req.body;
|
const { color, target_user_id } = req.body;
|
||||||
|
|||||||
Reference in New Issue
Block a user