326 lines
8.7 KiB
TypeScript
326 lines
8.7 KiB
TypeScript
import { setActiveTarget, updateSituation } from 'Actions/actions'
|
|
import InputSuggestions from 'Components/conversation/InputSuggestions'
|
|
import PeriodSwitch from 'Components/PeriodSwitch'
|
|
import RuleLink from 'Components/RuleLink'
|
|
import { ThemeColorsContext } from 'Components/utils/colors'
|
|
import {
|
|
EngineContext,
|
|
useEvaluation,
|
|
useInversionFail
|
|
} from 'Components/utils/EngineContext'
|
|
import { SitePathsContext } from 'Components/utils/SitePathsContext'
|
|
import { formatCurrency, formatValue } from 'Engine/format'
|
|
import { EvaluatedRule, EvaluatedNode } from 'Engine/types'
|
|
import { isNil } from 'ramda'
|
|
import React, { useCallback, useContext, useEffect, useState } from 'react'
|
|
import emoji from 'react-easy-emoji'
|
|
import { Trans, useTranslation } from 'react-i18next'
|
|
import { useDispatch, useSelector } from 'react-redux'
|
|
import { Link, useLocation } from 'react-router-dom'
|
|
import { RootState, SimulationConfig } from 'Reducers/rootReducer'
|
|
import { DottedName, ParsedRule } from 'Rules'
|
|
import {
|
|
situationSelector,
|
|
targetUnitSelector
|
|
} from 'Selectors/simulationSelectors'
|
|
import Animate from 'Ui/animate'
|
|
import AnimatedTargetValue from 'Ui/AnimatedTargetValue'
|
|
import CurrencyInput from './CurrencyInput/CurrencyInput'
|
|
import './TargetSelection.css'
|
|
|
|
export default function TargetSelection({ showPeriodSwitch = true }) {
|
|
const objectifs = useSelector(
|
|
(state: RootState) => state.simulation?.config.objectifs || []
|
|
)
|
|
const colors = useContext(ThemeColorsContext)
|
|
|
|
return (
|
|
<div id="targetSelection">
|
|
{((typeof objectifs[0] === 'string'
|
|
? [{ objectifs }]
|
|
: objectifs) as any).map(
|
|
({ icône, objectifs: targets, nom }, index: number) => (
|
|
<React.Fragment key={nom || '0'}>
|
|
<div style={{ display: 'flex', alignItems: 'end' }}>
|
|
<div style={{ flex: 1 }}>
|
|
{nom && (
|
|
<h2 style={{ marginBottom: 0 }}>
|
|
{emoji(icône)} <Trans>{nom}</Trans>
|
|
</h2>
|
|
)}
|
|
</div>
|
|
{index === 0 && showPeriodSwitch && <PeriodSwitch />}
|
|
</div>
|
|
<section
|
|
className="ui__ plain card"
|
|
style={{
|
|
marginTop: '.6em',
|
|
color: colors.textColor,
|
|
background: `linear-gradient(
|
|
60deg,
|
|
${colors.darkColor} 0%,
|
|
${colors.color} 100%
|
|
)`
|
|
}}
|
|
>
|
|
<ul className="targets">
|
|
{' '}
|
|
{targets.map(target => (
|
|
<Target key={target} dottedName={target} />
|
|
))}
|
|
</ul>
|
|
</section>
|
|
</React.Fragment>
|
|
)
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
type TargetProps = {
|
|
dottedName: DottedName
|
|
}
|
|
const Target = ({ dottedName }: TargetProps) => {
|
|
const activeInput = useSelector((state: RootState) => state.activeTargetInput)
|
|
const target = useEvaluation(dottedName, {
|
|
unit: useSelector(targetUnitSelector)
|
|
})
|
|
const dispatch = useDispatch()
|
|
const onSuggestionClick = useCallback(
|
|
value => {
|
|
dispatch(updateSituation(target.dottedName, value))
|
|
},
|
|
[target.dottedName, dispatch]
|
|
)
|
|
|
|
const isSmallTarget = !!target.question !== !!target.formule
|
|
if (
|
|
target.nodeValue === false ||
|
|
(isSmallTarget && !target.question && !target.nodeValue)
|
|
) {
|
|
return null
|
|
}
|
|
const isActiveInput = activeInput === target.dottedName
|
|
|
|
return (
|
|
<li
|
|
key={target.name}
|
|
className={isSmallTarget ? 'small-target' : undefined}
|
|
>
|
|
<Animate.appear unless={!isSmallTarget}>
|
|
<div>
|
|
<div className="main">
|
|
<Header
|
|
{...{
|
|
target,
|
|
isActiveInput
|
|
}}
|
|
/>
|
|
{isSmallTarget && (
|
|
<span
|
|
style={{
|
|
flex: 1,
|
|
borderBottom: '1px dashed #ffffff91',
|
|
marginLeft: '1rem'
|
|
}}
|
|
/>
|
|
)}
|
|
<TargetInputOrValue
|
|
{...{
|
|
target,
|
|
isActiveInput,
|
|
isSmallTarget
|
|
}}
|
|
/>
|
|
</div>
|
|
{isActiveInput && (
|
|
<Animate.fromTop>
|
|
<div css="display: flex; justify-content: flex-end">
|
|
<InputSuggestions
|
|
suggestions={target.suggestions}
|
|
onFirstClick={onSuggestionClick}
|
|
unit={target.unit}
|
|
/>
|
|
</div>
|
|
</Animate.fromTop>
|
|
)}
|
|
</div>
|
|
</Animate.appear>
|
|
</li>
|
|
)
|
|
}
|
|
|
|
let Header = ({ target }: { target: ParsedRule }) => {
|
|
const sitePaths = useContext(SitePathsContext)
|
|
const { t } = useTranslation()
|
|
const { pathname } = useLocation()
|
|
// TODO : Super hacky, we want to amend one label in the covid simulator, but
|
|
// because the label is fetched from the global state we have to do a hack
|
|
// here based on the URL.
|
|
const hackyShowPeriod = pathname === sitePaths.coronavirus
|
|
return (
|
|
<span className="header">
|
|
<span className="texts">
|
|
<span className="optionTitle">
|
|
<Link
|
|
to={{
|
|
pathname: sitePaths.documentation.rule(target.dottedName),
|
|
state: { useDefaultValues: true }
|
|
}}
|
|
>
|
|
{target.title || target.name}
|
|
{hackyShowPeriod && ' ' + t('mensuel')}
|
|
</Link>
|
|
</span>
|
|
<p className="ui__ notice">{target.summary}</p>
|
|
</span>
|
|
</span>
|
|
)
|
|
}
|
|
|
|
type TargetInputOrValueProps = {
|
|
target: EvaluatedRule<DottedName>
|
|
isActiveInput: boolean
|
|
isSmallTarget: boolean
|
|
}
|
|
|
|
function TargetInputOrValue({
|
|
target,
|
|
isActiveInput,
|
|
isSmallTarget
|
|
}: TargetInputOrValueProps) {
|
|
const { language } = useTranslation().i18n
|
|
const colors = useContext(ThemeColorsContext)
|
|
const dispatch = useDispatch()
|
|
const situationValue = useSelector(situationSelector)[target.dottedName]
|
|
const targetUnit = useSelector(targetUnitSelector)
|
|
const engine = useContext(EngineContext)
|
|
const value =
|
|
typeof situationValue === 'string'
|
|
? Math.round(
|
|
engine.evaluate(situationValue, { unit: targetUnit })
|
|
.nodeValue as number
|
|
)
|
|
: situationValue != null
|
|
? situationValue
|
|
: target?.nodeValue != null
|
|
? Math.round(+target.nodeValue)
|
|
: undefined
|
|
|
|
const blurValue = useInversionFail() && !isActiveInput
|
|
|
|
const onChange = useCallback(
|
|
evt =>
|
|
dispatch(
|
|
updateSituation(target.dottedName, +evt.target.value + ' ' + targetUnit)
|
|
),
|
|
[targetUnit, target, dispatch]
|
|
)
|
|
return (
|
|
<span
|
|
className="targetInputOrValue"
|
|
style={blurValue ? { filter: 'blur(3px)' } : {}}
|
|
>
|
|
{target.question ? (
|
|
<>
|
|
{!isActiveInput && <AnimatedTargetValue value={value} />}
|
|
<CurrencyInput
|
|
style={{
|
|
color: colors.textColor,
|
|
borderColor: colors.textColor
|
|
}}
|
|
debounce={600}
|
|
name={target.dottedName}
|
|
value={value}
|
|
className={
|
|
isActiveInput ||
|
|
isNil(value) ||
|
|
(target.question && isSmallTarget)
|
|
? 'targetInput'
|
|
: 'editableTarget'
|
|
}
|
|
onChange={onChange}
|
|
onFocus={() => {
|
|
if (isSmallTarget) return
|
|
dispatch(setActiveTarget(target.dottedName))
|
|
}}
|
|
language={language}
|
|
/>
|
|
<span className="targetInputBottomBorder">
|
|
{formatCurrency(value, language)}
|
|
</span>
|
|
</>
|
|
) : (
|
|
<span>
|
|
{value && Number.isNaN(value) ? '—' : formatCurrency(value, language)}
|
|
</span>
|
|
)}
|
|
{target.dottedName.includes('prix du travail') && <AidesGlimpse />}
|
|
{target.dottedName === 'contrat salarié . rémunération . net' && (
|
|
<TitreRestaurant />
|
|
)}
|
|
</span>
|
|
)
|
|
}
|
|
function TitreRestaurant() {
|
|
const targetUnit = useSelector(targetUnitSelector)
|
|
const titresRestaurant = useEvaluation(
|
|
'contrat salarié . frais professionnels . titres-restaurant . montant',
|
|
{ unit: targetUnit }
|
|
)
|
|
const { language } = useTranslation().i18n
|
|
if (!titresRestaurant?.nodeValue) return null
|
|
return (
|
|
<Animate.fromTop>
|
|
<div className="aidesGlimpse">
|
|
<RuleLink {...titresRestaurant}>
|
|
+{' '}
|
|
<strong>
|
|
{formatValue({
|
|
nodeValue: titresRestaurant.nodeValue,
|
|
unit: '€',
|
|
language
|
|
})}
|
|
</strong>{' '}
|
|
<Trans>en titres-restaurant</Trans> {emoji(' 🍽')}
|
|
</RuleLink>
|
|
</div>
|
|
</Animate.fromTop>
|
|
)
|
|
}
|
|
function AidesGlimpse() {
|
|
const targetUnit = useSelector(targetUnitSelector)
|
|
const aides = useEvaluation('contrat salarié . aides employeur', {
|
|
unit: targetUnit
|
|
})
|
|
const { language } = useTranslation().i18n
|
|
|
|
// Dans le cas où il n'y a qu'une seule aide à l'embauche qui s'applique, nous
|
|
// faisons un lien direct vers cette aide, plutôt qu'un lien vers la liste qui
|
|
// est une somme des aides qui sont toutes nulle sauf l'aide active.
|
|
const aidesDetail = aides?.formule.explanation.explanation
|
|
const aidesNotNul = aidesDetail?.filter(node => node.nodeValue !== false)
|
|
const aideLink = aidesNotNul?.length === 1 ? aidesNotNul[0] : aides
|
|
|
|
if (!aides?.nodeValue) return null
|
|
return (
|
|
<Animate.fromTop>
|
|
<div className="aidesGlimpse">
|
|
<RuleLink {...aideLink}>
|
|
<Trans>en incluant</Trans>{' '}
|
|
<strong>
|
|
<span>
|
|
{formatValue({
|
|
nodeValue: aides.nodeValue,
|
|
unit: '€',
|
|
language
|
|
})}
|
|
</span>
|
|
</strong>{' '}
|
|
<Trans>d'aides</Trans> {emoji(aides?.icons ?? '')}
|
|
</RuleLink>
|
|
</div>
|
|
</Animate.fromTop>
|
|
)
|
|
}
|