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_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 {
|
||||
year: number
|
||||
month: number
|
||||
@@ -86,7 +93,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 && (
|
||||
@@ -115,7 +122,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}
|
||||
|
||||
@@ -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 { 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([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
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useSettingsStore } from '../store/settingsStore'
|
||||
import de from './translations/de'
|
||||
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 }
|
||||
|
||||
@@ -27,7 +27,7 @@ export function TranslationProvider({ children }: TranslationProviderProps) {
|
||||
const fallback = translations.de
|
||||
|
||||
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))
|
||||
|
||||
@@ -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',
|
||||
@@ -390,6 +390,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.',
|
||||
|
||||
@@ -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',
|
||||
@@ -390,6 +390,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.',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user