// Copyright (c) 2026 PowerOn AG // All rights reserved. /** * PeriodPicker - public component (Trigger + Popover). * * Carries a semantic value `{ preset, fromDate, toDate }`. Presets are * re-resolved on every render so dynamic ranges (`ytd`, `last12Months`, …) * stay fresh when the user revisits the page. * * Outside-click is detected via `mousedown` (not `click`): inner elements * are re-rendered on selection and would otherwise be detached from the DOM * when the click event reaches the document, breaking `closest()`. */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useLanguage } from '../../providers/language/LanguageContext'; import { FloatingPortal } from '../UiComponents/FloatingPortal'; import PeriodPickerPopover from './PeriodPickerPopover'; import { formatIsoDateDe, isPresetDisabled, isValueAllowed, resolvePeriod, } from './PeriodPickerLogic'; import type { PeriodPickerProps, PeriodPreset, PeriodValue, } from './PeriodPickerTypes'; import styles from './PeriodPicker.module.css'; // Re-export public types so callers can import everything from one place. export type { PeriodPickerProps, PeriodPreset, PeriodValue, PeriodDirection, PeriodPresetKind, PeriodUnit } from './PeriodPickerTypes'; export { resolvePeriod, isPresetDisabled, isValueAllowed } from './PeriodPickerLogic'; const _DEFAULT_PRESET: PeriodPreset = { kind: 'ytd' }; function _formatTriggerLabel(value: PeriodValue | null, t: (k: string) => string, placeholder: string): string { if (!value) return placeholder; // "Alle" intentionally skips the range suffix: the sentinel dates // (1970-2999) would be noise in the trigger. if (value.preset.kind === 'allTime') return t('Alle'); const range = `${formatIsoDateDe(value.fromDate)} – ${formatIsoDateDe(value.toDate)}`; switch (value.preset.kind) { case 'ytd': return `${t('Laufendes Jahr')} · ${range}`; case 'lastYear': return `${t('Letztes Jahr')} · ${range}`; case 'nextYear': return `${t('Nächstes Jahr')} · ${range}`; case 'last12Months': return `${t('Letzte 12 Monate')} · ${range}`; case 'next12Months': return `${t('Nächste 12 Monate')} · ${range}`; case 'thisMonth': return `${t('Dieser Monat')} · ${range}`; case 'lastMonth': return `${t('Letzter Monat')} · ${range}`; case 'thisQuarter': return `${t('Dieses Quartal')} · ${range}`; case 'lastQuarter': return `${t('Letztes Quartal')} · ${range}`; case 'lastN': { const unitLabel = _unitLabelShort(value.preset.unit, t); return `${t('Letzte')} ${value.preset.amount} ${unitLabel} · ${range}`; } case 'nextN': { const unitLabel = _unitLabelShort(value.preset.unit, t); return `${t('Nächste')} ${value.preset.amount} ${unitLabel} · ${range}`; } case 'custom': default: return range; } } function _unitLabelShort(unit: 'day' | 'week' | 'month' | 'year', t: (k: string) => string): string { switch (unit) { case 'day': return t('Tage'); case 'week': return t('Wochen'); case 'month': return t('Monate'); case 'year': return t('Jahre'); } } export const PeriodPicker: React.FC = (props) => { const { value, onChange, direction = 'any', minDate, maxDate, enabledPresets, defaultPreset = _DEFAULT_PRESET, placeholder, disabled = false, className, } = props; const { t } = useLanguage(); const constraints = useMemo( () => ({ direction, minDate, maxDate, enabledPresets }), [direction, minDate, maxDate, enabledPresets], ); // Re-resolve semantic presets on every render so values stay fresh. const resolvedValue: PeriodValue | null = useMemo(() => { if (!value) return null; if (value.preset.kind === 'custom') return value; const r = resolvePeriod(value.preset, value); if (r.fromDate === value.fromDate && r.toDate === value.toDate) return value; return { preset: value.preset, fromDate: r.fromDate, toDate: r.toDate }; }, [value]); const _resolvedTrigger = useMemo( () => _formatTriggerLabel(resolvedValue, t, placeholder || t('Zeitraum wählen')), [resolvedValue, t, placeholder], ); const [open, setOpen] = useState(false); const triggerRef = useRef(null); const _initialDraft: PeriodValue = useMemo(() => { if (resolvedValue) return resolvedValue; const preset = isPresetDisabled(defaultPreset.kind, constraints) ? ({ kind: 'custom' } as PeriodPreset) : defaultPreset; const r = resolvePeriod(preset); return { preset, fromDate: r.fromDate, toDate: r.toDate }; }, [resolvedValue, defaultPreset, constraints]); const _handleApply = useCallback((next: PeriodValue) => { onChange(next); setOpen(false); }, [onChange]); const _handleCancel = useCallback(() => setOpen(false), []); // If parent-passed value violates constraints, fall back silently to the // default preset so the trigger never shows a forbidden range. useEffect(() => { if (resolvedValue && !isValueAllowed(resolvedValue, constraints)) { const fallbackPreset = isPresetDisabled(defaultPreset.kind, constraints) ? ({ kind: 'custom' } as PeriodPreset) : defaultPreset; const r = resolvePeriod(fallbackPreset, resolvedValue); onChange({ preset: fallbackPreset, fromDate: r.fromDate, toDate: r.toDate }); } }, [resolvedValue, constraints, defaultPreset, onChange]); const triggerCls = [styles.trigger]; if (open) triggerCls.push(styles.open); return (
setOpen(false)} placement="auto" >
); }; export default PeriodPicker;