mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Merge branch 'main' into feat/mfa
This commit is contained in:
@@ -72,7 +72,7 @@
|
||||
|
||||
### Customization & Admin
|
||||
- **Dark Mode** — Full light and dark theme with dynamic status bar color matching
|
||||
- **Multilingual** — English and German (i18n)
|
||||
- **Multilingual** — English, German, Chinese (Simplified), Dutch, Russian (i18n)
|
||||
- **Admin Panel** — User management, global categories, addon management, API keys, backups, and GitHub release history
|
||||
- **Auto-Backups** — Scheduled backups with configurable interval and retention
|
||||
- **Customizable** — Temperature units, time format (12h/24h), map tile sources, default coordinates
|
||||
|
||||
@@ -51,6 +51,9 @@ export default function TimezoneWidget() {
|
||||
})
|
||||
const [now, setNow] = useState(Date.now())
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [customLabel, setCustomLabel] = useState('')
|
||||
const [customTz, setCustomTz] = useState('')
|
||||
const [customError, setCustomError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const i = setInterval(() => setNow(Date.now()), 10000)
|
||||
@@ -61,6 +64,20 @@ export default function TimezoneWidget() {
|
||||
localStorage.setItem('dashboard_timezones', JSON.stringify(zones))
|
||||
}, [zones])
|
||||
|
||||
const isValidTz = (tz: string) => {
|
||||
try { Intl.DateTimeFormat('en-US', { timeZone: tz }).format(new Date()); return true } catch { return false }
|
||||
}
|
||||
|
||||
const addCustomZone = () => {
|
||||
const tz = customTz.trim()
|
||||
if (!tz) { setCustomError(t('dashboard.timezoneCustomErrorEmpty')); return }
|
||||
if (!isValidTz(tz)) { setCustomError(t('dashboard.timezoneCustomErrorInvalid')); return }
|
||||
if (zones.find(z => z.tz === tz)) { setCustomError(t('dashboard.timezoneCustomErrorDuplicate')); return }
|
||||
const label = customLabel.trim() || tz.split('/').pop()?.replace(/_/g, ' ') || tz
|
||||
setZones([...zones, { label, tz }])
|
||||
setCustomLabel(''); setCustomTz(''); setCustomError(''); setShowAdd(false)
|
||||
}
|
||||
|
||||
const addZone = (zone) => {
|
||||
if (!zones.find(z => z.tz === zone.tz)) {
|
||||
setZones([...zones, zone])
|
||||
@@ -108,7 +125,29 @@ export default function TimezoneWidget() {
|
||||
|
||||
{/* Add zone dropdown */}
|
||||
{showAdd && (
|
||||
<div className="mt-2 rounded-xl p-2 max-h-[200px] overflow-auto" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<div className="mt-2 rounded-xl p-2 max-h-[280px] overflow-auto" style={{ background: 'var(--bg-secondary)' }}>
|
||||
{/* Custom timezone */}
|
||||
<div className="px-2 py-2 mb-2 rounded-lg" style={{ background: 'var(--bg-card)' }}>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide mb-2" style={{ color: 'var(--text-faint)' }}>{t('dashboard.timezoneCustomTitle')}</p>
|
||||
<div className="space-y-1.5">
|
||||
<input value={customLabel} onChange={e => setCustomLabel(e.target.value)}
|
||||
placeholder={t('dashboard.timezoneCustomLabelPlaceholder')}
|
||||
className="w-full px-2 py-1.5 rounded-lg text-xs outline-none"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border-secondary)' }} />
|
||||
<input value={customTz} onChange={e => { setCustomTz(e.target.value); setCustomError('') }}
|
||||
placeholder={t('dashboard.timezoneCustomTzPlaceholder')}
|
||||
className="w-full px-2 py-1.5 rounded-lg text-xs outline-none"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: `1px solid ${customError ? '#ef4444' : 'var(--border-secondary)'}` }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') addCustomZone() }} />
|
||||
{customError && <p className="text-[10px]" style={{ color: '#ef4444' }}>{customError}</p>}
|
||||
<button onClick={addCustomZone}
|
||||
className="w-full py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{ background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
|
||||
{t('dashboard.timezoneCustomAdd')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Popular zones */}
|
||||
{POPULAR_ZONES.filter(z => !zones.find(existing => existing.tz === z.tz)).map(z => (
|
||||
<button key={z.tz} onClick={() => addZone(z)}
|
||||
className="w-full flex items-center justify-between px-2 py-1.5 rounded-lg text-xs text-left transition-colors"
|
||||
|
||||
@@ -763,7 +763,7 @@ export default function DayPlanSidebar({
|
||||
marginLeft: pi > 0 ? -4 : 0, flexShrink: 0,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{p.avatar ? <img src={p.avatar} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : p.username?.[0]?.toUpperCase()}
|
||||
{p.avatar ? <img src={`/uploads/avatars/${p.avatar}`} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : p.username?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
))}
|
||||
{assignment.participants.length > 5 && (
|
||||
|
||||
@@ -281,6 +281,15 @@ export default function PlaceFormModal({
|
||||
step="any"
|
||||
value={form.lat}
|
||||
onChange={e => handleChange('lat', e.target.value)}
|
||||
onPaste={e => {
|
||||
const text = e.clipboardData.getData('text').trim()
|
||||
const match = text.match(/^(-?\d+\.?\d*)\s*[,;\s]\s*(-?\d+\.?\d*)$/)
|
||||
if (match) {
|
||||
e.preventDefault()
|
||||
handleChange('lat', match[1])
|
||||
handleChange('lng', match[2])
|
||||
}
|
||||
}}
|
||||
placeholder={t('places.formLat')}
|
||||
className="form-input"
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,202 @@ 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])
|
||||
|
||||
const PRESET_COLORS = ['#fecaca', '#fed7aa', '#fde68a', '#bbf7d0', '#a5f3fc', '#c7d2fe', '#e9d5ff', '#fda4af', '#6366f1', '#ef4444', '#22c55e', '#3b82f6']
|
||||
const [showColorPicker, setShowColorPicker] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 items-start p-3 rounded-xl" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => setShowColorPicker(!showColorPicker)}
|
||||
style={{ width: 28, height: 28, borderRadius: 8, background: localColor, border: '2px solid var(--border-primary)', cursor: 'pointer' }}
|
||||
title={t('vacay.calendarColor')}
|
||||
/>
|
||||
{showColorPicker && (
|
||||
<div style={{ position: 'absolute', top: 34, left: 0, zIndex: 50, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12, padding: 8, boxShadow: '0 8px 24px rgba(0,0,0,0.12)', display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 4, width: 120 }}>
|
||||
{PRESET_COLORS.map(c => (
|
||||
<button key={c} onClick={() => { setLocalColor(c); setShowColorPicker(false); if (c !== cal.color) onUpdate({ color: c }) }}
|
||||
style={{ width: 24, height: 24, borderRadius: 6, background: c, border: localColor === c ? '2px solid var(--text-primary)' : '2px solid transparent', cursor: 'pointer' }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<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 }) }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
|
||||
placeholder={t('vacay.calendarLabel')}
|
||||
style={{ width: '100%', fontSize: 12, padding: '6px 10px', borderRadius: 8, background: 'var(--bg-input)', border: '1px solid var(--border-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }}
|
||||
/>
|
||||
<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 !== '')
|
||||
|
||||
const PRESET_COLORS = ['#fecaca', '#fed7aa', '#fde68a', '#bbf7d0', '#a5f3fc', '#c7d2fe', '#e9d5ff', '#fda4af', '#6366f1', '#ef4444', '#22c55e', '#3b82f6']
|
||||
const [showColorPicker, setShowColorPicker] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 items-start p-3 rounded-xl border border-dashed" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => setShowColorPicker(!showColorPicker)}
|
||||
style={{ width: 28, height: 28, borderRadius: 8, background: color, border: '2px solid var(--border-primary)', cursor: 'pointer' }}
|
||||
title={t('vacay.calendarColor')}
|
||||
/>
|
||||
{showColorPicker && (
|
||||
<div style={{ position: 'absolute', top: 34, left: 0, zIndex: 50, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12, padding: 8, boxShadow: '0 8px 24px rgba(0,0,0,0.12)', display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 4, width: 120 }}>
|
||||
{PRESET_COLORS.map(c => (
|
||||
<button key={c} onClick={() => { setColor(c); setShowColorPicker(false) }}
|
||||
style={{ width: 24, height: 24, borderRadius: 6, background: c, border: color === c ? '2px solid var(--text-primary)' : '2px solid transparent', cursor: 'pointer' }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<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')}
|
||||
style={{ width: '100%', fontSize: 12, padding: '6px 10px', borderRadius: 8, background: 'var(--bg-input)', border: '1px solid var(--border-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }}
|
||||
/>
|
||||
<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,18 +3,22 @@ import { useSettingsStore } from '../store/settingsStore'
|
||||
import de from './translations/de'
|
||||
import en from './translations/en'
|
||||
import es from './translations/es'
|
||||
import fr from './translations/fr'
|
||||
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 }
|
||||
const LOCALES: Record<string, string> = { de: 'de-DE', en: 'en-US', es: 'es-ES' }
|
||||
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' }
|
||||
|
||||
export function getLocaleForLanguage(language: string): string {
|
||||
return LOCALES[language] || LOCALES.en
|
||||
}
|
||||
|
||||
export function getIntlLanguage(language: string): string {
|
||||
return language === 'de' || language === 'es' ? language : 'en'
|
||||
return ['de', 'es', 'fr', 'ru', 'zh', 'nl'].includes(language) ? language : 'en'
|
||||
}
|
||||
|
||||
interface TranslationContextValue {
|
||||
@@ -37,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))
|
||||
|
||||
@@ -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',
|
||||
@@ -54,6 +54,13 @@ const de: Record<string, string> = {
|
||||
'dashboard.currency': 'Währung',
|
||||
'dashboard.timezone': 'Zeitzonen',
|
||||
'dashboard.localTime': 'Lokal',
|
||||
'dashboard.timezoneCustomTitle': 'Eigene Zeitzone',
|
||||
'dashboard.timezoneCustomLabelPlaceholder': 'Bezeichnung (optional)',
|
||||
'dashboard.timezoneCustomTzPlaceholder': 'z.B. America/New_York',
|
||||
'dashboard.timezoneCustomAdd': 'Hinzufügen',
|
||||
'dashboard.timezoneCustomErrorEmpty': 'Zeitzone eingeben',
|
||||
'dashboard.timezoneCustomErrorInvalid': 'Ungültige Zeitzone. Format: Europe/Berlin',
|
||||
'dashboard.timezoneCustomErrorDuplicate': 'Bereits hinzugefügt',
|
||||
'dashboard.emptyTitle': 'Noch keine Reisen',
|
||||
'dashboard.emptyText': 'Erstelle deine erste Reise und beginne mit der Planung von Orten, Tagesabläufen und Packlisten.',
|
||||
'dashboard.emptyButton': 'Erste Reise erstellen',
|
||||
@@ -322,6 +329,20 @@ const de: Record<string, string> = {
|
||||
'admin.tabs.addons': 'Addons',
|
||||
'admin.addons.title': 'Addons',
|
||||
'admin.addons.subtitle': 'Aktiviere oder deaktiviere Funktionen, um TREK nach deinen Wünschen anzupassen.',
|
||||
'admin.addons.catalog.memories.name': 'Erinnerungen',
|
||||
'admin.addons.catalog.memories.description': 'Geteilte Fotoalben für jede Reise',
|
||||
'admin.addons.catalog.packing.name': 'Packliste',
|
||||
'admin.addons.catalog.packing.description': 'Checklisten zum Kofferpacken für jede Reise',
|
||||
'admin.addons.catalog.budget.name': 'Budget',
|
||||
'admin.addons.catalog.budget.description': 'Ausgaben verfolgen und Reisebudget planen',
|
||||
'admin.addons.catalog.documents.name': 'Dokumente',
|
||||
'admin.addons.catalog.documents.description': 'Reisedokumente speichern und verwalten',
|
||||
'admin.addons.catalog.vacay.name': 'Vacay',
|
||||
'admin.addons.catalog.vacay.description': 'Persönlicher Urlaubsplaner mit Kalenderansicht',
|
||||
'admin.addons.catalog.atlas.name': 'Atlas',
|
||||
'admin.addons.catalog.atlas.description': 'Weltkarte mit besuchten Ländern und Reisestatistiken',
|
||||
'admin.addons.catalog.collab.name': 'Collab',
|
||||
'admin.addons.catalog.collab.description': 'Echtzeit-Notizen, Umfragen und Chat für die Reiseplanung',
|
||||
'admin.addons.subtitleBefore': 'Aktiviere oder deaktiviere Funktionen, um ',
|
||||
'admin.addons.subtitleAfter': ' nach deinen Wünschen anzupassen.',
|
||||
'admin.addons.enabled': 'Aktiviert',
|
||||
@@ -413,6 +434,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',
|
||||
@@ -54,6 +54,13 @@ const en: Record<string, string> = {
|
||||
'dashboard.currency': 'Currency',
|
||||
'dashboard.timezone': 'Timezones',
|
||||
'dashboard.localTime': 'Local',
|
||||
'dashboard.timezoneCustomTitle': 'Custom Timezone',
|
||||
'dashboard.timezoneCustomLabelPlaceholder': 'Label (optional)',
|
||||
'dashboard.timezoneCustomTzPlaceholder': 'e.g. America/New_York',
|
||||
'dashboard.timezoneCustomAdd': 'Add',
|
||||
'dashboard.timezoneCustomErrorEmpty': 'Enter a timezone identifier',
|
||||
'dashboard.timezoneCustomErrorInvalid': 'Invalid timezone. Use format like Europe/Berlin',
|
||||
'dashboard.timezoneCustomErrorDuplicate': 'Already added',
|
||||
'dashboard.emptyTitle': 'No trips yet',
|
||||
'dashboard.emptyText': 'Create your first trip and start planning!',
|
||||
'dashboard.emptyButton': 'Create First Trip',
|
||||
@@ -322,6 +329,20 @@ const en: Record<string, string> = {
|
||||
'admin.tabs.addons': 'Addons',
|
||||
'admin.addons.title': 'Addons',
|
||||
'admin.addons.subtitle': 'Enable or disable features to customize your TREK experience.',
|
||||
'admin.addons.catalog.memories.name': 'Memories',
|
||||
'admin.addons.catalog.memories.description': 'Shared photo albums for each trip',
|
||||
'admin.addons.catalog.packing.name': 'Packing',
|
||||
'admin.addons.catalog.packing.description': 'Checklists to prepare your luggage for each trip',
|
||||
'admin.addons.catalog.budget.name': 'Budget',
|
||||
'admin.addons.catalog.budget.description': 'Track expenses and plan your trip budget',
|
||||
'admin.addons.catalog.documents.name': 'Documents',
|
||||
'admin.addons.catalog.documents.description': 'Store and manage travel documents',
|
||||
'admin.addons.catalog.vacay.name': 'Vacay',
|
||||
'admin.addons.catalog.vacay.description': 'Personal vacation planner with calendar view',
|
||||
'admin.addons.catalog.atlas.name': 'Atlas',
|
||||
'admin.addons.catalog.atlas.description': 'World map with visited countries and travel stats',
|
||||
'admin.addons.catalog.collab.name': 'Collab',
|
||||
'admin.addons.catalog.collab.description': 'Real-time notes, polls, and chat for trip planning',
|
||||
'admin.addons.subtitleBefore': 'Enable or disable features to customize your ',
|
||||
'admin.addons.subtitleAfter': ' experience.',
|
||||
'admin.addons.enabled': 'Enabled',
|
||||
@@ -413,6 +434,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.',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -272,11 +272,15 @@ export default function SettingsPage(): React.ReactElement {
|
||||
{/* Sprache */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.language')}</label>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ value: 'de', label: 'Deutsch' },
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'es', label: 'Español' },
|
||||
{ value: 'fr', label: 'Français' },
|
||||
{ value: 'nl', label: 'Nederlands' },
|
||||
{ value: 'ru', label: 'Русский' },
|
||||
{ value: 'zh', label: '中文' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
@@ -285,9 +289,9 @@ export default function SettingsPage(): React.ReactElement {
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '8px 14px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 13, fontWeight: 500,
|
||||
border: settings.language === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: settings.language === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -283,10 +283,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
|
||||
@@ -303,6 +316,9 @@ export interface VacayUser {
|
||||
export interface VacayEntry {
|
||||
date: string
|
||||
user_id: number
|
||||
plan_id?: number
|
||||
person_color?: string
|
||||
person_name?: string
|
||||
}
|
||||
|
||||
export interface VacayStat {
|
||||
@@ -314,6 +330,8 @@ export interface VacayStat {
|
||||
export interface HolidayInfo {
|
||||
name: string
|
||||
localName: string
|
||||
color: string
|
||||
label: string | null
|
||||
}
|
||||
|
||||
export interface HolidaysMap {
|
||||
|
||||
Generated
+17
-109
@@ -16,7 +16,7 @@
|
||||
"express": "^4.18.3",
|
||||
"helmet": "^8.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"multer": "^2.1.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "^2.7.0",
|
||||
"otplib": "^12.0.1",
|
||||
@@ -1270,50 +1270,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
|
||||
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 0.8"
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^2.2.2",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream/node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-stream/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
@@ -2457,18 +2427,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
"bin": {
|
||||
"mkdirp": "bin/cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp-classic": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
@@ -2482,22 +2440,22 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "1.4.5-lts.2",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
|
||||
"integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
|
||||
"deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
|
||||
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.0.0",
|
||||
"concat-stream": "^1.5.2",
|
||||
"mkdirp": "^0.5.4",
|
||||
"object-assign": "^4.1.1",
|
||||
"type-is": "^1.6.4",
|
||||
"xtend": "^4.0.0"
|
||||
"busboy": "^1.6.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"type-is": "^1.6.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
"node": ">= 10.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
@@ -3603,56 +3561,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/zip-stream": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-5.0.2.tgz",
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
"express": "^4.18.3",
|
||||
"helmet": "^8.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"multer": "^2.1.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"otplib": "^12.0.1",
|
||||
"qrcode": "^1.5.4",
|
||||
|
||||
@@ -283,6 +283,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,
|
||||
|
||||
+6
-2
@@ -44,6 +44,8 @@ if (allowedOrigins) {
|
||||
corsOrigin = true;
|
||||
}
|
||||
|
||||
const shouldForceHttps = process.env.FORCE_HTTPS === 'true';
|
||||
|
||||
app.use(cors({
|
||||
origin: corsOrigin,
|
||||
credentials: true
|
||||
@@ -60,13 +62,15 @@ app.use(helmet({
|
||||
objectSrc: ["'self'"],
|
||||
frameSrc: ["'self'"],
|
||||
frameAncestors: ["'self'"],
|
||||
upgradeInsecureRequests: shouldForceHttps ? [] : null
|
||||
}
|
||||
},
|
||||
crossOriginEmbedderPolicy: false,
|
||||
hsts: process.env.FORCE_HTTPS === 'true' ? { maxAge: 31536000, includeSubDomains: false } : false,
|
||||
hsts: shouldForceHttps ? { maxAge: 31536000, includeSubDomains: false } : false,
|
||||
}));
|
||||
|
||||
// Redirect HTTP to HTTPS (opt-in via FORCE_HTTPS=true)
|
||||
if (process.env.FORCE_HTTPS === 'true') {
|
||||
if (shouldForceHttps) {
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.secure || req.headers['x-forwarded-proto'] === 'https') return next();
|
||||
res.redirect(301, 'https://' + req.headers.host + req.url);
|
||||
|
||||
+103
-25
@@ -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