ui-nyla/src/components/PeriodPicker/PeriodPicker.tsx
ValueOn AG d579df1c92
Some checks failed
Deploy Nyla Frontend to Integration / deploy (push) Failing after 52s
panel ui
2026-06-11 16:43:53 +02:00

178 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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;