Some checks failed
Deploy Nyla Frontend to Integration / deploy (push) Failing after 52s
178 lines
6.4 KiB
TypeScript
178 lines
6.4 KiB
TypeScript
// 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<PeriodPickerProps> = (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<HTMLButtonElement>(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 (
|
||
<div className={`${styles.wrapper}${className ? ` ${className}` : ''}`}>
|
||
<button
|
||
ref={triggerRef}
|
||
type="button"
|
||
className={triggerCls.join(' ')}
|
||
onClick={() => setOpen((o) => !o)}
|
||
disabled={disabled}
|
||
aria-haspopup="dialog"
|
||
aria-expanded={open}
|
||
>
|
||
<span className={styles.triggerIcon} aria-hidden>📅</span>
|
||
<span className={styles.triggerText}>{_resolvedTrigger}</span>
|
||
<span className={styles.triggerChev} aria-hidden>▾</span>
|
||
</button>
|
||
|
||
<FloatingPortal
|
||
open={open}
|
||
anchorRef={triggerRef}
|
||
onClose={() => setOpen(false)}
|
||
placement="auto"
|
||
>
|
||
<PeriodPickerPopover
|
||
initialValue={_initialDraft}
|
||
constraints={constraints}
|
||
onApply={_handleApply}
|
||
onCancel={_handleCancel}
|
||
/>
|
||
</FloatingPortal>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default PeriodPicker;
|