Revert "Revert "Refacto : séparation claire du moteur et de l'application 🔥""

This reverts commit 8c7ab52a4f.
pull/993/head
Johan Girod 2020-04-23 09:30:03 +02:00
parent c2249929c9
commit 7ccc4ce4e3
216 changed files with 3444 additions and 4999 deletions

View File

@ -2,9 +2,6 @@
"editor.formatOnSave": true,
"spellright.language": ["fr", "en"],
"spellright.documentTypes": ["yaml", "git-commit", "markdown"],
"editor.codeActionsOnSave": {
"source.organizeImports": true
},
"typescript.tsdk": "node_modules/typescript/lib",
"editor.tabSize": 2
}

View File

@ -50,6 +50,7 @@ jobs:
steps:
- install
- run: |
git config --global core.quotepath false
yarn test
yarn test-regressions

View File

@ -1,6 +1,6 @@
import chai from 'chai'
import Enzyme from 'enzyme'
import Adapter from 'enzyme-adapter-react-16'
import chai from 'chai'
import sinonChai from 'sinon-chai'
chai.use(sinonChai)

View File

@ -1,4 +1,13 @@
const fr = Cypress.env('language') === 'fr'
const testText = (selector, text) =>
cy.get(`[data-test-id=${selector}]`).should($span => {
const displayedText = $span
.text()
.trim()
.replace(/[\s]/g, ' ')
console.log(displayedText, text)
expect(displayedText).to.eq(text)
})
describe('Page covid-19', function() {
if (!fr) {
@ -11,12 +20,12 @@ describe('Page covid-19', function() {
it('should display 100% de prise en charge pour un SMIC', () => {
cy.get('input.currencyInput__input').click()
cy.contains('SMIC').click()
cy.contains('Soit 100% du revenu net')
cy.contains('Soit 0% du coût habituel')
testText('comparaison-net', 'Soit 100 % du revenu net')
testText('comparaison-total', 'Soit 0 % du coût habituel')
})
it('should display 85% de prise en charge pour un salaire médian', () => {
it('should display 85 % de prise en charge pour un salaire médian', () => {
cy.contains('salaire médian').click()
cy.contains('Soit 85% du revenu net')
cy.contains('Soit 0% du coût habituel')
testText('comparaison-net', 'Soit 85 % du revenu net')
testText('comparaison-total', 'Soit 0 % du coût habituel')
})
})

View File

@ -20,7 +20,7 @@ describe('Simulateurs', function() {
}
cy.get(inputSelector).each((testedInput, i) => {
cy.wrap(testedInput).type('{selectall}60000')
cy.wait(600)
cy.wait(800)
cy.contains('Cotisations')
cy.get(inputSelector).each(($input, j) => {
const val = $input.val().replace(/[\s,.]/g, '')
@ -41,7 +41,7 @@ describe('Simulateurs', function() {
if (['indépendant', 'assimilé-salarié'].includes(simulateur)) {
cy.get(chargeInputSelector).type('{selectall}6000')
}
cy.wait(600)
cy.wait(800)
cy.contains('€ / mois').click()
cy.get(inputSelector)
.first()
@ -108,7 +108,7 @@ describe('Simulateurs', function() {
cy.get(inputSelector)
.first()
.type('{selectall}5000')
cy.wait(600)
cy.wait(800)
cy.get(inputSelector).each($input => {
const val = +$input.val().replace(/[\s,.]/g, '')
expect(val).not.to.be.below(4000)

View File

@ -62,7 +62,7 @@
/>
<!-- data-helmet pour que React Helmet puisse écraser ce meta par défaut -->
<link
href="https://fonts.googleapis.com/css?family=Roboto:300,400,400i,500,600|Montserrat:400,600"
href="https://fonts.googleapis.com/css?family=Roboto:400,400i,600|Montserrat:400,600"
rel="stylesheet"
type="text/css"
/>

View File

@ -1,6 +1,8 @@
import { ThemeColorsProvider } from 'Components/utils/colors'
import { SitePathProvider, SitePaths } from 'Components/utils/withSitePaths'
import { EngineProvider } from 'Components/utils/EngineContext'
import { SitePathProvider, SitePaths } from 'Components/utils/SitePathsContext'
import { TrackerProvider } from 'Components/utils/withTracker'
import Engine from 'Engine'
import { createBrowserHistory } from 'history'
import { AvailableLangs } from 'i18n'
import i18next from 'i18next'
@ -12,6 +14,7 @@ import reducers, { RootState } from 'Reducers/rootReducer'
import { applyMiddleware, compose, createStore, Middleware, Store } from 'redux'
import thunk from 'redux-thunk'
import Tracker from 'Tracker'
import { Rules } from './rules'
import { inIframe } from './utils'
declare global {

View File

@ -1,4 +1,4 @@
import { SitePaths } from 'Components/utils/withSitePaths'
import { SitePaths } from 'Components/utils/SitePathsContext'
import { History } from 'history'
import { RootState, SimulationConfig } from 'Reducers/rootReducer'
import { ThunkAction } from 'redux-thunk'
@ -12,13 +12,12 @@ export type Action =
| UpdateAction
| SetSimulationConfigAction
| DeletePreviousSimulationAction
| SetExempleAction
| ExplainVariableAction
| UpdateSituationAction
| HideControlAction
| LoadPreviousSimulationAction
| SetSituationBranchAction
| UpdateDefaultUnitAction
| UpdateTargetUnitAction
| SetActiveTargetAction
| CompanyStatusAction
@ -46,18 +45,6 @@ type DeletePreviousSimulationAction = {
type: 'DELETE_PREVIOUS_SIMULATION'
}
type SetExempleAction =
| {
type: 'SET_EXAMPLE'
name: null
}
| {
type: 'SET_EXAMPLE'
name: string
situation: object
dottedName: DottedName
}
type ResetSimulationAction = ReturnType<typeof resetSimulation>
type UpdateAction = ReturnType<typeof updateSituation>
type UpdateSituationAction = ReturnType<typeof updateSituation>
@ -66,14 +53,14 @@ type SetSituationBranchAction = ReturnType<typeof setSituationBranch>
type SetActiveTargetAction = ReturnType<typeof setActiveTarget>
type HideControlAction = ReturnType<typeof hideControl>
type ExplainVariableAction = ReturnType<typeof explainVariable>
type UpdateDefaultUnitAction = ReturnType<typeof updateUnit>
type UpdateTargetUnitAction = ReturnType<typeof updateUnit>
export const resetSimulation = () =>
({
type: 'RESET_SIMULATION'
} as const)
export const goToQuestion = (question: string) =>
export const goToQuestion = (question: DottedName) =>
({
type: 'STEP_ACTION',
name: 'unfold',
@ -99,7 +86,7 @@ export const setSituationBranch = (id: number) =>
} as const)
export const setSimulationConfig = (
config: Object,
config: SimulationConfig,
useCompanyDetails: boolean = false
): ThunkResult<void> => (dispatch, getState, { history }): void => {
if (getState().simulation?.config === config) {
@ -134,26 +121,17 @@ export const updateSituation = (fieldName: DottedName, value: unknown) =>
value
} as const)
export const updateUnit = (defaultUnit: string) =>
export const updateUnit = (targetUnit: string) =>
({
type: 'UPDATE_DEFAULT_UNIT',
defaultUnit
type: 'UPDATE_TARGET_UNIT',
targetUnit
} as const)
export function setExample(
name: string,
situation: Situation,
dottedName: DottedName
) {
return { type: 'SET_EXAMPLE', name, situation, dottedName } as const
}
export const goBackToSimulation = (): ThunkResult<void> => (
dispatch,
_,
getState,
{ history }
) => {
dispatch({ type: 'SET_EXAMPLE', name: null })
const url = getState().simulation?.url
url && history.push(url)
}

View File

@ -1,7 +1,7 @@
import React from 'react'
import emoji from 'react-easy-emoji'
import { useSelector } from 'react-redux'
import { firstStepCompletedSelector } from 'Selectors/analyseSelectors'
import { firstStepCompletedSelector } from 'Selectors/simulationSelectors'
import Animate from 'Ui/animate'
import './Banner.css'

View File

@ -4,31 +4,34 @@ import emoji from 'react-easy-emoji'
import { animated, config, useSpring } from 'react-spring'
import useDisplayOnIntersecting from 'Components/utils/useDisplayOnIntersecting'
import { ThemeColorsContext } from 'Components/utils/colors'
import { formatValue } from 'Engine/format'
import { useTranslation } from 'react-i18next'
const ANIMATION_SPRING = config.gentle
let ChartItemBar = ({ styles, color, numberToPlot, unit }) => (
<div className="distribution-chart__bar-container">
<animated.div
className="distribution-chart__bar"
style={{
backgroundColor: color,
flex: styles.flex
}}
/>
<div
css={`
font-weight: bold;
margin-left: 1rem;
color: var(--textColorOnWhite);
`}
>
<Value maximumFractionDigits={0} unit={unit}>
{numberToPlot}
</Value>
let ChartItemBar = ({ styles, color, numberToPlot, unit }) => {
const language = useTranslation().i18n.language
return (
<div className="distribution-chart__bar-container">
<animated.div
className="distribution-chart__bar"
style={{
backgroundColor: color,
flex: styles.flex
}}
/>
<div
css={`
font-weight: bold;
margin-left: 1rem;
color: var(--textColorOnWhite);
`}
>
{formatValue({ nodeValue: numberToPlot, unit, precision: 0, language })}
</div>
</div>
</div>
)
)
}
let BranchIcône = ({ icône }) => (
<div className="distribution-chart__legend">
<span className="distribution-chart__icon">{emoji(icône)}</span>

View File

@ -1,24 +1,22 @@
import { goToQuestion, hideControl } from 'Actions/actions'
import { useControls, useInversionFail } from 'Components/utils/EngineContext'
import { makeJsx } from 'Engine/evaluation'
import React from 'react'
import emoji from 'react-easy-emoji'
import { useTranslation } from 'react-i18next'
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from 'Reducers/rootReducer'
import { analysisWithDefaultsSelector } from 'Selectors/analyseSelectors'
import animate from 'Ui/animate'
import './Controls.css'
import { Markdown } from './utils/markdown'
import { ScrollToElement } from './utils/Scroll'
import { answeredQuestionsSelector } from 'Selectors/simulationSelectors'
export default function Controls() {
const { t } = useTranslation()
const foldedSteps = useSelector(
(state: RootState) => state.simulation?.foldedSteps
)
const analysis = useSelector(analysisWithDefaultsSelector)
const controls = analysis?.controls
const inversionFail = analysis?.cache._meta.inversionFail
const answeredQuestions = useSelector(answeredQuestionsSelector)
const controls = useControls()
const inversionFail = useInversionFail()
const hiddenControls = useSelector(
(state: RootState) => state.simulation?.hiddenControls
)
@ -56,7 +54,7 @@ export default function Controls() {
<span id="controlExplanation">{makeJsx(evaluated)}</span>
)}
{solution && !foldedSteps?.includes(solution.cible) && (
{solution && !answeredQuestions?.includes(solution.cible) && (
<div>
<button
key={solution.cible}

View File

@ -47,12 +47,12 @@ describe('CurrencyInput', () => {
expect(input.instance().value).to.equal('0,5')
})
it('should not accept negative number', () => {
it('should accept negative number', () => {
let onChange = spy()
const input = getInput(<CurrencyInput onChange={onChange} />)
input.simulate('change', { target: { value: '-12', focus: () => {} } })
expect(onChange).to.have.been.calledWith(
match.hasNested('target.value', '12')
match.hasNested('target.value', '-12')
)
})

View File

@ -1,12 +1,12 @@
import classnames from 'classnames'
import { currencyFormat } from 'Engine/format'
import React, { useRef, useState } from 'react'
import React, { useMemo, useRef, useState } from 'react'
import NumberFormat, { NumberFormatProps } from 'react-number-format'
import { debounce } from '../../utils'
import './CurrencyInput.css'
type CurrencyInputProps = NumberFormatProps & {
value?: string | number
value?: string | number | null
debounce?: number
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void
currencySymbol?: string
@ -25,8 +25,12 @@ export default function CurrencyInput({
}: CurrencyInputProps) {
const [initialValue, setInitialValue] = useState(valueProp)
const [currentValue, setCurrentValue] = useState(valueProp)
const onChangeDebounced = useRef(
debounceTimeout && onChange ? debounce(debounceTimeout, onChange) : onChange
const onChangeDebounced = useMemo(
() =>
debounceTimeout && onChange
? debounce(debounceTimeout, onChange)
: onChange,
[onChange, debounceTimeout]
)
// We need some mutable reference because the <NumberFormat /> component doesn't provide
// the DOM `event` in its custom `onValueChange` handler
@ -54,7 +58,7 @@ export default function CurrencyInput({
value: nextValue.current
}
nextValue.current = ''
onChangeDebounced.current?.(event)
onChangeDebounced?.(event)
}
const {
@ -89,10 +93,10 @@ export default function CurrencyInput({
}
onValueChange={({ value }) => {
setCurrentValue(value)
nextValue.current = value.toString().replace('-', '')
nextValue.current = value.toString().replace(/^0+/, '')
}}
onChange={handleChange}
value={currentValue.toString().replace('.', decimalSeparator)}
value={currentValue?.toString().replace('.', decimalSeparator)}
autoComplete="off"
/>
{!isCurrencyPrefixed && <>&nbsp;</>}

View File

@ -1,35 +1,50 @@
import React from 'react'
import { ThemeColorsContext } from 'Components/utils/colors'
import { EngineContext } from 'Components/utils/EngineContext'
import useDisplayOnIntersecting from 'Components/utils/useDisplayOnIntersecting'
import { formatValue } from 'Engine/format'
import { add, max } from 'ramda'
import { default as React, default as React, useContext } from 'react'
import emoji from 'react-easy-emoji'
import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import { DottedName } from 'Rules'
import { parsedRulesSelector } from 'Selectors/analyseSelectors'
import répartitionSelector from 'Selectors/repartitionSelectors'
import { targetUnitSelector } from 'Selectors/simulationSelectors'
import BarChartBranch from './BarChart'
import './Distribution.css'
import './PaySlip'
import { getCotisationsBySection } from './PaySlip'
import RuleLink from './RuleLink'
import BarChartBranch from './BarChart'
export default function Distribution() {
const distribution = useSelector(répartitionSelector) as any
const targetUnit = useSelector(targetUnitSelector)
const engine = useContext(EngineContext)
const distribution = (getCotisationsBySection(
useContext(EngineContext).getParsedRules()
).map(([section, cotisations]) => [
section,
cotisations
.map(c => engine.evaluate(c, { unit: targetUnit }))
.reduce(
(acc, evaluation) => acc + ((evaluation?.nodeValue as number) || 0),
0
)
]) as Array<[DottedName, number]>)
.filter(([, value]) => value > 0)
.sort(([, a], [, b]) => b - a)
if (!Object.values(distribution).length) {
return null
}
const maximum = distribution.map(([, value]) => value).reduce(max, 0)
return (
<>
<div className="distribution-chart__container">
{distribution.répartition.map(
([brancheDottedName, { partPatronale, partSalariale }]) => (
<DistributionBranch
key={brancheDottedName}
dottedName={brancheDottedName}
value={partPatronale + partSalariale}
maximum={distribution.maximum}
/>
)
)}
</div>
</>
<div className="distribution-chart__container">
{distribution.map(([sectionName, value]) => (
<DistributionBranch
key={sectionName}
dottedName={sectionName}
value={value}
maximum={maximum}
/>
))}
</div>
)
}
@ -37,6 +52,7 @@ type DistributionBranchProps = {
dottedName: DottedName
value: number
maximum: number
icon?: string
}
@ -46,15 +62,16 @@ export function DistributionBranch({
icon,
maximum
}: DistributionBranchProps) {
const rules = useSelector(parsedRulesSelector)
const branch = rules[dottedName]
const rules = useContext(EngineContext).getParsedRules()
const branche = rules[dottedName]
return (
<BarChartBranch
value={value}
maximum={maximum}
title={<RuleLink {...branch} />}
icon={icon ?? branch.icons}
description={branch.summary}
title={<RuleLink {...branche} />}
icon={icon ?? branche.icons}
description={branche.summary}
unit="€"
/>
)

View File

@ -3,7 +3,6 @@
*/
.situationValue {
display: none;
text-decoration: underline white;
border-bottom-left-radius: 3px;
font-weight: 400;
@ -14,14 +13,6 @@
display: flex;
align-items: baseline;
}
#rule-rules .name > .situationValue {
border-bottom: 2px solid white;
padding-left: 0.4em;
}
#rule-rules.showValues .situationValue {
display: inline-block;
}
.node.inlineExpression:not(.comparison) {
padding-left: 0;
@ -42,10 +33,6 @@
text-align: right;
}
#rule-rules section {
margin: 1em 0;
}
#declenchement > ul {
padding: 0;
list-style-type: none;
@ -67,7 +54,7 @@
padding: 0.2em 0.8em;
display: inline-block;
color: white !important;
font-weight: 500;
font-weight: 600;
border-bottom-right-radius: 6px;
}
.inlineMecanism .name {
@ -122,7 +109,7 @@
}
.percentage .name {
font-weight: 500;
font-weight: 600;
}
.inlineExpression .operator {
@ -142,23 +129,15 @@
border: 1px solid;
max-width: 100%;
border-radius: 3px;
padding: 1em;
padding-top: 1em;
padding: 1rem;
position: relative;
flex: 1;
}
#rule-rules.showValues .mecanism {
padding-bottom: 2em;
}
.mecanism-result {
position: absolute;
display: none;
bottom: 0px;
right: 0;
}
#rule-rules.showValues .mecanism-result {
display: initial;
}
.mecanism .mecanism {
flex: initial;
}

View File

@ -1,11 +1,8 @@
import classNames from 'classnames'
import { makeJsx } from 'Engine/evaluation'
import { any, identity, path } from 'ramda'
import React from 'react'
import { Trans } from 'react-i18next'
import './Algorithm.css'
// The showValues prop is passed as a context. It used to be delt in CSS (not(.showValues) display: none), both coexist right now
import { ShowValuesProvider } from './ShowValuesContext'
let Conditions = ({
'rendu non applicable': disabledBy,
@ -54,33 +51,32 @@ function ShowIfDisabled({ dependency }) {
)
}
export default function Algorithm({ rule, showValues }) {
export default function Algorithm({ rule }) {
let formula =
rule['formule'] ||
rule.formule ||
(rule.category === 'variable' && rule.explanation.formule),
displayFormula =
formula &&
!!Object.keys(formula).length &&
!path(['formule', 'explanation', 'une possibilité'], rule) &&
formula.explanation?.category !== 'number'
!(formula.explanation.constant && rule.nodeValue)
return (
<div id="algorithm">
<section id="rule-rules" className={classNames({ showValues })}>
<ShowValuesProvider value={showValues}>
<Conditions {...rule} />
{displayFormula && (
<section id="formule">
<h2>
<Trans>Calcul</Trans>
</h2>
<div style={{ display: 'flex', justifyContent: 'center' }}>
{makeJsx(formula)}
</div>
</section>
)}
</ShowValuesProvider>
{makeJsx(rule['rendu non applicable'])}
</section>
</div>
<>
<Conditions {...rule} />
{displayFormula && (
<>
<h2>Comment cette donnée est-elle calculée ?</h2>
<div
className={
formula.explanation.constant || formula.explanation.operator
? 'mecanism'
: ''
}
>
{makeJsx(formula)}
</div>
</>
)}
</>
)
}

View File

@ -0,0 +1,58 @@
import classNames from 'classnames'
import React from 'react'
import { Trans } from 'react-i18next'
export default function Examples({ rule, setCurrentExample, currentExample }) {
let { examples } = rule
if (!examples) return null
return (
<>
<h2>
<Trans i18nKey="examples">Exemples</Trans>{' '}
</h2>
<ul>
{examples.map(ex => (
<Example
key={ex.nom}
{...{ ex, rule, currentExample, setCurrentExample }}
/>
))}
</ul>
{currentExample && (
<button
className="ui__ button small"
onClick={() => setCurrentExample(null)}
>
<Trans i18nKey="cancelExample">Enlever l'exemple</Trans>
</button>
)}
</>
)
}
let Example = ({
ex: { nom, situation },
rule,
currentExample,
setCurrentExample
}) => {
let selected = currentExample && currentExample.name == nom
return (
<li key={nom}>
<button
onClick={() =>
selected
? setCurrentExample(null)
: setCurrentExample(nom, situation, rule.dottedName)
}
className={classNames('ui__ button small', {
selected
})}
>
{nom}
</button>
</li>
)
}

View File

@ -1,18 +1,19 @@
import { ThemeColorsContext } from 'Components/utils/colors'
import { SitePathsContext } from 'Components/utils/withSitePaths'
import { SitePathsContext } from 'Components/utils/SitePathsContext'
import React, { useContext } from 'react'
import emoji from 'react-easy-emoji'
import { useSelector } from 'react-redux'
import { Link } from 'react-router-dom'
import { DottedName } from 'Rules'
import { parsedRulesSelector } from 'Selectors/analyseSelectors'
import { capitalise0 } from '../../utils'
import './Namespace.css'
import { EngineContext } from 'Components/utils/EngineContext'
export default function Namespace({ dottedName }: { dottedName: DottedName }) {
const sitePaths = useContext(SitePathsContext)
const colors = useContext(ThemeColorsContext)
const flatRules = useSelector(parsedRulesSelector)
const rules = useContext(EngineContext).getParsedRules()
return (
<ul id="namespace">
{dottedName
@ -27,7 +28,7 @@ export default function Namespace({ dottedName }: { dottedName: DottedName }) {
)
.map((fragments: string[]) => {
let ruleName = fragments.join(' . ') as DottedName,
rule = flatRules[ruleName]
rule = rules[ruleName]
if (!rule) {
throw new Error(
`Attention, il se peut que la règle ${ruleName}, ait été définie avec un namespace qui n'existe pas.`

View File

@ -1,12 +1,9 @@
.references {
font-size: 100%;
list-style: none;
padding-left: 1em;
color: #333350;
padding: 0;
}
.references a {
color: inherit;
width: 40%;
flex: 1;
text-decoration: underline;
}
@ -20,15 +17,6 @@
text-align: left;
font-style: italic;
}
.references .url {
font-weight: 600;
color: white;
background: #333350;
border-radius: 0.4em;
font-style: italic;
padding: 0.05em 0.6em;
font-size: 95%;
}
.references .imageWrapper {
width: 6rem;
height: 3rem;

View File

@ -23,16 +23,28 @@ function Ref({ name, link }: RefProps) {
refData = (refKey && references[refKey]) || {},
domain = cleanDomain(link)
return (
<li key={name}>
<li
css={`
display: flex;
align-items: baseline;
`}
key={name}
>
<span className="imageWrapper">
{refData.image && (
<img src={require('Images/références/' + refData.image)} />
)}
</span>
<a href={link} target="_blank">
<a
href={link}
target="_blank"
css={`
margin-right: 1rem;
`}
>
{capitalise0(name)}
</a>
<span className="url">{domain}</span>
<span className="ui__ label">{domain}</span>
</li>
)
}

View File

@ -0,0 +1,199 @@
import { ThemeColorsContext } from 'Components/utils/colors'
import { EngineContext, useEvaluation } from 'Components/utils/EngineContext'
import { SitePathsContext } from 'Components/utils/SitePathsContext'
import { formatValue } from 'Engine/format'
import mecanisms from 'Engine/mecanisms.yaml'
import { serializeUnit } from 'Engine/units'
import { filter, isEmpty } from 'ramda'
import React, { Suspense, useContext, useState } from 'react'
import emoji from 'react-easy-emoji'
import { Helmet } from 'react-helmet'
import { Trans, useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import Animate from 'Ui/animate'
import { AttachDictionary } from '../AttachDictionary'
import RuleLink from '../RuleLink'
import { Markdown } from '../utils/markdown'
import Algorithm from './Algorithm'
import Examples from './Examples'
import RuleHeader from './Header'
import References from './References'
import { UseDefaultValuesContext } from './UseDefaultValuesContext'
let LazySource = React.lazy(() => import('./RuleSource'))
export default AttachDictionary(mecanisms)(function Rule({ dottedName }) {
const [currentExample, setCurrentExample] = useState(null)
const rules = useContext(EngineContext).getParsedRules()
const useDefaultValues = useContext(UseDefaultValuesContext)
const rule = useEvaluation(dottedName, { useDefaultValues })
const [viewSource, setViewSource] = useState(false)
const { t, i18n } = useTranslation()
let { type, name, acronyme, title, description, question, icon } = rule,
namespaceRules = filter(
rule =>
rule.dottedName.startsWith(dottedName) &&
rule.dottedName.split(' . ').length ===
dottedName.split(' . ').length + 1,
rules
)
const renderReferences = ({ références: refs }) =>
refs ? (
<div>
<h2>
<Trans>Références</Trans>
</h2>
<References refs={refs} />
</div>
) : null
return (
<div id="rule">
<Animate.fromBottom>
<Helmet
title={title}
meta={[
{
name: 'description',
content: description
}
]}
/>
<RuleHeader
{...{
dottedName,
type,
description,
question,
flatRule: rule,
name,
acronyme,
title,
icon
}}
/>
{(rule.nodeValue || rule.defaultValue || rule.unit) && (
<>
<p
className="ui__ lead card light-bg"
css={`
display: inline-block;
`}
>
{rule.nodeValue != null && (
<>
{formatValue({ ...rule, language: i18n.language })}
<br />
</>
)}
{rule.defaultValue?.nodeValue != null && (
<>
<small>
Valeur par défaut :{' '}
{formatValue({
...rule.defaultValue,
language: i18n.language
})}
</small>
<br />
</>
)}
{rule.nodeValue == null && !rule.defaultValue?.unit && rule.unit && (
<>
<small>Unité : {serializeUnit(rule.unit)}</small>
</>
)}
</p>
</>
)}
<Algorithm rule={rule} />
{viewSource === dottedName ? (
<Suspense fallback={<div>Chargement du code source...</div>}>
<LazySource dottedName={dottedName} />
</Suspense>
) : (
<div
css={`
text-align: right;
margin-top: 1rem;
`}
>
<button
className="ui__ small simple button"
onClick={() => setViewSource(dottedName)}
>
{emoji('✍️')} {t('Afficher la description publicode')}
</button>
</div>
)}
{rule['rend non applicable'] && (
<>
<h3>
<Trans>Rend non applicable les règles suivantes</Trans> :{' '}
</h3>
<ul>
{rule['rend non applicable'].map(ruleName => (
<li key={ruleName}>
<RuleLink dottedName={ruleName} />
</li>
))}
</ul>
</>
)}
{rule.note && (
<>
<h3>Note</h3>
<div className="ui__ notice">
<Markdown source={rule.note} />
</div>
</>
)}
{renderReferences(rule)}
<Examples
currentExample={currentExample}
rule={rule}
setCurrentExample={setCurrentExample}
/>
{!isEmpty(namespaceRules) && (
<NamespaceRulesList {...{ namespaceRules }} />
)}
</Animate.fromBottom>
</div>
)
})
function NamespaceRulesList({ namespaceRules }) {
const colors = useContext(ThemeColorsContext)
const sitePaths = useContext(SitePathsContext)
const useDefaultValues = useContext(UseDefaultValuesContext)
return (
<section>
<h2>
<Trans>Pages associées</Trans>
</h2>
<ul>
{Object.values(namespaceRules).map(r => (
<li key={r.name}>
<Link
style={{
color: colors.textColorOnWhite,
textDecoration: 'underline'
}}
to={{
pathname: sitePaths.documentation.rule(r.dottedName),
state: { useDefaultValues }
}}
>
{r.title || r.name}
</Link>
</li>
))}
</ul>
</section>
)
}

View File

@ -1,7 +1,6 @@
import { ParsedRule } from 'Engine/types'
import { safeDump } from 'js-yaml'
import React from 'react'
import emoji from 'react-easy-emoji'
import rules from 'Rules'
import PublicodeHighlighter from '../ui/PublicodeHighlighter'
@ -11,13 +10,15 @@ export default function RuleSource({ dottedName }: RuleSourceProps) {
let source = rules[dottedName]
return (
<div id="RuleSource" className="ui__ container">
<h2>
{emoji('⚙️ ')}
Code source <br />
<code>{dottedName}</code>
</h2>
<section>
<h3>Source publicode</h3>
<PublicodeHighlighter source={safeDump({ [dottedName]: source })} />
</div>
<p className="ui__ notice">
Ci-dessus la règle d'origine, écrite en publicode. Publicode est un
langage déclaratif développé par beta.gouv.fr en partenariat avec
l'Acoss pour encoder les algorithmes d'intérêt public.{' '}
<a href="https://publi.codes">En savoir plus.</a>
</p>
</section>
)
}

View File

@ -1,12 +1,12 @@
import SearchBar from 'Components/SearchBar'
import React from 'react'
import React, { useContext } from 'react'
import { Trans } from 'react-i18next'
import { useSelector } from 'react-redux'
import { parsedRulesSelector } from 'Selectors/analyseSelectors'
import './RulesList.css'
import { EngineContext } from 'Components/utils/EngineContext'
export default function RulesList() {
const rules = useSelector(parsedRulesSelector)
const rules = useContext(EngineContext).getParsedRules()
return (
<div id="RulesList" className="ui__ container">
<h1>

View File

@ -0,0 +1,3 @@
import { createContext } from 'react'
export const UseDefaultValuesContext = createContext<boolean>(true)

View File

@ -0,0 +1,31 @@
import RulePage from 'Components/RulePage'
import { EngineProvider } from 'Components/utils/EngineContext'
import Engine from 'Engine'
import React from 'react'
import { Route, Switch } from 'react-router'
import { DottedName } from 'Rules'
import RulesList from './RulesList'
import { UseDefaultValuesContext } from './UseDefaultValuesContext'
type DocumentationProps<Names extends string> = {
basePath: string
engine: Engine<Names>
useDefaultValues?: boolean
}
export default function Documentation({
basePath,
engine,
useDefaultValues = false
}: DocumentationProps<DottedName>) {
return (
<EngineProvider value={engine}>
<UseDefaultValuesContext.Provider value={useDefaultValues}>
<Switch>
<Route exact path={basePath} component={RulesList} />
<Route path={basePath + '/:name+'} component={RulePage} />
</Switch>
</UseDefaultValuesContext.Provider>
</EngineProvider>
)
}

View File

@ -0,0 +1,65 @@
import Engine from 'Engine'
import { formatValue } from 'Engine/format'
import { EvaluatedNode } from 'Engine/types'
import React, { useContext } from 'react'
import { useTranslation } from 'react-i18next'
import { DottedName } from 'Rules'
import { coerceArray } from '../utils'
import RuleLink from './RuleLink'
import { EngineContext } from './utils/EngineContext'
export type ValueProps = {
expression: string
unit?: string
displayedUnit?: string
precision?: number
engine?: Engine<DottedName>
linkToRule?: boolean
} & React.HTMLProps<HTMLSpanElement>
export default function Value({
expression,
unit,
displayedUnit,
precision,
engine,
linkToRule = true,
...props
}: ValueProps) {
const { language } = useTranslation().i18n
if (expression === null) {
throw new TypeError('expression cannot be null')
}
const evaluation = (engine ?? useContext(EngineContext)).evaluate(
expression,
{ unit }
)
const nodeValue = evaluation.nodeValue
const value = formatValue({
nodeValue,
unit:
displayedUnit ?? (evaluation as EvaluatedNode<DottedName, number>).unit,
language,
precision
})
if ('dottedName' in evaluation && linkToRule) {
return (
<RuleLink {...evaluation}>
<span {...props}>{value}</span>
</RuleLink>
)
}
return <span {...props}>{value}</span>
}
type ConditionProps = {
expression: string | string[]
children: React.ReactNode
}
export function Condition({ expression, children }: ConditionProps) {
const engine = useContext(EngineContext)
if (!coerceArray(expression).every(expr => engine.evaluate(expr).nodeValue)) {
return null
}
return <>{children}</>
}

View File

@ -1,7 +0,0 @@
ul#mecanisms {
margin: 3em auto;
}
#mecanisms .warning {
color: #e74c3c;
}

View File

@ -3,7 +3,7 @@ import emoji from 'react-easy-emoji'
import { useTranslation } from 'react-i18next'
import { Link, useLocation } from 'react-router-dom'
import { icons } from './ui/SocialIcon'
import { SitePathsContext } from './utils/withSitePaths'
import { SitePathsContext } from './utils/SitePathsContext'
export default function MoreInfosOnUs() {
const { pathname } = useLocation()

View File

@ -31,6 +31,9 @@
background-color: transparent !important;
}
.payslip__container h5 {
margin: 0;
}
.payslip__container h5 {
margin: 0;
}
@ -41,6 +44,13 @@
text-transform: capitalize;
}
.payslip__container span {
display: flex;
align-items: flex-end;
font-family: 'Courier New', Courier, monospace;
justify-content: flex-end;
}
.payslip__cotisationsSection h4:not(:first-child) {
text-align: right;
}

View File

@ -1,28 +1,66 @@
import { ThemeColorsContext } from 'Components/utils/colors'
import Value from 'Components/Value'
import { getRuleFromAnalysis } from 'Engine/ruleUtils'
import { useEvaluation, EngineContext } from 'Components/utils/EngineContext'
import Value from 'Components/EngineValue'
import { formatValue } from 'Engine/format'
import React, { Fragment, useContext } from 'react'
import { Trans } from 'react-i18next'
import { useSelector } from 'react-redux'
import {
analysisWithDefaultsSelector,
parsedRulesSelector
} from 'Selectors/analyseSelectors'
import { analysisToCotisationsSelector } from 'Selectors/ficheDePaieSelectors'
import { DottedName } from 'Rules'
import './PaySlip.css'
import { Line, SalaireBrutSection, SalaireNetSection } from './PaySlipSections'
import RuleLink from './RuleLink'
export default function PaySlip() {
const { lightestColor } = useContext(ThemeColorsContext)
const cotisations = useSelector(analysisToCotisationsSelector)
const analysis = useSelector(analysisWithDefaultsSelector)
const parsedRules = useSelector(parsedRulesSelector)
let getRule = getRuleFromAnalysis(analysis)
export const SECTION_ORDER = [
'protection sociale . santé',
'protection sociale . accidents du travail et maladies professionnelles',
'protection sociale . retraite',
'protection sociale . famille',
'protection sociale . assurance chômage',
'protection sociale . formation',
'protection sociale . transport',
'protection sociale . autres'
] as const
const heuresSupplémentaires = getRule(
'contrat salarié . temps de travail . heures supplémentaires'
)
type Section = typeof SECTION_ORDER[number]
function getSection(rule): Section {
const section = ('protection sociale . ' +
(rule.cotisation?.branche ?? rule.taxe?.branche)) as Section
if (SECTION_ORDER.includes(section)) {
return section
}
return 'protection sociale . autres'
}
export function getCotisationsBySection(
parsedRules
): Array<[Section, DottedName[]]> {
const cotisations = [
...parsedRules['contrat salarié . cotisations . patronales'].formule
.explanation.explanation,
...parsedRules['contrat salarié . cotisations . salariales'].formule
.explanation.explanation
]
.map(cotisation => cotisation.dottedName)
.filter(Boolean)
.reduce((acc, cotisation) => {
const sectionName = getSection(parsedRules[cotisation])
return {
...acc,
[sectionName]: (acc[sectionName] ?? new Set()).add(cotisation)
}
}, {}) as Record<Section, Set<DottedName>>
return Object.entries(cotisations)
.map(([section, dottedNames]) => [section, [...dottedNames.values()]])
.sort(
([a], [b]) =>
SECTION_ORDER.indexOf(a as Section) -
SECTION_ORDER.indexOf(b as Section)
) as Array<[Section, DottedName[]]>
}
export default function PaySlip() {
const parsedRules = useContext(EngineContext).getParsedRules()
const cotisationsBySection = getCotisationsBySection(parsedRules)
return (
<div
@ -38,20 +76,18 @@ export default function PaySlip() {
>
<div className="payslip__salarySection">
<Line
rule={getRule('contrat salarié . temps de travail')}
unit="heures/mois"
maximumFractionDigits={1}
rule="contrat salarié . temps de travail"
displayedUnit="heures/mois"
precision={1}
/>
<Line
rule="contrat salarié . temps de travail . heures supplémentaires"
displayedUnit="heures/mois"
precision={1}
/>
{!!heuresSupplémentaires?.nodeValue && (
<Line
rule={heuresSupplémentaires}
unit="heures/mois"
maximumFractionDigits={1}
/>
)}
</div>
<SalaireBrutSection getRule={getRule} />
<SalaireBrutSection />
{/* Section cotisations */}
<div className="payslip__cotisationsSection">
<h4>
@ -63,34 +99,15 @@ export default function PaySlip() {
<h4>
<Trans>Part salarié</Trans>
</h4>
{cotisations.map(([brancheDottedName, cotisationList]) => {
let branche = parsedRules[brancheDottedName]
{cotisationsBySection.map(([sectionDottedName, cotisations]) => {
let section = parsedRules[sectionDottedName]
return (
<Fragment key={branche.dottedName}>
<Fragment key={section.dottedName}>
<h5 className="payslip__cotisationTitle">
<RuleLink {...branche} />
<RuleLink {...section} />
</h5>
{cotisationList.map(cotisation => (
<Fragment key={cotisation.dottedName}>
<RuleLink
style={{ backgroundColor: lightestColor }}
{...cotisation}
/>
<Value
nilValueSymbol="—"
unit="€"
customCSS="background-color: var(--lightestColor)"
>
{cotisation.montant.partPatronale}
</Value>
<Value
nilValueSymbol="—"
unit="€"
customCSS="background-color: var(--lightestColor)"
>
{cotisation.montant.partSalariale}
</Value>
</Fragment>
{cotisations.map(cotisation => (
<Cotisation key={cotisation} dottedName={cotisation} />
))}
</Fragment>
)
@ -101,23 +118,57 @@ export default function PaySlip() {
<Trans>Total des retenues</Trans>
</div>
<Value
nilValueSymbol="—"
{...getRule('contrat salarié . cotisations . patronales')}
unit="€"
expression="contrat salarié . cotisations . patronales"
displayedUnit="€"
className="payslip__total"
/>
<Value
nilValueSymbol="—"
{...getRule('contrat salarié . cotisations . salariales')}
unit="€"
expression="contrat salarié . cotisations . salariales"
displayedUnit="€"
className="payslip__total"
/>
{/* Salaire chargé */}
<Line rule={getRule('contrat salarié . rémunération . total')} />
<Line rule="contrat salarié . rémunération . total" />
<span />
</div>
{/* Section salaire net */}
<SalaireNetSection getRule={getRule} />
<SalaireNetSection />
</div>
)
}
function Cotisation({ dottedName }: { dottedName: DottedName }) {
const parsedRules = useContext(EngineContext).getParsedRules()
const partSalariale = useEvaluation(
'contrat salarié . cotisations . salariales'
)?.formule.explanation.explanation.find(
cotisation => cotisation.dottedName === dottedName
)
const partPatronale = useEvaluation(
'contrat salarié . cotisations . patronales'
)?.formule.explanation.explanation.find(
cotisation => cotisation.dottedName === dottedName
)
if (!partPatronale?.nodeValue && !partSalariale?.nodeValue) {
return null
}
return (
<>
<RuleLink
{...parsedRules[dottedName]}
style={{ backgroundColor: 'var(--lightestColor)' }}
/>
<span style={{ backgroundColor: 'var(--lightestColor)' }}>
{partPatronale?.nodeValue
? formatValue({ ...partPatronale, unit: '€' })
: ''}
</span>
<span style={{ backgroundColor: 'var(--lightestColor)' }}>
{partSalariale?.nodeValue
? formatValue({ ...partSalariale, unit: '€' })
: ''}
</span>
</>
)
}

View File

@ -1,125 +1,94 @@
import Value from 'Components/Value'
import { EvaluatedRule } from 'Engine/types'
import React from 'react'
import { EngineContext } from 'Components/utils/EngineContext'
import Value, { ValueProps, Condition } from 'Components/EngineValue'
import React, { useContext } from 'react'
import { Trans } from 'react-i18next'
import { useSelector } from 'react-redux'
import { DottedName } from 'Rules'
import { defaultUnitSelector } from 'Selectors/analyseSelectors'
import { coerceArray } from '../utils'
import RuleLink from './RuleLink'
export let SalaireBrutSection = ({
getRule
}: {
getRule: (rule: DottedName) => EvaluatedRule
}) => {
let avantagesEnNature = getRule(
'contrat salarié . rémunération . avantages en nature'
),
indemnitésSalarié = getRule('contrat salarié . CDD . indemnités salarié'),
remboursementDeFrais = getRule('contrat salarié . frais professionnels'),
heuresSupplémentaires = getRule(
'contrat salarié . rémunération . heures supplémentaires'
),
salaireDeBase = getRule('contrat salarié . rémunération . brut de base'),
rémunérationBrute = getRule('contrat salarié . rémunération . brut'),
chômagePartielIndemnité = getRule(
'contrat salarié . activité partielle . indemnités'
),
chômagePartielAbsence = getRule(
'contrat salarié . activité partielle . retrait absence'
),
primes = getRule('contrat salarié . rémunération . primes')
export let SalaireBrutSection = () => {
return (
<div className="payslip__salarySection">
<h4 className="payslip__salaryTitle">
<Trans>Salaire</Trans>
</h4>
<Line rule={salaireDeBase} />
{!!avantagesEnNature?.nodeValue && (
<Line
rule={getRule(
'contrat salarié . rémunération . avantages en nature . montant'
)}
/>
)}
{chômagePartielIndemnité?.nodeValue && (
<>
<Line rule={chômagePartielAbsence} />
<Line rule={chômagePartielIndemnité} />
</>
)}
{!!heuresSupplémentaires?.nodeValue && (
<Line rule={heuresSupplémentaires} />
)}
{!!primes?.nodeValue && <Line rule={primes} />}
{!!remboursementDeFrais?.nodeValue && (
<Line rule={remboursementDeFrais} />
)}
{!!indemnitésSalarié?.nodeValue && <Line rule={indemnitésSalarié} />}
{rémunérationBrute.nodeValue !== salaireDeBase.nodeValue && (
<Line rule={rémunérationBrute} />
)}
<Line rule="contrat salarié . rémunération . brut de base" />
<Line rule="contrat salarié . rémunération . avantages en nature . montant" />
<Line rule="contrat salarié . activité partielle . retrait absence" />
<Line rule="contrat salarié . activité partielle . indemnités" />
<Line rule="contrat salarié . rémunération . heures supplémentaires" />
<Line rule="contrat salarié . rémunération . heures complémentaires" />
<Line rule="contrat salarié . rémunération . primes" />
<Line rule="contrat salarié . frais professionnels" />
<Line rule="contrat salarié . CDD . indemnités salarié" />
<Condition expression="contrat salarié . rémunération . brut de base != contrat salarié . rémunération . brut">
<Line rule="contrat salarié . rémunération . brut" />
</Condition>
</div>
)
}
export let Line = ({ rule, className = '', ...props }) => {
const defaultUnit = useSelector(defaultUnitSelector)
return (
<>
<RuleLink {...rule} className={className} />
<Value
{...rule}
nilValueSymbol="—"
defaultUnit={defaultUnit}
unit="€"
className={className}
{...props}
/>
</>
)
}
export let SalaireNetSection = ({ getRule }) => {
let avantagesEnNature = getRule(
'contrat salarié . rémunération . avantages en nature . montant'
)
let impôt = getRule('impôt')
let netImposable = getRule('contrat salarié . rémunération . net imposable')
const retenueTitresRestaurant = getRule(
'contrat salarié . frais professionnels . titres-restaurant . montant'
)
export let SalaireNetSection = () => {
return (
<div className="payslip__salarySection">
<h4 className="payslip__salaryTitle">
<Trans>Salaire net</Trans>
</h4>
{netImposable && <Line rule={netImposable} />}
{(avantagesEnNature?.nodeValue || retenueTitresRestaurant?.nodeValue) && (
<Line
rule={getRule('contrat salarié . rémunération . net de cotisations')}
/>
)}
{!!avantagesEnNature?.nodeValue && (
<Line negative rule={avantagesEnNature} />
)}
{!!retenueTitresRestaurant?.nodeValue && (
<Line negative rule={retenueTitresRestaurant} />
)}
<Line rule="contrat salarié . rémunération . net imposable" />
<Condition
expression={[
'contrat salarié . rémunération . avantages en nature',
'contrat salarié . frais professionnels . titres-restaurant'
]}
>
<Line rule="contrat salarié . rémunération . net de cotisations" />
</Condition>
<Line
rule={getRule('contrat salarié . rémunération . net')}
negative
rule="contrat salarié . rémunération . avantages en nature . montant"
/>
<Line
negative
rule="contrat salarié . frais professionnels . titres-restaurant . montant"
/>
<Line
rule="contrat salarié . rémunération . net"
className="payslip__total"
/>
{!!impôt && (
<>
<Line negative rule={impôt} />
<Line
className="payslip__total"
rule={getRule('contrat salarié . rémunération . net après impôt')}
/>
</>
)}
<Condition expression="impôt">
<Line negative rule="impôt" unit="€/mois" />
<Line
className="payslip__total"
rule="contrat salarié . rémunération . net après impôt"
/>
</Condition>
</div>
)
}
type LineProps = {
rule: DottedName
negative?: boolean
} & Omit<ValueProps, 'expression'>
export function Line({
rule,
displayedUnit = '€',
negative = false,
className,
...props
}: LineProps) {
const parsedRules = useContext(EngineContext).getParsedRules()
return (
<Condition expression={rule}>
<RuleLink {...parsedRules[rule]} className={className} />
<Value
linkToRule={false}
expression={(negative ? '- ' : '') + rule}
displayedUnit={displayedUnit}
className={className}
{...props}
/>
</Condition>
)
}

View File

@ -1,5 +1,6 @@
import { formatValue } from 'Engine/format'
import React, { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { debounce as debounceFn } from '../utils'
import './PercentageField.css'
@ -9,6 +10,7 @@ export default function PercentageField({ onChange, value, debounce = 0 }) {
debounce ? debounceFn(debounce, onChange) : onChange,
[debounce, onChange]
)
const language = useTranslation().i18n.language
return (
<div>
@ -28,7 +30,8 @@ export default function PercentageField({ onChange, value, debounce = 0 }) {
/>
<span style={{ display: 'inline-block', width: '3em' }}>
{formatValue({
value: localValue,
nodeValue: localValue,
language,
unit: '%'
})}
</span>

View File

@ -3,13 +3,13 @@ import { parseUnit, serializeUnit } from 'Engine/units'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { useDispatch, useSelector } from 'react-redux'
import { defaultUnitSelector } from 'Selectors/analyseSelectors'
import { targetUnitSelector } from 'Selectors/simulationSelectors'
import './PeriodSwitch.css'
export default function PeriodSwitch() {
const dispatch = useDispatch()
const language = useTranslation().i18n.language
const currentUnit = useSelector(defaultUnitSelector)
const currentUnit = useSelector(targetUnitSelector)
let units = ['€/mois', '€/an']
return (

View File

@ -3,15 +3,15 @@ import React from 'react'
import { Trans } from 'react-i18next'
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from 'Reducers/rootReducer'
import { noUserInputSelector } from 'Selectors/analyseSelectors'
import { LinkButton } from 'Ui/Button'
import Banner from './Banner'
import { firstStepCompletedSelector } from 'Selectors/simulationSelectors'
export default function PreviousSimulationBanner() {
const previousSimulation = useSelector(
(state: RootState) => state.previousSimulation
)
const newSimulationStarted = !useSelector(noUserInputSelector)
const newSimulationStarted = useSelector(firstStepCompletedSelector)
const dispatch = useDispatch()
return (

View File

@ -5,20 +5,20 @@ import { Trans } from 'react-i18next'
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from 'Reducers/rootReducer'
import { DottedName } from 'Rules'
import { useNextQuestions } from './utils/useNextQuestion'
import {
currentQuestionSelector,
nextStepsSelector
} from 'Selectors/analyseSelectors'
answeredQuestionsSelector,
currentQuestionSelector
} from 'Selectors/simulationSelectors'
export default function QuickLinks() {
const currentQuestion = useSelector(currentQuestionSelector)
const nextSteps = useSelector(nextStepsSelector)
const nextSteps = useNextQuestions()
const quickLinks = useSelector(
(state: RootState) => state.simulation?.config.questions?.["à l'affiche"]
)
const quickLinksToHide = useSelector(
(state: RootState) => state.simulation?.foldedSteps || []
)
const quickLinksToHide = useSelector(answeredQuestionsSelector)
const dispatch = useDispatch()
if (!quickLinks) {

View File

@ -1,5 +1,5 @@
import { ThemeColorsContext } from 'Components/utils/colors'
import { SitePathsContext } from 'Components/utils/withSitePaths'
import { SitePathsContext } from 'Components/utils/SitePathsContext'
import { nameLeaf } from 'Engine/ruleUtils'
import { ParsedRule } from 'Engine/types'
import React, { useContext } from 'react'
@ -25,10 +25,14 @@ export default function RuleLink({
const sitePaths = useContext(SitePathsContext)
const { color } = useContext(ThemeColorsContext)
const newPath = sitePaths.documentation.rule(dottedName)
return (
<Link
to={newPath}
to={{
pathname: newPath,
state: {
useDefaultValues: true
}
}}
title={title}
style={{ color, ...style }}
className={className}

View File

@ -6,10 +6,3 @@
margin-bottom: 0.6rem;
justify-content: space-between;
}
#situationBranch {
color: white;
background: #333;
border-radius: 0.3em;
padding: 0.2em 0.6em;
}

View File

@ -1,34 +1,30 @@
import { goBackToSimulation } from 'Actions/actions'
import { ScrollToTop } from 'Components/utils/Scroll'
import { decodeRuleName } from 'Engine/ruleUtils'
import React from 'react'
import React, { useContext } from 'react'
import { Trans } from 'react-i18next'
import { connect, useSelector } from 'react-redux'
import { Redirect, useParams } from 'react-router-dom'
import { DottedName } from 'Rules'
import {
noUserInputSelector,
parsedRulesSelector,
situationBranchNameSelector
} from 'Selectors/analyseSelectors'
import Rule from './rule/Rule'
import Rule from './Documentation/Rule'
import './RulePage.css'
import SearchButton from './SearchButton'
import { EngineContext } from './utils/EngineContext'
import { firstStepCompletedSelector } from 'Selectors/simulationSelectors'
export default function RulePage() {
const parsedRules = useSelector(parsedRulesSelector)
const brancheName = useSelector(situationBranchNameSelector)
const valuesToShow = !useSelector(noUserInputSelector)
const parsedRules = useContext(EngineContext).getParsedRules()
const valuesToShow = useSelector(firstStepCompletedSelector)
const { name } = useParams()
const decodedRuleName = decodeRuleName(name ?? '')
const renderRule = (dottedName: DottedName) => {
return (
<div id="RulePage">
<ScrollToTop key={brancheName + dottedName} />
<ScrollToTop key={dottedName} />
<div className="rule-page__header">
{valuesToShow ? <BackToSimulation /> : <span />}
{brancheName && <span id="situationBranch">{brancheName}</span>}
<SearchButton />
</div>
<Rule dottedName={dottedName} />

View File

@ -2,29 +2,20 @@ import Distribution from 'Components/Distribution'
import PaySlip from 'Components/PaySlip'
import StackedBarChart from 'Components/StackedBarChart'
import { ThemeColorsContext } from 'Components/utils/colors'
import { getRuleFromAnalysis } from 'Engine/ruleUtils'
import { useEvaluation, useInversionFail } from 'Components/utils/EngineContext'
import React, { useContext, useRef } from 'react'
import emoji from 'react-easy-emoji'
import { Trans, useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import { RootState } from 'Reducers/rootReducer'
import {
analysisWithDefaultsSelector,
defaultUnitSelector
} from 'Selectors/analyseSelectors'
import * as Animate from 'Ui/animate'
import { answeredQuestionsSelector } from 'Selectors/simulationSelectors'
export default function SalaryExplanation() {
const showDistributionFirst = useSelector(
(state: RootState) => !state.simulation?.foldedSteps.length
)
const analysis = useSelector(analysisWithDefaultsSelector)
const inversionFail = analysis?.cache._meta.inversionFail
const showDistributionFirst = !useSelector(answeredQuestionsSelector).length
const distributionRef = useRef<HTMLDivElement>(null)
// We can't provide an explanation if the engine has failed to run the
// simulation.
if (inversionFail) {
if (useInversionFail()) {
return null
}
return (
@ -83,11 +74,16 @@ export default function SalaryExplanation() {
}
function RevenueRepatitionSection() {
const analysis = useSelector(analysisWithDefaultsSelector)
const getRule = getRuleFromAnalysis(analysis)
const { t } = useTranslation()
const { palettes } = useContext(ThemeColorsContext)
const data = useEvaluation(
[
'contrat salarié . rémunération . net après impôt',
'impôt',
'contrat salarié . cotisations'
],
{ unit: '€/mois' }
)
return (
<section>
<h2>
@ -96,18 +92,17 @@ function RevenueRepatitionSection() {
<StackedBarChart
data={[
{
...getRule('contrat salarié . rémunération . net après impôt'),
...data[0],
title: t('Revenu disponible'),
color: palettes[0][0]
},
{
...getRule('impôt'),
...data[1],
title: t('impôt'),
color: palettes[1][0]
},
{
...getRule('contrat salarié . cotisations'),
...data[2],
color: palettes[1][1]
}
]}
@ -117,15 +112,10 @@ function RevenueRepatitionSection() {
}
function PaySlipSection() {
const unit = useSelector(defaultUnitSelector)
return (
<section>
<h2>
{unit?.endsWith('mois') ? (
<Trans>Fiche de paie</Trans>
) : (
<Trans>Détail annuel des cotisations</Trans>
)}
<Trans>Fiche de paie</Trans>
</h2>
<PaySlip />
</section>

View File

@ -1,4 +1,4 @@
import { setSimulationConfig, setSituationBranch } from 'Actions/actions'
import { setSimulationConfig } from 'Actions/actions'
import {
defineDirectorStatus,
isAutoentrepreneur
@ -6,34 +6,24 @@ import {
import classnames from 'classnames'
import Conversation from 'Components/conversation/Conversation'
import SeeAnswersButton from 'Components/conversation/SeeAnswersButton'
import PeriodSwitch from 'Components/PeriodSwitch'
import ComparaisonConfig from 'Components/simulationConfigs/rémunération-dirigeant.yaml'
import { SitePathsContext } from 'Components/utils/withSitePaths'
import Value from 'Components/Value'
import { getRuleFromAnalysis } from 'Engine/ruleUtils'
import Value from 'Components/EngineValue'
import dirigeantComparaison from 'Components/simulationConfigs/rémunération-dirigeant.yaml'
import Engine from 'Engine'
import revenusSVG from 'Images/revenus.svg'
import { default as React, useCallback, useContext, useState } from 'react'
import {
default as React,
useCallback,
useContext,
useMemo,
useState
} from 'react'
import emoji from 'react-easy-emoji'
import { Trans } from 'react-i18next'
import { useDispatch, useSelector } from 'react-redux'
import { Link } from 'react-router-dom'
import { RootState } from 'Reducers/rootReducer'
import { DottedName } from 'Rules'
import {
analysisWithDefaultsSelector,
branchAnalyseSelector
} from 'Selectors/analyseSelectors'
import Animate from 'Ui/animate'
import { situationSelector } from 'Selectors/simulationSelectors'
import InfoBulle from 'Ui/InfoBulle'
import './SchemeComparaison.css'
let getBranchIndex = (branch: string) =>
({ assimilé: 0, indépendant: 1, 'auto-entrepreneur': 2 }[branch])
let getRuleFrom = analyses => (branch: string, dottedName: DottedName) => {
let i = getBranchIndex(branch)
return getRuleFromAnalysis(analyses[i])(dottedName)
}
import { EngineContext } from './utils/EngineContext'
type SchemeComparaisonProps = {
hideAutoEntrepreneur?: boolean
@ -45,27 +35,50 @@ export default function SchemeComparaison({
hideAssimiléSalarié = false
}: SchemeComparaisonProps) {
const dispatch = useDispatch()
dispatch(setSimulationConfig(ComparaisonConfig))
const analyses = useSelector(analysisWithDefaultsSelector)
const plafondAutoEntrepreneurDépassé = useSelector((state: RootState) =>
branchAnalyseSelector(state, {
situationBranchName: 'Auto-entrepreneur'
}).controls?.find(
dispatch(setSimulationConfig(dirigeantComparaison))
const plafondAutoEntrepreneurDépassé = useContext(EngineContext)
.controls()
.find(
({ test }) =>
test.includes && test.includes('base des cotisations > plafond')
)
)
let getRule = getRuleFrom(analyses)
const [showMore, setShowMore] = useState(false)
const [conversationStarted, setConversationStarted] = useState(
!!getRule('assimilé', 'revenu net après impôt')?.nodeValue
!!Object.keys(useSelector(situationSelector)).length
)
const startConversation = useCallback(() => setConversationStarted(true), [
setConversationStarted
])
const parsedRules = useContext(EngineContext).getParsedRules()
const situation = useSelector(situationSelector)
const displayResult =
useSelector(situationSelector)['entreprise . charges'] != undefined
const assimiléEngine = useMemo(
() =>
new Engine(parsedRules).setSituation({
...situation,
dirigeant: "'assimilé salarié'"
}),
[situation]
)
const autoEntrepreneurEngine = useMemo(
() =>
new Engine(parsedRules).setSituation({
...situation,
dirigeant: "'auto-entrepreneur'"
}),
[situation]
)
const indépendantEngine = useMemo(
() =>
new Engine(parsedRules).setSituation({
...situation,
dirigeant: "'indépendant'"
}),
[situation]
)
return (
<>
<div
@ -292,16 +305,6 @@ export default function SchemeComparaison({
</div>
</Trans>
)}
{conversationStarted && (
<>
<Trans i18nKey="comparaisonRégimes.période">
<h3 className="legend">Unité</h3>
</Trans>
<div className="AS-indep-et-auto" style={{ alignSelf: 'start' }}>
<PeriodSwitch />
</div>
</>
)}
<div className="all colored">
{!conversationStarted ? (
<>
@ -325,244 +328,185 @@ export default function SchemeComparaison({
</div>
)}
</div>
{conversationStarted &&
!!getRule('assimilé', 'revenu net après impôt')?.nodeValue && (
<>
<Trans i18nKey="comparaisonRégimes.revenuNetApresImpot">
<h3 className="legend">Revenu net après impôt</h3>
</Trans>
<div className="AS">
<Animate.appear className="ui__ plain card">
<RuleValueLink
branch="assimilé"
rule="revenu net après impôt"
/>
</Animate.appear>
</div>
<div className="indep">
<Animate.appear className="ui__ plain card">
<RuleValueLink
branch="indépendant"
rule="revenu net après impôt"
/>
</Animate.appear>
</div>
<div className="auto">
<Animate.appear
className={classnames(
'ui__ plain card',
plafondAutoEntrepreneurDépassé && 'disabled'
)}
>
{plafondAutoEntrepreneurDépassé ? (
'Plafond de CA dépassé'
) : (
<RuleValueLink
branch="auto-entrepreneur"
rule="revenu net après impôt"
/>
)}
</Animate.appear>
</div>
<Trans i18nKey="comparaisonRégimes.revenuNetAvantImpot">
<h3 className="legend">
Revenu net de cotisations <small>(avant impôts)</small>
</h3>
</Trans>
<div className="AS">
<RuleValueLink
branch="assimilé"
rule="revenus net de cotisations"
/>
</div>
<div className="indep">
<RuleValueLink
branch="indépendant"
rule="dirigeant . indépendant . revenu net de cotisations"
/>
</div>
<div className="auto">
{plafondAutoEntrepreneurDépassé ? (
'—'
) : (
<RuleValueLink
branch="auto-entrepreneur"
rule="dirigeant . auto-entrepreneur . net de cotisations"
/>
)}
</div>
{displayResult && (
<>
<Trans i18nKey="comparaisonRégimes.revenuNetAvantImpot">
<h3 className="legend">
<Trans i18nKey="comparaisonRégimes.retraiteEstimation.legend">
<span>Pension de retraite</span>
<small>(avant impôts)</small>
</Trans>
Revenu net de cotisations <small>(avant impôts)</small>
</h3>
<div className="AS">
<span>
<RuleValueLink
branch="assimilé"
rule="protection sociale . retraite"
</Trans>
<div className="AS">
<Value
linkToRule={false}
engine={assimiléEngine}
precision={0}
unit="€/an"
expression="contrat salarié . rémunération . net"
/>
</div>
<div className="indep">
<Value
linkToRule={false}
engine={indépendantEngine}
precision={0}
expression="dirigeant . indépendant . revenu net de cotisations"
/>
</div>
<div className="auto">
<>
{plafondAutoEntrepreneurDépassé && 'Plafond de CA dépassé'}
<Value
linkToRule={false}
engine={autoEntrepreneurEngine}
precision={0}
className={''}
unit="€/an"
expression="dirigeant . auto-entrepreneur . net de cotisations"
/>
</>
</div>
<h3 className="legend">
<Trans i18nKey="comparaisonRégimes.retraiteEstimation.legend">
<span>Pension de retraite</span>
<small>(avant impôts)</small>
</Trans>
</h3>
<div className="AS">
<Value
linkToRule={false}
engine={assimiléEngine}
precision={0}
expression="protection sociale . retraite"
/>{' '}
<InfoBulle>
<Trans i18nKey="comparaisonRégimes.retraiteEstimation.infobulles.AS">
Pension calculée pour 172 trimestres cotisés au régime général
sans variations de revenus.
</Trans>
</InfoBulle>
</div>
<div className="indep">
<Value
linkToRule={false}
engine={indépendantEngine}
precision={0}
expression="protection sociale . retraite"
/>{' '}
<InfoBulle>
<Trans i18nKey="comparaisonRégimes.retraiteEstimation.infobulles.indep">
Pension calculée pour 172 trimestres cotisés au régime des
indépendants sans variations de revenus.
</Trans>
</InfoBulle>
</div>
<div className="auto">
{plafondAutoEntrepreneurDépassé ? (
'—'
) : (
<>
<Value
linkToRule={false}
engine={autoEntrepreneurEngine}
precision={0}
expression="protection sociale . retraite"
/>{' '}
<InfoBulle>
<Trans i18nKey="comparaisonRégimes.retraiteEstimation.infobulles.AS">
Pension calculée pour 172 trimestres cotisés au régime
général sans variations de revenus.
<Trans i18nKey="comparaisonRégimes.retraiteEstimation.infobulles.auto">
Pension calculée pour 172 trimestres cotisés en
auto-entrepreneur sans variations de revenus.
</Trans>
</InfoBulle>
</span>
</div>
<div className="indep">
{getRule('indépendant', 'protection sociale . retraite')
.isApplicable !== false ? (
<span>
<RuleValueLink
branch="indépendant"
rule="protection sociale . retraite"
/>{' '}
<InfoBulle>
<Trans i18nKey="comparaisonRégimes.retraiteEstimation.infobulles.indep">
Pension calculée pour 172 trimestres cotisés au régime
des indépendants sans variations de revenus.
</Trans>
</InfoBulle>
</span>
) : (
<span className="ui__ notice">
<Trans>Pas implémenté</Trans>
</span>
)}
</div>
<div className="auto">
{plafondAutoEntrepreneurDépassé ? (
'—'
) : getRule(
'auto-entrepreneur',
'protection sociale . retraite'
).isApplicable !== false ? (
<span>
<RuleValueLink
branch="auto-entrepreneur"
rule="protection sociale . retraite"
/>{' '}
<InfoBulle>
<Trans i18nKey="comparaisonRégimes.retraiteEstimation.infobulles.auto">
Pension calculée pour 172 trimestres cotisés en
auto-entrepreneur sans variations de revenus.
</Trans>
</InfoBulle>
</span>
) : (
<span className="ui__ notice">
<Trans>Pas implémenté</Trans>
</span>
)}
</div>
<Trans i18nKey="comparaisonRégimes.trimestreValidés">
<h3 className="legend">
Nombre de trimestres validés <small>(pour la retraite)</small>
</h3>
</Trans>
<div className="AS">
<RuleValueLink
branch="assimilé"
rule="protection sociale . retraite . trimestres validés par an"
appendText={<Trans>trimestres</Trans>}
unit={null}
</>
)}
</div>
<Trans i18nKey="comparaisonRégimes.trimestreValidés">
<h3 className="legend">
Nombre de trimestres validés <small>(pour la retraite)</small>
</h3>
</Trans>
<div className="AS">
<Value
linkToRule={false}
engine={assimiléEngine}
precision={0}
displayedUnit="trimestre"
expression="protection sociale . retraite . trimestres validés"
/>
</div>
<div className="indep">
<Value
linkToRule={false}
engine={indépendantEngine}
precision={0}
expression="protection sociale . retraite . trimestres validés"
displayedUnit="trimestre"
/>
</div>
<div className="auto">
{plafondAutoEntrepreneurDépassé ? (
'—'
) : (
<Value
linkToRule={false}
engine={autoEntrepreneurEngine}
precision={0}
expression="protection sociale . retraite . trimestres validés"
displayedUnit="trimestres"
/>
</div>
<div className="indep">
<RuleValueLink
branch="indépendant"
rule="protection sociale . retraite . trimestres validés par an"
appendText={<Trans>trimestres</Trans>}
unit={null}
)}
</div>
<Trans i18nKey="comparaisonRégimes.indemnités">
<h3 className="legend">
Indemnités journalières <small>(en cas d'arrêt maladie)</small>
</h3>
</Trans>
<div className="AS">
<span>
<Value
linkToRule={false}
engine={assimiléEngine}
precision={0}
expression="protection sociale . santé . indemnités journalières"
/>
</div>
<div className="auto">
{plafondAutoEntrepreneurDépassé ? (
'—'
) : (
<RuleValueLink
branch="auto-entrepreneur"
rule="protection sociale . retraite . trimestres validés par an"
appendText={<Trans>trimestres</Trans>}
unit={null}
/>
)}
</div>
<Trans i18nKey="comparaisonRégimes.indemnités">
<h3 className="legend">
Indemnités journalières{' '}
<small>(en cas d'arrêt maladie)</small>
</h3>
</Trans>
<div className="AS">
</span>
<small>
(
<Value
linkToRule={false}
engine={assimiléEngine}
precision={0}
expression="protection sociale . accidents du travail et maladies professionnelles"
/>{' '}
<Trans>
pour les accidents de trajet/travail et maladie pro
</Trans>
)
</small>
</div>
<div className="indep">
<Value
linkToRule={false}
engine={indépendantEngine}
precision={0}
expression="protection sociale . santé . indemnités journalières"
/>
</div>
<div className="auto">
{plafondAutoEntrepreneurDépassé ? (
'—'
) : (
<span>
<RuleValueLink
branch="assimilé"
appendText={
<>
/ <Trans>jour</Trans>
</>
}
rule="protection sociale . santé . indemnités journalières"
<Value
linkToRule={false}
engine={autoEntrepreneurEngine}
precision={0}
expression="protection sociale . santé . indemnités journalières"
/>
</span>
<small>
(
<RuleValueLink
branch="assimilé"
rule="protection sociale . accidents du travail et maladies professionnelles"
/>{' '}
<Trans>
pour les accidents de trajet/travail et maladie pro
</Trans>
)
</small>
</div>
<div className="indep">
<span>
{getRule(
'indépendant',
'protection sociale . santé . indemnités journalières'
).isApplicable !== false ? (
<span>
<RuleValueLink
appendText={
<>
/ <Trans>jour</Trans>
</>
}
branch="indépendant"
rule="protection sociale . santé . indemnités journalières"
/>
</span>
) : (
<span className="ui__ notice">
<Trans>Pas implémenté</Trans>
</span>
)}
</span>
</div>
<div className="auto">
{plafondAutoEntrepreneurDépassé ? (
'—'
) : (
<span>
<RuleValueLink
branch="auto-entrepreneur"
rule="protection sociale . santé . indemnités journalières"
appendText={
<>
/ <Trans>jour</Trans>
</>
}
/>
</span>
)}
</div>
</>
)}
)}
</div>
</>
)}
</div>
<div className="ui__ container">
<br />
@ -622,35 +566,3 @@ export default function SchemeComparaison({
</>
)
}
type RuleValueLinkProps = {
branch: string
rule: DottedName
appendText?: React.ReactNode
unit?: null | string
}
function RuleValueLink({
branch,
rule: dottedName,
appendText,
unit
}: RuleValueLinkProps) {
const dispatch = useDispatch()
const analyses = useSelector(analysisWithDefaultsSelector)
const sitePaths = useContext(SitePathsContext)
let rule = getRuleFrom(analyses)(branch, dottedName)
return !rule ? null : (
<Link
onClick={() => dispatch(setSituationBranch(getBranchIndex(branch)))}
to={sitePaths.documentation.rule(rule.dottedName)}
>
<Value
maximumFractionDigits={0}
{...rule}
unit={unit != null ? unit : '€'}
/>
{appendText && <> {appendText}</>}
</Link>
)
}

View File

@ -1,4 +1,4 @@
import { SitePathsContext } from 'Components/utils/withSitePaths'
import { SitePathsContext } from 'Components/utils/SitePathsContext'
import { parentName } from 'Engine/ruleUtils'
import { ParsedRule, ParsedRules } from 'Engine/types'
import { pick, sortBy, take } from 'ramda'
@ -10,6 +10,7 @@ import { DottedName } from 'Rules'
import Worker from 'worker-loader!./SearchBar.worker.js'
import { capitalise0 } from '../utils'
import './SearchBar.css'
import { UseDefaultValuesContext } from './Documentation/UseDefaultValuesContext'
const worker = new Worker()
@ -34,15 +35,16 @@ export default function SearchBar({
let [focusElem, setFocusElem] = useState(-1)
const { i18n } = useTranslation()
const history = useHistory()
const useDefaultValues = useContext(UseDefaultValuesContext)
const handleKeyDown = e => {
if (e.key === 'Enter' && results.length > 0) {
finallyCallback && finallyCallback()
history.push(
sitePaths.documentation.rule(
history.push({
pathname: sitePaths.documentation.rule(
results[focusElem > 0 ? focusElem : 0].dottedName
)
)
),
state: { useDefaultValues }
})
}
if (
@ -191,7 +193,12 @@ export default function SearchBar({
return (
<>
{formattedResults.length === 0 && (
<Link to={sitePaths.documentation.rule(dottedName)}>
<Link
to={{
pathname: sitePaths.documentation.rule(dottedName),
state: { useDefaultValues }
}}
>
{title || capitalise0(name) || ''}
</Link>
)}
@ -202,7 +209,12 @@ export default function SearchBar({
return (
<Link
to={sitePaths.documentation.rule(dottedName)}
to={{
pathname: sitePaths.documentation.rule(dottedName),
state: {
useDefaultValues
}
}}
key={resultIndex}
>
<Highlighter text={formattedResult.formatted.title} />

View File

@ -1,9 +1,9 @@
import React, { useEffect, useState } from 'react'
import React, { useEffect, useState, useContext } from 'react'
import emoji from 'react-easy-emoji'
import { Trans } from 'react-i18next'
import { useSelector } from 'react-redux'
import { parsedRulesSelector } from 'Selectors/analyseSelectors'
import Overlay from './Overlay'
import { EngineContext } from 'Components/utils/EngineContext'
import SearchBar from './SearchBar'
type SearchButtonProps = {
@ -11,7 +11,7 @@ type SearchButtonProps = {
}
export default function SearchButton({ invisibleButton }: SearchButtonProps) {
const rules = useSelector(parsedRulesSelector)
const rules = useContext(EngineContext).getParsedRules()
const [visible, setVisible] = useState(false)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {

View File

@ -1,7 +1,7 @@
import Warning from 'Components/ui/WarningBlock'
import React from 'react'
import { Trans } from 'react-i18next'
import { SitePaths } from './utils/withSitePaths'
import { SitePaths } from './utils/SitePathsContext'
type SimulateurWarningProps = {
simulateur: Exclude<keyof SitePaths['simulateurs'], 'index'>

View File

@ -9,8 +9,8 @@ import TargetSelection from 'Components/TargetSelection'
import React from 'react'
import { Trans } from 'react-i18next'
import { useSelector } from 'react-redux'
import { firstStepCompletedSelector } from 'Selectors/analyseSelectors'
import { simulationProgressSelector } from 'Selectors/progressSelectors'
import { firstStepCompletedSelector } from 'Selectors/simulationSelectors'
import { useSimulationProgress } from 'Components/utils/useNextQuestion'
import * as Animate from 'Ui/animate'
import Progress from 'Ui/Progress'
@ -28,7 +28,7 @@ export default function Simulation({
showPeriodSwitch
}: SimulationProps) {
const firstStepCompleted = useSelector(firstStepCompletedSelector)
const progress = useSelector(simulationProgressSelector)
const progress = useSimulationProgress()
return (
<>
<TargetSelection showPeriodSwitch={showPeriodSwitch} />

View File

@ -62,7 +62,7 @@
#targetSelection .optionTitle {
font-size: 115%;
font-weight: 500;
font-weight: 600;
}
#targetSelection .optionTitle a {
color: inherit;
@ -169,7 +169,7 @@
font-style: italic;
color: #c0392b;
background: yellow;
font-weight: 500;
font-weight: 600;
}
/* Autre idée pour styler les checkboxes https://codepen.io/KenanYusuf/pen/PZKEKd */

View File

@ -3,11 +3,16 @@ import InputSuggestions from 'Components/conversation/InputSuggestions'
import PeriodSwitch from 'Components/PeriodSwitch'
import RuleLink from 'Components/RuleLink'
import { ThemeColorsContext } from 'Components/utils/colors'
import { SitePathsContext } from 'Components/utils/withSitePaths'
import { formatCurrency } from 'Engine/format'
import { ParsedRule } from 'Engine/types'
import { isEmpty, isNil } from 'ramda'
import React, { useContext, useEffect, useState } from 'react'
import {
EngineContext,
useEvaluation,
useInversionFail
} from 'Components/utils/EngineContext'
import { SitePathsContext } from 'Components/utils/SitePathsContext'
import { formatCurrency, formatValue } from 'Engine/format'
import { EvaluatedRule } 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'
@ -15,10 +20,9 @@ import { Link, useLocation } from 'react-router-dom'
import { RootState } from 'Reducers/rootReducer'
import { DottedName } from 'Rules'
import {
analysisWithDefaultsSelector,
situationSelector,
useTarget
} from 'Selectors/analyseSelectors'
targetUnitSelector
} from 'Selectors/simulationSelectors'
import Animate from 'Ui/animate'
import AnimatedTargetValue from 'Ui/AnimatedTargetValue'
import CurrencyInput from './CurrencyInput/CurrencyInput'
@ -26,48 +30,12 @@ import './TargetSelection.css'
export default function TargetSelection({ showPeriodSwitch = true }) {
const [initialRender, setInitialRender] = useState(true)
const analysis = useSelector(analysisWithDefaultsSelector)
const objectifs = useSelector(
(state: RootState) => state.simulation?.config.objectifs || []
)
const secondaryObjectives = useSelector(
(state: RootState) =>
state.simulation?.config['objectifs secondaires'] || []
)
const situation = useSelector(situationSelector)
const dispatch = useDispatch()
const colors = useContext(ThemeColorsContext)
const targets =
analysis?.targets.filter(
t =>
!secondaryObjectives.includes(t.dottedName) &&
t.dottedName !== 'contrat salarié . aides employeur'
) || []
useEffect(() => {
// Initialize defaultValue for target that can't be computed
// TODO: this logic shouldn't be here
targets
.filter(
target =>
(!target.formule || isEmpty(target.formule)) &&
(!isNil(target.defaultValue) ||
!isNil(target.explanation?.defaultValue)) &&
!situation[target.dottedName]
)
.forEach(target => {
dispatch(
updateSituation(
target.dottedName,
!isNil(target.defaultValue)
? target.defaultValue
: target.explanation?.defaultValue
)
)
})
setInitialRender(false)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
@ -77,7 +45,7 @@ export default function TargetSelection({ showPeriodSwitch = true }) {
{((typeof objectifs[0] === 'string'
? [{ objectifs }]
: objectifs) as any).map(
({ icône, objectifs: groupTargets, nom }, index) => (
({ icône, objectifs: targets, nom }, index) => (
<React.Fragment key={nom || '0'}>
<div style={{ display: 'flex', alignItems: 'end' }}>
<div style={{ flex: 1 }}>
@ -101,14 +69,16 @@ export default function TargetSelection({ showPeriodSwitch = true }) {
)`
}}
>
<Targets
{...{
targets: targets.filter(({ dottedName }) =>
groupTargets.includes(dottedName)
),
initialRender
}}
/>
<ul className="targets">
{' '}
{targets.map(target => (
<Target
key={target}
dottedName={target}
initialRender={initialRender}
/>
))}
</ul>
</section>
</React.Fragment>
)
@ -117,36 +87,25 @@ export default function TargetSelection({ showPeriodSwitch = true }) {
)
}
let Targets = ({ targets, initialRender }) => (
<div>
<ul className="targets">
{targets
.map(target => target.explanation || target)
.filter(target => {
return (
target.isApplicable !== false &&
(target.question || target.nodeValue)
)
})
.map(target => (
<Target
key={target.dottedName}
initialRender={initialRender}
{...{
target
}}
/>
))}
</ul>
</div>
)
const Target = ({ target, initialRender }) => {
type TargetProps = {
dottedName: DottedName
initialRender: boolean
}
const Target = ({ dottedName, initialRender }: TargetProps) => {
const activeInput = useSelector((state: RootState) => state.activeTargetInput)
const dispatch = useDispatch()
const isActiveInput = activeInput === target.dottedName
const target = useEvaluation(dottedName, {
unit: useSelector(targetUnitSelector)
})
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}
@ -186,7 +145,7 @@ const Target = ({ target, initialRender }) => {
onFirstClick={value => {
dispatch(updateSituation(target.dottedName, value))
}}
unit={target.defaultUnit}
unit={target.unit}
/>
</div>
</Animate.fromTop>
@ -209,7 +168,12 @@ let Header = ({ target }) => {
<span className="header">
<span className="texts">
<span className="optionTitle">
<Link to={sitePaths.documentation.rule(target.dottedName)}>
<Link
to={{
pathname: sitePaths.documentation.rule(target.dottedName),
state: { useDefaultValues: true }
}}
>
{target.title || target.name}
{hackyShowPeriod && ' ' + t('mensuel')}
</Link>
@ -221,7 +185,7 @@ let Header = ({ target }) => {
}
type TargetInputOrValueProps = {
target: ParsedRule<DottedName>
target: EvaluatedRule<DottedName>
isActiveInput: boolean
isSmallTarget: boolean
}
@ -235,16 +199,29 @@ function TargetInputOrValue({
const colors = useContext(ThemeColorsContext)
const dispatch = useDispatch()
const situationValue = useSelector(situationSelector)[target.dottedName]
const targetWithValue = useTarget(target.dottedName)
const inversionFail = useSelector(analysisWithDefaultsSelector)?.cache._meta
.inversionFail
const targetUnit = useSelector(targetUnitSelector)
const engine = useContext(EngineContext)
const value =
targetWithValue?.nodeValue != null && !inversionFail
? Math.round(targetWithValue.nodeValue)
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 = inversionFail && !isActiveInput
const blurValue = useInversionFail() && !isActiveInput
const onChange = useCallback(
evt =>
dispatch(
updateSituation(target.dottedName, +evt.target.value + ' ' + targetUnit)
),
[targetUnit, target, dispatch]
)
return (
<span
className="targetInputOrValue"
@ -260,15 +237,15 @@ function TargetInputOrValue({
}}
debounce={600}
name={target.dottedName}
value={situationValue ? Math.round(situationValue) : value}
value={value}
className={
isActiveInput || isNil(value) ? 'targetInput' : 'editableTarget'
}
onChange={evt =>
dispatch(
updateSituation(target.dottedName, Number(evt.target.value))
)
isActiveInput ||
isNil(value) ||
(target.question && isSmallTarget)
? 'targetInput'
: 'editableTarget'
}
onChange={onChange}
onFocus={() => {
if (isSmallTarget) return
dispatch(setActiveTarget(target.dottedName))
@ -292,8 +269,10 @@ function TargetInputOrValue({
)
}
function TitreRestaurant() {
const titresRestaurant = useTarget(
'contrat salarié . frais professionnels . titres-restaurant . montant'
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
@ -303,7 +282,11 @@ function TitreRestaurant() {
<RuleLink {...titresRestaurant}>
+{' '}
<strong>
{formatCurrency(titresRestaurant.nodeValue, language)}
{formatValue({
nodeValue: titresRestaurant.nodeValue,
unit: '€',
language
})}
</strong>{' '}
<Trans>en titres-restaurant</Trans> {emoji(' 🍽')}
</RuleLink>
@ -312,16 +295,18 @@ function TitreRestaurant() {
)
}
function AidesGlimpse() {
const aides = useTarget('contrat salarié . aides employeur')
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 aidesNode = aides?.explanation
const aidesDetail = aides?.explanation.formule.explanation.explanation
const aidesDetail = aides?.formule.explanation.explanation
const aidesNotNul = aidesDetail?.filter(node => node.nodeValue !== false)
const aideLink = aidesNotNul?.length === 1 ? aidesNotNul[0] : aidesNode
const aideLink = aidesNotNul?.length === 1 ? aidesNotNul[0] : aides
if (!aides?.nodeValue) return null
return (
@ -330,11 +315,15 @@ function AidesGlimpse() {
<RuleLink {...aideLink}>
<Trans>en incluant</Trans>{' '}
<strong>
<AnimatedTargetValue value={aides.nodeValue}>
<span>{formatCurrency(aides.nodeValue, language)}</span>
</AnimatedTargetValue>
<span>
{formatValue({
nodeValue: aides.nodeValue,
unit: '€',
language
})}
</span>
</strong>{' '}
<Trans>d'aides</Trans> {emoji(aides.explanation?.icons ?? '')}
<Trans>d'aides</Trans> {emoji(aides?.icons ?? '')}
</RuleLink>
</div>
</Animate.fromTop>

View File

@ -1,23 +0,0 @@
#targets {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
#targets > .icon {
margin: 0 0.6em;
font-size: 200%;
color: var(--color);
}
#targets .value {
font-size: 180%;
}
#targets .unit {
}
#targets .explanation {
font-size: 150%;
text-decoration: none;
line-height: 0;
}

View File

@ -1,34 +0,0 @@
import { ThemeColorsContext } from 'Components/utils/colors'
import { SitePathsContext } from 'Components/utils/withSitePaths'
import React, { useContext } from 'react'
import emoji from 'react-easy-emoji'
import { useSelector } from 'react-redux'
import { Link } from 'react-router-dom'
import { analysisWithDefaultsSelector } from 'Selectors/analyseSelectors'
import './Targets.css'
export default function Targets() {
const colors = useContext(ThemeColorsContext)
const sitePaths = useContext(SitePathsContext)
const analysis = useSelector(analysisWithDefaultsSelector)
let { nodeValue, unité: unit, dottedName } = analysis.targets[0]
return (
<div id="targets">
<span className="icon"></span>
<span className="content" style={{ color: colors.textColor }}>
<span className="figure">
<span className="value">{nodeValue?.toFixed(1)}</span>{' '}
<span className="unit">{unit}</span>
</span>
<Link
title="Quel est calcul ?"
style={{ color: colors.color }}
to={sitePaths.documentation.rule(dottedName)}
className="explanation"
>
{emoji('📖')}
</Link>
</span>
</div>
)
}

View File

@ -1,85 +0,0 @@
import classnames from 'classnames'
import { formatValue, formatValueOptions } from 'Engine/format'
import { EvaluatedRule } from 'Engine/types'
import { Unit } from 'Engine/units'
import React from 'react'
import { Trans, useTranslation } from 'react-i18next'
// let booleanTranslations = { true: '✅', false: '❌' }
let booleanTranslations = {
fr: { true: 'Oui', false: 'Non' },
en: { true: 'Yes', false: 'No' }
}
let style = customStyle => `
font-family: 'Courier New', Courier, monospace;
${customStyle}
`
export type ValueProps = Partial<
Pick<EvaluatedRule, 'nodeValue'> &
Pick<
formatValueOptions,
'maximumFractionDigits' | 'minimumFractionDigits'
> & {
nilValueSymbol: string
children: number
negative: boolean
unit: string | Unit
customCSS: string
className?: string
}
>
export default function Value({
nodeValue: value,
unit,
nilValueSymbol,
maximumFractionDigits,
minimumFractionDigits,
children,
className,
negative,
customCSS = ''
}: ValueProps) {
const { language } = useTranslation().i18n
/* Either an entire rule object is passed, or just the right attributes and the value as a JSX child*/
let nodeValue = value === undefined ? children : value
if (
(nilValueSymbol !== undefined && nodeValue === 0) ||
(nodeValue && Number.isNaN(nodeValue)) ||
nodeValue === null
)
return (
<span css={style(customCSS)} className={classnames('value', className)}>
-
</span>
)
let valueType = typeof nodeValue,
formattedValue =
valueType === 'string' ? (
<Trans>{nodeValue}</Trans>
) : valueType === 'object' ? (
(nodeValue as any).nom
) : valueType === 'boolean' ? (
booleanTranslations[language][nodeValue]
) : nodeValue !== undefined ? (
formatValue({
minimumFractionDigits,
maximumFractionDigits,
language,
unit,
value: nodeValue
})
) : null
return nodeValue == undefined ? null : (
<span css={style(customCSS)} className={classnames('value', className)}>
{negative ? '-' : ''}
{formattedValue}
</span>
)
}

View File

@ -1,16 +1,16 @@
import { explainVariable } from 'Actions/actions'
import Overlay from 'Components/Overlay'
import { Markdown } from 'Components/utils/markdown'
import React from 'react'
import React, { useContext } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from 'Reducers/rootReducer'
import { parsedRulesSelector } from 'Selectors/analyseSelectors'
import References from '../rule/References'
import References from '../Documentation/References'
import './Aide.css'
import { EngineContext } from 'Components/utils/EngineContext'
export default function Aide() {
const explained = useSelector((state: RootState) => state.explainedVariable)
const rules = useSelector(parsedRulesSelector)
const rules = useContext(EngineContext).getParsedRules()
const dispatch = useDispatch()
const stopExplaining = () => dispatch(explainVariable())
@ -29,9 +29,7 @@ export default function Aide() {
`}
>
<h2>{rule.title}</h2>
<p>
<Markdown source={text} />
</p>
<Markdown source={text} />
{refs && (
<div>
<References refs={refs} />

View File

@ -1,18 +1,14 @@
import { goToQuestion, resetSimulation } from 'Actions/actions'
import Overlay from 'Components/Overlay'
import Value from 'Components/Value'
import { getRuleFromAnalysis } from 'Engine/ruleUtils'
import { useEvaluation } from 'Components/utils/EngineContext'
import { useNextQuestions } from 'Components/utils/useNextQuestion'
import { formatValue } from 'Engine/format'
import React from 'react'
import emoji from 'react-easy-emoji'
import { Trans } from 'react-i18next'
import { Trans, useTranslation } from 'react-i18next'
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from 'Reducers/rootReducer'
import { createSelector } from 'reselect'
import {
analysisWithDefaultsSelector,
nextStepsSelector
} from 'Selectors/analyseSelectors'
import { softCatch } from '../../utils'
import { DottedName } from 'Rules'
import { answeredQuestionsSelector } from 'Selectors/simulationSelectors'
import './AnswerList.css'
type AnswerListProps = {
@ -21,46 +17,60 @@ type AnswerListProps = {
export default function AnswerList({ onClose }: AnswerListProps) {
const dispatch = useDispatch()
const { folded, next } = useSelector(stepsToRules)
const answeredQuestions = useSelector(answeredQuestionsSelector)
const nextSteps = useNextQuestions()
return (
<Overlay onClose={onClose} className="answer-list">
<h2>
{emoji('📋 ')}
<Trans>Mes réponses</Trans>
<small css="margin-left: 2em; img {font-size: .8em}">
{emoji('🗑')}{' '}
<button
className="ui__ simple small button"
onClick={() => {
dispatch(resetSimulation())
onClose()
}}
>
<Trans>Tout effacer</Trans>
</button>
</small>
</h2>
<StepsTable {...{ rules: folded, onClose }} />
{next.length > 0 && (
{!!answeredQuestions.length && (
<>
<h2>
{emoji('📋 ')}
<Trans>Mes réponses</Trans>
<small css="margin-left: 2em; img {font-size: .8em}">
{emoji('🗑')}{' '}
<button
className="ui__ simple small button"
onClick={() => {
dispatch(resetSimulation())
onClose()
}}
>
<Trans>Tout effacer</Trans>
</button>
</small>
</h2>
<StepsTable {...{ rules: answeredQuestions, onClose }} />
</>
)}
{!!nextSteps.length && (
<>
<h2>
{emoji('🔮 ')}
<Trans>Prochaines questions</Trans>
</h2>
<StepsTable {...{ rules: next, onClose }} />
<StepsTable {...{ rules: nextSteps, onClose }} />
</>
)}
</Overlay>
)
}
function StepsTable({ rules, onClose }) {
function StepsTable({
rules,
onClose
}: {
rules: Array<DottedName>
onClose: () => void
}) {
const dispatch = useDispatch()
const evaluatedRules = useEvaluation(rules)
const language = useTranslation().i18n.language
return (
<table>
<tbody>
{rules
.filter(rule => rule.nodeValue !== undefined)
{evaluatedRules
.filter(rule => rule.isApplicable !== false)
.map(rule => (
<tr
key={rule.dottedName}
@ -89,7 +99,7 @@ function StepsTable({ rules, onClose }) {
font-size: inherit;
width: 100%;
text-align: start;
font-weight: 500;
font-weight: 600;
> span {
border-bottom-color: var(--textColorOnWhite);
padding: 0.05em 0em;
@ -98,7 +108,7 @@ function StepsTable({ rules, onClose }) {
`}
>
<span className="answerContent">
<Value {...rule} />
{formatValue({ ...rule, language })}
</span>
</span>{' '}
</td>
@ -108,17 +118,3 @@ function StepsTable({ rules, onClose }) {
</table>
)
}
const stepsToRules = createSelector(
(state: RootState) => state.simulation?.foldedSteps || [],
nextStepsSelector,
analysisWithDefaultsSelector,
(folded, nextSteps, analysis) => ({
folded: folded
.map(softCatch(getRuleFromAnalysis(analysis)))
.filter(Boolean),
next: nextSteps
.map(softCatch(getRuleFromAnalysis(analysis)))
.filter(Boolean)
})
)

View File

@ -1,20 +1,22 @@
import { goToQuestion, validateStepWithValue } from 'Actions/actions'
import QuickLinks from 'Components/QuickLinks'
import RuleInput from 'Engine/RuleInput'
import React from 'react'
import React, { useContext, useEffect } from 'react'
import emoji from 'react-easy-emoji'
import { Trans } from 'react-i18next'
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from 'Reducers/rootReducer'
import {
currentQuestionSelector,
nextStepsSelector,
parsedRulesSelector
} from 'Selectors/analyseSelectors'
import * as Animate from 'Ui/animate'
import Aide from './Aide'
import './conversation.css'
import FormDecorator from './FormDecorator'
import { useNextQuestions } from 'Components/utils/useNextQuestion'
import { EngineContext } from 'Components/utils/EngineContext'
import PreviousAnswers from 'sites/mon-entreprise.fr/pages/Créer/GuideStatut/PreviousAnswers'
import {
answeredQuestionsSelector,
currentQuestionSelector
} from 'Selectors/simulationSelectors'
export type ConversationProps = {
customEndMessages?: React.ReactNode
@ -22,18 +24,15 @@ export type ConversationProps = {
export default function Conversation({ customEndMessages }: ConversationProps) {
const dispatch = useDispatch()
const rules = useSelector(parsedRulesSelector)
const currentQuestion = useSelector(currentQuestionSelector)
const previousAnswers = useSelector(
(state: RootState) => state.simulation?.foldedSteps || []
)
const nextSteps = useSelector(nextStepsSelector)
const rules = useContext(EngineContext).getParsedRules()
const currentQuestion = useNextQuestions()[0]
const previousAnswers = useSelector(answeredQuestionsSelector)
const setDefault = () =>
dispatch(
validateStepWithValue(
currentQuestion,
rules[currentQuestion].defaultValue
rules[currentQuestion]['par défaut']
)
)
const goToPrevious = () =>
@ -45,35 +44,31 @@ export default function Conversation({ customEndMessages }: ConversationProps) {
}
const DecoratedInputComponent = FormDecorator(RuleInput)
return rules && nextSteps.length ? (
return currentQuestion ? (
<>
<Aide />
<div tabIndex={0} style={{ outline: 'none' }} onKeyDown={handleKeyDown}>
{currentQuestion && (
<React.Fragment key={currentQuestion}>
<Animate.fadeIn>
<DecoratedInputComponent dottedName={currentQuestion} />
</Animate.fadeIn>
<div className="ui__ answer-group">
{previousAnswers.length > 0 && (
<>
<button
onClick={goToPrevious}
className="ui__ simple small push-left button"
>
<Trans>Précédent</Trans>
</button>
</>
)}
<Animate.fadeIn>
<DecoratedInputComponent dottedName={currentQuestion} />
</Animate.fadeIn>
<div className="ui__ answer-group">
{previousAnswers.length > 0 && (
<>
<button
onClick={setDefault}
className="ui__ simple small push-right button"
onClick={goToPrevious}
className="ui__ simple small push-left button"
>
<Trans>Passer</Trans>
<Trans>Précédent</Trans>
</button>
</div>
</React.Fragment>
)}
</>
)}
<button
onClick={setDefault}
className="ui__ simple small push-right button"
>
<Trans>Passer</Trans>
</button>
</div>
</div>
<QuickLinks />
</>

View File

@ -4,15 +4,15 @@ import emoji from 'react-easy-emoji'
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from 'Reducers/rootReducer'
import { DottedName } from 'Rules'
import { parsedRulesSelector } from 'Selectors/analyseSelectors'
import { TrackerContext } from '../utils/withTracker'
import './Explicable.css'
import { EngineContext } from 'Components/utils/EngineContext'
export default function Explicable({ dottedName }: { dottedName: DottedName }) {
const rules = useContext(EngineContext).getParsedRules()
const tracker = useContext(TrackerContext)
const dispatch = useDispatch()
const explained = useSelector((state: RootState) => state.explainedVariable)
const rules = useSelector(parsedRulesSelector)
// Rien à expliquer ici, ce n'est pas une règle
if (dottedName == null) return null

View File

@ -1,12 +1,9 @@
import { updateSituation } from 'Actions/actions'
import { updateSituation, goToQuestion } from 'Actions/actions'
import Explicable from 'Components/conversation/Explicable'
import React from 'react'
import { useTranslation } from 'react-i18next'
import React, { useContext } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import {
parsedRulesSelector,
situationSelector
} from 'Selectors/analyseSelectors'
import { situationSelector } from 'Selectors/simulationSelectors'
import { EngineContext } from 'Components/utils/EngineContext'
/*
This higher order component wraps "Form" components (e.g. Question.js), that represent user inputs,
@ -20,9 +17,8 @@ export default function FormDecorator(RenderField) {
return function FormStep({ dottedName }) {
const dispatch = useDispatch()
const situation = useSelector(situationSelector)
const rules = useSelector(parsedRulesSelector)
const rules = useContext(EngineContext).getParsedRules()
const language = useTranslation().i18n.language
const submit = source =>
dispatch({
type: 'STEP_ACTION',
@ -31,6 +27,7 @@ export default function FormDecorator(RenderField) {
source
})
const setFormValue = value => {
dispatch(goToQuestion(dottedName))
dispatch(updateSituation(dottedName, value))
}

View File

@ -1,43 +0,0 @@
import React from 'react'
/* Simple way for a visual stack : using two h1,
hinting at the fact that it is a group result */
export default ({
text,
onClick,
folded,
themeColors: { color, textColorOnWhite }
}) => (
<div className="group-title" onClick={onClick}>
{folded && (
<h1
style={{
color: 'transparent',
position: 'absolute',
left: '.15em',
top: '.20em',
border: '1px solid #aaa',
borderTop: 'none',
borderLeft: 'none'
}}
>
{text}
</h1>
)}
<h1
style={
folded
? {
cursor: 'pointer',
border: '1px solid #aaa'
}
: {
border: '1px solid ' + color,
color: textColorOnWhite
}
}
>
{text}
</h1>
</div>
)

View File

@ -33,6 +33,7 @@ export default function Input({
onChange(value)
}}
onSecondClick={() => onSubmit && onSubmit('suggestion')}
unit={unit}
/>
</div>
@ -41,7 +42,7 @@ export default function Input({
autoFocus={autoFocus}
className="suffixed"
id={'step-' + dottedName}
placeholder={defaultValue}
placeholder={defaultValue?.nodeValue ?? defaultValue}
thousandSeparator={thousandSeparator}
decimalSeparator={decimalSeparator}
allowEmptyFormatting={true}

View File

@ -1,15 +1,12 @@
import { Rule } from 'Engine/types'
import { toPairs } from 'ramda'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import { defaultUnitSelector } from 'Selectors/analyseSelectors'
import { convertUnit, parseUnit, Unit } from '../../engine/units'
import { serializeUnit, Unit } from '../../engine/units'
type InputSuggestionsProps = {
suggestions?: Rule['suggestions']
onFirstClick: (val: number | string) => void
onSecondClick?: (val: number | string) => void
suggestions?: Record<string, number>
onFirstClick: (val: string) => void
onSecondClick?: (val: string) => void
unit?: Unit
}
@ -21,27 +18,25 @@ export default function InputSuggestions({
}: InputSuggestionsProps) {
const [suggestion, setSuggestion] = useState<string | number>()
const { t } = useTranslation()
const defaultUnit = parseUnit(useSelector(defaultUnitSelector) ?? '')
if (!suggestions) return null
return (
<div css="display: flex; align-items: baseline; ">
<small>Suggestions :</small>
{toPairs(suggestions).map(([text, value]: [string, string | number]) => {
value =
unit && typeof value === 'number'
? convertUnit(unit, defaultUnit, value)
: value
{toPairs(suggestions).map(([text, value]: [string, number]) => {
const valueWithUnit: string = `${value} ${
unit ? serializeUnit(unit)?.replace(' / ', '/') : ''
}`
return (
<button
className="ui__ link-button"
key={value}
css="margin: 0 0.4rem !important"
onClick={() => {
onFirstClick(value)
if (suggestion !== value) setSuggestion(value)
else onSecondClick && onSecondClick(value)
onFirstClick(valueWithUnit)
if (suggestion !== value) setSuggestion(valueWithUnit)
else onSecondClick && onSecondClick(valueWithUnit)
}}
title={t('cliquez pour insérer cette suggestion')}
>

View File

@ -88,7 +88,7 @@ export default function Question({
<li key={dottedName} className="variantLeaf">
<RadioLabel
{...{
value: relativeDottedName(dottedName),
value: `'${relativeDottedName(dottedName)}'`,
label: title,
dottedName,
currentValue,

View File

@ -6,20 +6,15 @@ import Answers from './AnswerList'
import './conversation.css'
export default function SeeAnswersButton() {
const arePreviousAnswers = !!useSelector(
(state: RootState) => state.simulation?.foldedSteps.length
)
const [showAnswerModal, setShowAnswerModal] = useState(false)
return (
<>
{arePreviousAnswers && (
<button
className="ui__ small simple button "
onClick={() => setShowAnswerModal(true)}
>
<Trans>Modifier mes réponses</Trans>
</button>
)}
<button
className="ui__ small simple button "
onClick={() => setShowAnswerModal(true)}
>
<Trans>Voir toutes les questions</Trans>
</button>
{showAnswerModal && <Answers onClose={() => setShowAnswerModal(false)} />}
</>
)

View File

@ -47,16 +47,14 @@ export default function Select({ onChange, onSubmit }) {
tauxVersementTransport(option.code)
.then(({ taux }) => {
// serialize to not mix our data schema and the API response's
onChange(
JSON.stringify({
...option,
...(taux != undefined
? {
'taux du versement transport': taux
}
: {})
})
)
onChange({
...option,
...(taux != undefined
? {
'taux du versement transport': taux
}
: {})
})
onSubmit()
})
.catch(error => {

View File

@ -1,79 +0,0 @@
import { setExample } from 'Actions/actions'
import classNames from 'classnames'
import { compose } from 'ramda'
import React from 'react'
import { Trans } from 'react-i18next'
import { connect } from 'react-redux'
export default compose(
connect(
state => ({
parsedRules: state.parsedRules,
themeColors: state.themeColors
}),
dispatch => ({
setExample: compose(dispatch, setExample)
})
)
)(function Examples({
situationExists,
rule,
themeColors,
setExample,
currentExample
}) {
let { examples } = rule
if (!examples) return null
return (
<>
<h2>
<Trans i18nKey="examples">Exemples</Trans>{' '}
<small>
<Trans i18nKey="clickexample">
Cliquez sur un exemple pour le tester
</Trans>
</small>
</h2>
<ul>
{examples.map(ex => (
<Example
key={ex.nom}
{...{ ex, rule, currentExample, setExample, themeColors }}
/>
))}
</ul>
{situationExists && currentExample && (
<button className="ui__ button small" onClick={() => setExample(null)}>
<Trans i18nKey="cancelExample">Revenir à votre situation</Trans>
</button>
)}
</>
)
})
let Example = ({
ex: { nom, situation },
rule,
currentExample,
setExample
}) => {
let selected = currentExample && currentExample.name == nom
return (
<li key={nom}>
<button
onClick={() =>
selected
? setExample(null)
: setExample(nom, situation, rule.dottedName)
}
className={classNames('ui__ button small', {
selected
})}
>
{nom}
</button>
</li>
)
}

View File

@ -1,31 +0,0 @@
.reportErrorContainer {
text-align: center;
padding: 0.3em 0.6em;
margin: 3em auto 0;
}
.reportError {
color: #c0392b;
font-size: 100%;
text-align: center;
}
.reportError:hover {
color: #b53527;
}
#notes {
color: #666;
}
h2 small {
font-size: 75%;
}
#rule #ruleDefault {
text-align: center;
opacity: 0.75;
}
#toggleRuleSource {
margin-top: 1em;
}

View File

@ -1,239 +0,0 @@
import { ThemeColorsContext } from 'Components/utils/colors'
import { SitePathsContext } from 'Components/utils/withSitePaths'
import Value from 'Components/Value'
import mecanisms from 'Engine/mecanisms.yaml'
import { filter, isEmpty } from 'ramda'
import React, { Suspense, useContext, useState } from 'react'
import emoji from 'react-easy-emoji'
import { Helmet } from 'react-helmet'
import { Trans, useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import { Link } from 'react-router-dom'
import {
exampleAnalysisSelector,
noUserInputSelector,
parsedRulesSelector,
ruleAnalysisSelector
} from 'Selectors/analyseSelectors'
import Animate from 'Ui/animate'
import { AttachDictionary } from '../AttachDictionary'
import RuleLink from '../RuleLink'
import { Markdown } from '../utils/markdown'
import Algorithm from './Algorithm'
import Examples from './Examples'
import RuleHeader from './Header'
import References from './References'
import './Rule.css'
let LazySource = React.lazy(() => import('./RuleSource'))
export default AttachDictionary(mecanisms)(function Rule({ dottedName }) {
const currentExample = useSelector(state => state.currentExample)
const rules = useSelector(parsedRulesSelector)
const valuesToShow = !useSelector(noUserInputSelector)
const analysedRule = useSelector(state =>
ruleAnalysisSelector(state, { dottedName })
)
const analysedExample = useSelector(state =>
exampleAnalysisSelector(state, { dottedName })
)
const sitePaths = useContext(SitePathsContext)
const [viewSource, setViewSource] = useState(false)
const { t } = useTranslation()
let rule = rules[dottedName]
let { type, name, acronyme, title, description, question, icon } = rule,
namespaceRules = filter(
rule =>
rule.dottedName.startsWith(dottedName) &&
rule.dottedName.split(' . ').length ===
dottedName.split(' . ').length + 1,
rules
)
let displayedRule = analysedExample || analysedRule
const renderToggleSourceButton = () => {
return (
<button
id="toggleRuleSource"
className="ui__ link-button"
onClick={() => setViewSource(!viewSource)}
>
{emoji(
viewSource
? `📖 ${t('Revenir à la documentation')}`
: `✍️ ${t('Voir le code source')}`
)}
</button>
)
}
const renderReferences = ({ références: refs }) =>
refs ? (
<div>
<h2>
<Trans>Références</Trans>
</h2>
<References refs={refs} />
</div>
) : null
return (
<>
{viewSource ? (
<>
{renderToggleSourceButton()}
<Suspense fallback={<div>Chargement du code source...</div>}>
<LazySource dottedName={dottedName} />
</Suspense>
</>
) : (
<div id="rule">
<Animate.fromBottom>
<Helmet
title={title}
meta={[
{
name: 'description',
content: description
}
]}
/>
<RuleHeader
{...{
dottedName,
type,
description,
question,
flatRule: rule,
name,
acronyme,
title,
icon,
valuesToShow
}}
/>
<section id="rule-content">
<div
id="ruleValue"
css={`
display: flex;
justify-content: center;
flex-wrap: wrap;
align-items: center;
> .value {
font-size: 220%;
}
margin: 0.6em 0;
> * {
margin: 0 0.6em;
}
`}
>
<Value
{...displayedRule}
nilValueSymbol={displayedRule.parentDependencies.some(
parent => parent?.nodeValue == false
)}
/>
</div>
{displayedRule.defaultValue != null && (
<div id="ruleDefault">
par défaut :{' '}
<Value
{...displayedRule}
nodeValue={displayedRule.defaultValue}
unit={displayedRule.unit || displayedRule.defaultUnit}
/>
</div>
)}
{!valuesToShow && (
<div style={{ textAlign: 'center', marginTop: '1em' }}>
<Link
className="ui__ cta plain button"
target="_parent"
to={
dottedName.includes('contrat salarié')
? sitePaths.simulateurs.salarié
: dottedName.includes('auto-entrepreneur')
? sitePaths.simulateurs['auto-entrepreneur']
: dottedName.includes('indépendant')
? sitePaths.simulateurs.indépendant
: // otherwise
sitePaths.simulateurs.index
}
>
<Trans>Faire une simulation</Trans>
</Link>
</div>
)}
<Algorithm
rule={displayedRule}
showValues={valuesToShow || currentExample}
/>
{displayedRule['rend non applicable'] && (
<section id="non-applicable">
<h3>
<Trans>Rend non applicable les règles suivantes</Trans> :{' '}
</h3>
<ul>
{displayedRule['rend non applicable'].map(ruleName => (
<li key={ruleName}>
<RuleLink dottedName={ruleName} />
</li>
))}
</ul>
</section>
)}
{rule.note && (
<section id="notes">
<h3>Note : </h3>
<Markdown source={rule.note} />
</section>
)}
<Examples
currentExample={currentExample}
situationExists={valuesToShow}
rule={displayedRule}
/>
{!isEmpty(namespaceRules) && (
<NamespaceRulesList {...{ namespaceRules }} />
)}
{renderReferences(rule)}
</section>
{renderToggleSourceButton()}
</Animate.fromBottom>
</div>
)}
</>
)
})
function NamespaceRulesList({ namespaceRules }) {
const colors = useContext(ThemeColorsContext)
const sitePaths = useContext(SitePathsContext)
return (
<section>
<h2>
<Trans>Pages associées</Trans>
</h2>
<ul>
{Object.values(namespaceRules).map(r => (
<li key={r.name}>
<Link
style={{
color: colors.textColorOnWhite,
textDecoration: 'underline'
}}
to={sitePaths.documentation.rule(r.dottedName)}
>
{r.title || r.name}
</Link>
</li>
))}
</ul>
</section>
)
}

View File

@ -1,9 +0,0 @@
import React from 'react'
export const ShowValuesContext = React.createContext(false)
const {
Consumer: ShowValuesConsumer,
Provider: ShowValuesProvider
} = ShowValuesContext
export { ShowValuesConsumer, ShowValuesProvider }

View File

@ -1,5 +1,5 @@
situation:
dirigeant: artiste-auteur
dirigeant: "'artiste-auteur'"
unité par défaut: €/an
objectifs:
- artiste-auteur . cotisations

View File

@ -36,5 +36,5 @@ questions:
unité par défaut: €/an
situation:
dirigeant: 'assimilé salarié'
dirigeant: "'assimilé salarié'"
contrat salarié . ATMP . taux réduit: oui

View File

@ -18,4 +18,4 @@ questions:
unité par défaut: €/an
situation:
dirigeant: 'auto-entrepreneur'
dirigeant: "'auto-entrepreneur'"

View File

@ -1,12 +1,11 @@
objectifs:
- contrat salarié . rémunération . brut de base
objectifs secondaires:
- contrat salarié . prix du travail
objectifs cachés:
- contrat salarié . rémunération . net
- contrat salarié . prix du travail
- chômage partiel . revenu net habituel
- chômage partiel . coût employeur habituel
- contrat salarié . activité partielle . indemnités
questions:
uniquement:

View File

@ -31,4 +31,4 @@ questions:
unité par défaut: €/an
situation:
dirigeant: 'indépendant'
dirigeant: "'indépendant'"

View File

@ -2,12 +2,12 @@ titre: |
Calcul du revenu du travailleur indépendant ou dirigeant d'entreprise après paiement des cotisations et de l'impôt sur le revenu.
objectifs:
- revenu net après impôt
- revenus net de cotisations
- contrat salarié . rémunération . net
- dirigeant . indépendant . revenu net de cotisations
- dirigeant . auto-entrepreneur . net de cotisations
- protection sociale . retraite
- protection sociale . retraite . trimestres validés par an
- protection sociale . retraite . trimestres validés
- protection sociale . santé . indemnités journalières
- protection sociale . accidents du travail et maladies professionnelles
questions:
uniquement:
@ -19,14 +19,6 @@ questions:
- entreprise . catégorie d'activité . libérale règlementée
unité par défaut: €/an
branches:
- nom: Assimilé salarié
situation:
dirigeant: 'assimilé salarié'
contrat salarié . ATMP . taux réduit: oui
- nom: Indépendant
situation:
dirigeant: 'indépendant'
- nom: Auto-entrepreneur
situation:
dirigeant: 'auto-entrepreneur'
situation:
dirigeant: "'auto-entrepreneur'"
contrat salarié . ATMP . taux réduit: oui

View File

@ -1,16 +1,10 @@
objectifs:
- contrat salarié . prix du travail
- contrat salarié . aides employeur
- contrat salarié . rémunération . brut de base . équivalent temps plein
- contrat salarié . rémunération . brut de base
- contrat salarié . rémunération . net
- contrat salarié . rémunération . net après impôt
objectifs secondaires:
- contrat salarié . temps de travail
- contrat salarié . cotisations
- contrat salarié . frais professionnels . titres-restaurant . montant
questions:
à l'affiche:
Chômage partiel: contrat salarié . activité partielle

View File

@ -25,7 +25,7 @@ export default function AnimatedTargetValue({
// We don't want to show the animated if the difference comes from a change in the unit
const currentUnit = useSelector(
(state: RootState) => state?.simulation?.defaultUnit
(state: RootState) => state?.simulation?.targetUnit
)
const previousUnit = useRef(currentUnit)

View File

@ -52,7 +52,7 @@
border: 1px dotted gray;
}
.ui__.toggle input[type='radio']:checked ~ .radioText {
font-weight: 500;
font-weight: 600;
}
.ui__.toggle input[type='radio']:checked ~ * {

View File

@ -123,7 +123,7 @@ a:not(:disabled):not(.button):not(.button-choice):hover {
strong,
b {
font-weight: 500;
font-weight: 600;
}
textarea {

View File

@ -126,6 +126,7 @@ span.ui__.enumeration:not(:last-of-type)::after {
.ui__.label {
font-size: 85%;
line-height: initial;
padding: 0.4rem 0.6rem;
font-weight: bold;
color: white !important;
@ -133,6 +134,10 @@ span.ui__.enumeration:not(:last-of-type)::after {
border-radius: 0.3rem;
text-align: center;
}
.ui__.small.label {
font-size: 75%;
padding: 0.2rem 0.4rem;
}
.no-scroll {
overflow: hidden;

View File

@ -0,0 +1,52 @@
import Engine, { EvaluationOptions } from 'Engine'
import { EvaluatedRule } from 'Engine/types'
import React, { createContext, useContext } from 'react'
import rules, { DottedName } from 'Rules'
export const EngineContext = createContext<Engine<DottedName>>(
new Engine(rules)
)
export const EngineProvider = EngineContext.Provider
type SituationProviderProps = {
children: React.ReactNode
situation: Partial<Record<DottedName, string | number | Object>>
}
export function SituationProvider({
children,
situation
}: SituationProviderProps) {
const engine = useContext(EngineContext)
engine.setSituation(situation)
return (
<EngineContext.Provider value={engine}>{children}</EngineContext.Provider>
)
}
export function useEvaluation(
rule: DottedName,
options?: EvaluationOptions
): EvaluatedRule<DottedName>
export function useEvaluation(
rule: DottedName[],
options?: EvaluationOptions
): EvaluatedRule<DottedName>[]
export function useEvaluation(
rule: Array<DottedName> | DottedName,
options?: EvaluationOptions
): Array<EvaluatedRule<DottedName>> | EvaluatedRule<DottedName> {
const engine = useContext(EngineContext)
if (Array.isArray(rule)) {
return rule.map(name => engine.evaluate(name, options))
}
return engine.evaluate(rule, options)
}
export function useInversionFail() {
return useContext(EngineContext).inversionFail()
}
export function useControls() {
return useContext(EngineContext).controls()
}

View File

@ -0,0 +1,132 @@
import { splitName } from 'Engine/ruleUtils'
import {
add,
countBy,
descend,
difference,
equals,
flatten,
head,
identity,
intersection,
keys,
last,
length,
map,
mergeWith,
negate,
pair,
pipe,
reduce,
sortBy,
sortWith,
takeWhile,
toPairs,
values,
zipWith
} from 'ramda'
import { useSelector } from 'react-redux'
import { useEvaluation } from './EngineContext'
import {
objectifsSelector,
configSelector,
answeredQuestionsSelector,
currentQuestionSelector
} from 'Selectors/simulationSelectors'
import { useMemo } from 'react'
import { DottedName } from 'Rules'
import { SimulationConfig } from 'Reducers/rootReducer'
type MissingVariables = Array<Partial<Record<DottedName, number>>>
export function getNextSteps(
missingVariables: MissingVariables
): Array<DottedName> {
let byCount = ([, [count]]) => count
let byScore = ([, [, score]]) => score
let missingByTotalScore = reduce(mergeWith(add), {}, missingVariables)
let innerKeys = flatten(map(keys, missingVariables)),
missingByTargetsAdvanced = countBy(identity, innerKeys)
let missingByCompound = mergeWith(
pair,
missingByTargetsAdvanced,
missingByTotalScore
),
pairs = toPairs(missingByCompound),
sortedPairs = sortWith([descend(byCount), descend(byScore) as any], pairs)
return map(head, sortedPairs)
}
const similarity = (rule1: string = '', rule2: string = '') =>
pipe(
zipWith(equals),
takeWhile(Boolean),
length,
negate
)(splitName(rule1), splitName(rule2))
export function getNextQuestions(
missingVariables: MissingVariables,
questionConfig: SimulationConfig['questions'] = {},
answeredQuestions = []
): Array<DottedName> {
const {
'non prioritaires': notPriority = [],
uniquement: only = null,
'liste noire': blacklist = []
} = questionConfig
// console.log(missingVariables)
let nextSteps = difference(getNextSteps(missingVariables), answeredQuestions)
if (only) {
nextSteps = intersection(nextSteps, [...only, ...notPriority])
}
if (blacklist) {
nextSteps = difference(nextSteps, blacklist)
}
const lastStep = last(answeredQuestions)
// L'ajout de la réponse permet de traiter les questions dont la réponse est "une possibilité", exemple "contrat salarié . cdd"
// lastStepWithAnswer =
// lastStep && situation[lastStep]
// ? ([lastStep, situation[lastStep]].join(' . ') as DottedName)
// : lastStep
return sortBy(
question =>
notPriority.includes(question)
? notPriority.indexOf(question)
: similarity(question, lastStep),
nextSteps
)
}
export const useNextQuestions = function(): Array<DottedName> {
const objectifs = useSelector(objectifsSelector)
const answeredQuestions = useSelector(answeredQuestionsSelector)
const currentQuestion = useSelector(currentQuestionSelector)
const questionsConfig = useSelector(configSelector).questions ?? {}
const missingVariables = useEvaluation(objectifs, {
useDefaultValues: false
}).map(node => node.missingVariables ?? {})
const nextQuestions = useMemo(() => {
return getNextQuestions(
missingVariables,
questionsConfig,
answeredQuestions
)
}, [missingVariables, questionsConfig, answeredQuestions])
if (currentQuestion && currentQuestion !== nextQuestions[0]) {
return [currentQuestion, ...nextQuestions]
}
return nextQuestions
}
export function useSimulationProgress(): number {
const numberQuestionAnswered = useSelector(answeredQuestionsSelector).length
const numberQuestionLeft = useNextQuestions().length
return numberQuestionAnswered / (numberQuestionAnswered + numberQuestionLeft)
}

View File

@ -1,13 +1,14 @@
import Input from 'Components/conversation/Input'
import Question from 'Components/conversation/Question'
import SelectGéo from 'Components/conversation/select/SelectGéo'
import SelectGéo from 'Components/conversation/select/SelectGeo'
import SelectAtmp from 'Components/conversation/select/SelectTauxRisque'
import SendButton from 'Components/conversation/SendButton'
import CurrencyInput from 'Components/CurrencyInput/CurrencyInput'
import PercentageField from 'Components/PercentageField'
import ToggleSwitch from 'Components/ui/ToggleSwitch'
import { EngineContext } from 'Components/utils/EngineContext'
import { ParsedRules } from 'Engine/types'
import React from 'react'
import React, { useContext } from 'react'
import { useTranslation } from 'react-i18next'
import { DottedName } from 'Rules'
import DateInput from '../components/conversation/DateInput'
@ -17,7 +18,7 @@ export const binaryOptionChoices = [
{ value: 'oui', label: 'Oui' }
]
type Value = string | number | object | boolean
type Value = string | number | object | boolean | null
export type RuleInputProps = {
rules: ParsedRules
dottedName: DottedName
@ -46,8 +47,9 @@ export default function RuleInput({
onSubmit
}: RuleInputProps) {
let rule = rules[dottedName]
let unit = rule.unit || rule.defaultUnit
let unit = rule.unit
let language = useTranslation().i18n.language
let engine = useContext(EngineContext)
let commonProps = {
key: dottedName,
@ -100,6 +102,11 @@ export default function RuleInput({
<Question {...commonProps} choices={binaryOptionChoices} />
)
}
commonProps.value =
typeof commonProps.value === 'string'
? engine.evaluate(commonProps.value as DottedName).nodeValue
: commonProps.value
if (unit?.numerators.includes('€') && isTarget) {
return (
<>
@ -123,20 +130,18 @@ export default function RuleInput({
return <Input {...commonProps} unit={unit} />
}
let getVariant = rule => rule?.formule?.explanation['une possibilité']
let getVariant = rule => rule?.formule?.explanation['possibilités']
export let buildVariantTree = (allRules, path) => {
let rec = path => {
let node = allRules[path]
if (!node) throw new Error(`La règle ${path} est introuvable`)
let variant = getVariant(node),
variants = variant && node.formule.explanation['possibilités'],
shouldBeExpanded = variant && true, //variants.find( v => relevantPaths.find(rp => contains(path + ' . ' + v)(rp) )),
canGiveUp = variant && !node.formule.explanation['choix obligatoire']
let variant = getVariant(node)
const variants = variant && node.formule.explanation['possibilités']
const canGiveUp = variant && !node.formule.explanation['choix obligatoire']
return Object.assign(
node,
shouldBeExpanded
!!variant
? {
canGiveUp,
children: variants.map(v => rec(path + ' . ' + v))

View File

@ -60,13 +60,13 @@ export function typeWarning(
export function warning(
rules: string[] | string,
message: string,
solution: string
solution?: string
) {
console.warn(
`\n[ Avertissement ]
Dans la règle \`${coerceArray(rules).slice(-1)[0]}\`
${message}
💡 ${solution}
💡 ${solution ? solution : ''}
`
)
}

View File

@ -3,7 +3,6 @@ import { ParsedRule } from 'Engine/types'
import { map, mergeAll, pick, pipe } from 'ramda'
import { typeWarning } from './error'
import { convertNodeToUnit } from './nodeUnits'
import { areUnitConvertible } from './units'
export const evaluateApplicability = (
cache,
@ -48,8 +47,11 @@ export const evaluateApplicability = (
])
return {
...node,
isApplicable,
nodeValue: isApplicable,
missingVariables,
parentDependencies,
...evaluatedAttributes
}
}
@ -72,7 +74,7 @@ export default (cache, situationGate, parsedRules, node) => {
? evaluateNode(cache, situationGate, parsedRules, node.formule)
: {}
// evaluate the formula lazily, only if the applicability is known and true
const evaluatedFormula = isApplicable
let evaluatedFormula = isApplicable
? evaluateFormula()
: isApplicable === false
? {
@ -85,27 +87,10 @@ export default (cache, situationGate, parsedRules, node) => {
missingVariables: {},
nodeValue: null
}
let {
missingVariables: formulaMissingVariables,
nodeValue
} = evaluatedFormula
const missingVariables = mergeMissing(
bonus(condMissing, !!Object.keys(condMissing).length),
formulaMissingVariables
)
const unit =
node.unit ||
(node.defaultUnit &&
cache._meta.defaultUnits.find(unit =>
areUnitConvertible(node.defaultUnit, unit)
)) ||
node.defaultUnit ||
evaluatedFormula.unit
const temporalValue = evaluatedFormula.temporalValue
if (unit) {
if (node.unit) {
try {
nodeValue = convertNodeToUnit(unit, evaluatedFormula).nodeValue
evaluatedFormula = convertNodeToUnit(node.unit, evaluatedFormula)
} catch (e) {
typeWarning(
node.dottedName,
@ -114,14 +99,20 @@ export default (cache, situationGate, parsedRules, node) => {
)
}
}
const missingVariables = mergeMissing(
bonus(condMissing, !!Object.keys(condMissing).length),
evaluatedFormula.missingVariables
)
// console.log(node.dottedName, evaluatedFormula.unit)
let temporalValue = evaluatedFormula.temporalValue
cache._meta.contextRule.pop()
return {
...node,
...applicabilityEvaluation,
...(node.formule && { formule: evaluatedFormula }),
nodeValue,
unit,
nodeValue: evaluatedFormula.nodeValue,
unit: node.unit ?? evaluatedFormula.unit,
temporalValue,
isApplicable,
missingVariables

View File

@ -11,9 +11,9 @@ import {
import React from 'react'
import { typeWarning } from './error'
import { convertNodeToUnit, simplifyNodeUnit } from './nodeUnits'
import { EvaluatedNode } from 'Engine/types'
import {
concatTemporals,
EvaluatedNode,
liftTemporalNode,
mapTemporal,
pureTemporal,
@ -21,10 +21,11 @@ import {
temporalAverage,
zipTemporals
} from './temporal'
import { ParsedRule, ParsedRules } from './types'
export let makeJsx = node =>
typeof node.jsx == 'function'
? node.jsx(node.nodeValue, node.explanation, node.lazyEval, node.unit)
? node.jsx(node.nodeValue, node.explanation, node.unit)
: node.jsx
export let collectNodeMissing = node => node.missingVariables || {}
@ -40,12 +41,6 @@ export let evaluateNode = (cache, situationGate, parsedRules, node) => {
let evaluatedNode = node.evaluate
? node.evaluate(cache, situationGate, parsedRules, node)
: node
if (typeof evaluatedNode.nodeValue !== 'number') {
return evaluatedNode
}
evaluatedNode = node.unité
? convertNodeToUnit(node.unit, evaluatedNode)
: simplifyNodeUnit(evaluatedNode)
return evaluatedNode
}
@ -175,6 +170,7 @@ export let evaluateObject = (objectShape, effect) => (
}, temporalExplanations)
const sameUnitTemporalExplanation: Temporal<EvaluatedNode<
string,
number
>> = convertNodesToSameUnit(
temporalExplanation.map(x => x.value),
@ -210,3 +206,22 @@ export let evaluateObject = (objectShape, effect) => (
temporalExplanation
}
}
type DefaultValues<Names extends string> = { [name in Names]: any } | {}
export function collectDefaults<Names extends string>(
parsedRules: ParsedRules<Names>
): DefaultValues<Names> {
const cache = { _meta: { contextRule: [] as string[] } }
return (Object.values(parsedRules) as Array<ParsedRule<Names>>).reduce(
(acc, parsedRule) => {
if (parsedRule?.['par défaut'] == null) {
return acc
}
return {
...acc,
[parsedRule.dottedName]: parsedRule['par défaut']
}
},
{}
)
}

View File

@ -1,48 +1,62 @@
import { expect } from 'chai'
import { parseUnit } from 'Engine/units'
import { formatCurrency, formatPercentage, formatValue } from './format'
import { formatValue } from './format'
describe('format engine values', () => {
it('format currencies', () => {
expect(formatCurrency(12, 'fr')).to.equal('12 €')
expect(formatCurrency(1200, 'fr')).to.equal('1 200 €')
expect(formatCurrency(12, 'en')).to.equal('€ 12')
expect(formatCurrency(12.1)).to.equal('€ 12.10')
expect(formatCurrency(12.123)).to.equal('€ 12.12')
expect(formatValue({ nodeValue: 12, unit: '€', language: 'fr' })).to.equal(
'12 €'
)
expect(
formatValue({ nodeValue: 1200, unit: '€', language: 'fr' })
).to.equal('1 200 €')
expect(formatValue({ nodeValue: 12, unit: '€', language: 'en' })).to.equal(
'€12'
)
expect(
formatValue({ nodeValue: 12.1, unit: '€', language: 'en' })
).to.equal('€12.10')
expect(
formatValue({ nodeValue: 12.123, unit: '€', language: 'en' })
).to.equal('€12.12')
})
it('format percentages', () => {
expect(formatPercentage(10)).to.equal('10%')
expect(formatPercentage(100)).to.equal('100%')
expect(formatPercentage(10.2)).to.equal('10.2%')
expect(formatValue({ nodeValue: 10, unit: '%' })).to.equal('10%')
expect(formatValue({ nodeValue: 100, unit: '%' })).to.equal('100%')
expect(formatValue({ nodeValue: 10.2, unit: '%' })).to.equal('10.2%')
})
it('format values', () => {
expect(formatValue({ unit: '€', value: 12 })).to.equal('€12')
expect(formatValue({ unit: '€', value: 12.1 })).to.equal('€12.10')
expect(formatValue({ unit: '€', value: 12, language: 'fr' })).to.equal(
expect(formatValue({ unit: '€', nodeValue: 12 })).to.equal('€12')
expect(formatValue({ unit: '€', nodeValue: 12.1 })).to.equal('€12.10')
expect(formatValue({ unit: '€', nodeValue: 12, language: 'fr' })).to.equal(
'12 €'
)
expect(formatValue({ value: 1200, language: 'fr' })).to.equal('1 200')
expect(formatValue({ nodeValue: 1200, language: 'fr' })).to.equal('1 200')
})
})
describe('Units handling', () => {
it('translate unit', () => {
expect(formatValue({ value: 1, unit: 'jour', language: 'fr' })).to.equal(
'1 jour'
)
expect(formatValue({ value: 1, unit: 'jour', language: 'en' })).to.equal(
'1 day'
)
expect(
formatValue({ nodeValue: 1, unit: 'jour', language: 'fr' })
).to.equal('1 jour')
expect(
formatValue({ nodeValue: 1, unit: 'jour', language: 'en' })
).to.equal('1 day')
})
it('pluralize unit', () => {
expect(formatValue({ value: 2, unit: 'jour', language: 'fr' })).to.equal(
'2 jours'
)
expect(
formatValue({ value: 7, unit: parseUnit('jour/semaine'), language: 'fr' })
formatValue({ nodeValue: 2, unit: 'jour', language: 'fr' })
).to.equal('2 jours')
expect(
formatValue({
nodeValue: 7,
unit: parseUnit('jour/semaine'),
language: 'fr'
})
).to.equal('7 jours / semaine')
})
})

View File

@ -1,5 +1,6 @@
import { serializeUnit } from 'Engine/units'
import { memoizeWith } from 'ramda'
import { Evaluation } from './types'
import { Unit } from './units'
const NumberFormat = memoizeWith(
@ -46,7 +47,7 @@ export const currencyFormat = (language: string | undefined) => ({
export const formatCurrency = (value: number | undefined, language: string) => {
return value == null
? ''
: (formatValue({ unit: '€', language, value }) ?? '').replace(
: (formatNumber({ unit: '€', language, value }) ?? '').replace(
/^(-)?€/,
'$1€\u00A0'
)
@ -55,17 +56,17 @@ export const formatCurrency = (value: number | undefined, language: string) => {
export const formatPercentage = (value: number | undefined) =>
value == null
? ''
: formatValue({ unit: '%', value, maximumFractionDigits: 2 })
: formatNumber({ unit: '%', value, maximumFractionDigits: 2 })
export type formatValueOptions = {
maximumFractionDigits?: number
minimumFractionDigits?: number
language?: string
unit?: Unit | string
value?: number
value: number
}
export function formatValue({
function formatNumber({
maximumFractionDigits,
minimumFractionDigits,
language,
@ -106,3 +107,44 @@ export function formatValue({
)
}
}
const booleanTranslations = {
fr: { true: 'Oui', false: 'Non' },
en: { true: 'Yes', false: 'No' }
}
type ValueArg = {
nodeValue: Evaluation
language: string
unit?: string | Unit
precision?: number
}
export function formatValue({
nodeValue,
language,
unit,
precision = 2
}: ValueArg) {
if (
(typeof nodeValue === 'number' && Number.isNaN(nodeValue)) ||
nodeValue === null
) {
return '-'
}
return typeof nodeValue === 'string'
? nodeValue
: typeof nodeValue === 'object'
? (nodeValue as any).nom
: typeof nodeValue === 'boolean'
? booleanTranslations[language][nodeValue]
: typeof nodeValue === 'number'
? formatNumber({
minimumFractionDigits: 0,
maximumFractionDigits: precision,
language,
unit,
value: nodeValue
})
: null
}

View File

@ -1,60 +0,0 @@
import {
add,
countBy,
descend,
flatten,
head,
identity,
keys,
map,
mergeWith,
pair,
reduce,
sortWith,
toPairs,
values
} from 'ramda'
import { DottedName } from 'Rules'
/*
COLLECTE DES VARIABLES MANQUANTES
*********************************
on collecte les variables manquantes : celles qui sont nécessaires pour
remplir les objectifs de la simulation (calculer des cotisations) mais qui n'ont pas
encore été renseignées
TODO perf : peut-on le faire en même temps que l'on traverse l'AST ?
Oui sûrement, cette liste se complète en remontant l'arbre. En fait, on le fait déjà pour nodeValue,
et quand nodeValue vaut null, c'est qu'il y a des missingVariables ! Il suffit donc de remplacer les
null par un tableau, et d'ailleurs utiliser des fonction d'aide pour mutualiser ces tests.
missingVariables: {variable: [objectives]}
*/
type Explanation = {
missingVariables: Array<DottedName>
dottedName: DottedName
}
export let getNextSteps = missingVariablesByTarget => {
let byCount = ([, [count]]) => count
let byScore = ([, [, score]]) => score
let missingByTotalScore = reduce(
mergeWith(add),
{},
values(missingVariablesByTarget)
)
let innerKeys = flatten(map(keys, values(missingVariablesByTarget))),
missingByTargetsAdvanced = countBy(identity, innerKeys)
let missingByCompound = mergeWith(
pair,
missingByTargetsAdvanced,
missingByTotalScore
),
pairs = toPairs(missingByCompound),
sortedPairs = sortWith([descend(byCount), descend(byScore) as any], pairs)
return map(head, sortedPairs)
}

View File

@ -21,21 +21,11 @@ let evaluateBottomUp = situationGate => startingFragments => {
return rec(startingFragments)
}
let formatBooleanValue = { oui: true, non: false }
export let getSituationValue = (situationGate, variableName, rule) => {
// get the current situation value
// it's the user input or test input, possibly with default values
let value = situationGate(variableName)
if (rule.API) return typeof value == 'string' ? JSON.parse(value) : value
if (rule.unit != null) {
return value == undefined ? value : +value
}
// a leaf variable with an unit attribute is not boolean
if (formatBooleanValue[value] !== undefined) return formatBooleanValue[value]
if (rule.formule && rule.formule['une possibilité'])
return evaluateBottomUp(situationGate)(splitName(variableName))

View File

@ -1,24 +1,21 @@
import { evaluateControls } from 'Engine/controls'
import { ParsedRules, Rules } from 'Engine/types'
import { convertNodeToUnit, simplifyNodeUnit } from 'Engine/nodeUnits'
import { parse } from 'Engine/parse'
import { EvaluatedNode, EvaluatedRule, ParsedRules, Rules } from 'Engine/types'
import { parseUnit } from 'Engine/units'
import { mapObjIndexed } from 'ramda'
import { Simulation } from 'Reducers/rootReducer'
import { evaluateNode } from './evaluation'
import { evaluationError, warning } from './error'
import { collectDefaults, evaluateNode } from './evaluation'
import parseRules from './parseRules'
import { collectDefaults } from './ruleUtils'
import { parseUnit, Unit } from './units'
const emptyCache = {
_meta: { contextRule: [], defaultUnits: [] }
}
type EngineConfig<Names extends string> = {
rules: string | Rules<Names> | ParsedRules<Names>
useDefaultValues?: boolean
}
const emptyCache = () => ({
_meta: { contextRule: [] }
})
type Cache = {
_meta: {
contextRule: Array<string>
defaultUnits: Array<Unit>
inversionFail?: {
given: string
estimated: string
@ -26,70 +23,135 @@ type Cache = {
}
}
export type EvaluationOptions = Partial<{
unit: string
useDefaultValues: boolean
}>
export { default as translateRules } from './translateRules'
export { parseRules }
export default class Engine<Names extends string> {
parsedRules: ParsedRules<Names>
defaultValues: Simulation['situation']
situation: Simulation['situation'] = {}
cache: Cache = { ...emptyCache }
cache: Cache
cacheWithoutDefault: Cache
constructor({ rules, useDefaultValues = true }: EngineConfig<Names>) {
constructor(rules: string | Rules<Names> | ParsedRules<Names>) {
this.cache = emptyCache()
this.cacheWithoutDefault = emptyCache()
this.parsedRules =
typeof rules === 'string' || !(Object.values(rules)[0] as any)?.dottedName
? parseRules(rules)
: (rules as ParsedRules<Names>)
this.defaultValues = useDefaultValues
? collectDefaults(this.parsedRules)
: {}
this.defaultValues = mapObjIndexed(
(value, name) =>
typeof value === 'string'
? this.evaluateExpression(value, `[valeur par défaut] ${name}`, false)
: value,
collectDefaults(this.parsedRules)
)
}
private resetCache() {
this.cache = { ...emptyCache }
this.cache = emptyCache()
this.cacheWithoutDefault = emptyCache()
}
setSituation(situation: Simulation['situation'] = {}) {
this.situation = situation
this.resetCache()
return this
private situationGate(useDefaultValues = true) {
return dottedName =>
this.situation[dottedName] ??
(useDefaultValues ? this.defaultValues[dottedName] : null)
}
setDefaultUnits(defaultUnits: string[] = []) {
this.cache._meta.defaultUnits = defaultUnits.map(unit =>
parseUnit(unit)
) as any
return this
}
evaluate(expression: string | Array<string>) {
const results = (Array.isArray(expression) ? expression : [expression]).map(
expr =>
this.cache[expr] ||
(this.parsedRules[expr]
? evaluateNode(
this.cache,
this.situationGate,
this.parsedRules,
this.parsedRules[expr]
)
: // TODO: To support expressions (with operations, unit conversion,
// etc.) it should be enough to replace the above line with :
// parse(this.parsedRules, { dottedName: '' }, this.parsedRules)(expr)
// But currently there are small side effects (null values converted
// to 0), so we need to modify a little bit the engine before enabling
// publicode expressions in the UI.
null)
private evaluateExpression(
expression: string,
context: string,
useDefaultValues: boolean = true
): EvaluatedRule<Names> {
const result = simplifyNodeUnit(
evaluateNode(
useDefaultValues ? this.cache : this.cacheWithoutDefault,
this.situationGate(useDefaultValues),
this.parsedRules,
parse(
this.parsedRules,
{ dottedName: context },
this.parsedRules
)(expression)
)
)
return Array.isArray(expression) ? results : results[0]
if (Object.keys(result.defaultValue?.missingVariable ?? {}).length) {
throw new evaluationError(
context,
"Impossible d'évaluer l'expression car celle ci fait appel à des variables manquantes"
)
}
return result
}
setSituation(
situation: Partial<Record<Names, string | number | object>> = {}
) {
this.resetCache()
this.situation = mapObjIndexed(
(value, name) =>
typeof value === 'string'
? this.evaluateExpression(value, `[situation] ${name}`, true)
: value,
situation
)
return this
}
evaluate(expression: Names, options?: EvaluationOptions): EvaluatedRule<Names>
evaluate(
expression: string,
options?: EvaluationOptions
): EvaluatedNode<Names>
evaluate(
expression: string,
options?: EvaluationOptions
): EvaluatedNode<Names> {
let result = this.evaluateExpression(
expression,
`[evaluation] ${expression}`,
options?.useDefaultValues ?? true
)
if (result.category === 'reference' && result.explanation) {
result = result.explanation
}
if (options?.unit) {
try {
return convertNodeToUnit(
parseUnit(options.unit),
result as EvaluatedNode<Names, number>
)
} catch (e) {
warning(
`[evaluation] ${expression}`,
"L'unité demandée est incompatible avec l'expression évaluée"
)
}
}
return result
}
controls() {
return evaluateControls(this.cache, this.situationGate, this.parsedRules)
return evaluateControls(this.cache, this.situationGate(), this.parsedRules)
}
inversionFail(): boolean {
return !!this.cache._meta.inversionFail
}
getParsedRules(): ParsedRules<Names> {
return this.parsedRules
}
// TODO : this should be private
getCache(): Cache {
return this.cache
}
situationGate = (dottedName: string) =>
this.situation[dottedName] ?? this.defaultValues[dottedName]
}

View File

@ -40,7 +40,7 @@ export default function Allègement(nodeValue, rawExplanation) {
</li>
)}
{explanation.plafond && (
<li key="abattement">
<li key="plafond">
<span className="key">plafond: </span>
<span className="value">{makeJsx(explanation.plafond)}</span>
</li>

View File

@ -19,7 +19,7 @@
padding: 0.1em 0.4em;
}
.barème table th {
font-weight: 500;
font-weight: 600;
}
.barème table th:first-letter {
text-transform: uppercase;

View File

@ -4,8 +4,9 @@ import { Trans } from 'react-i18next'
import { makeJsx } from '../evaluation'
import './Barème.css'
import { Node, NodeValuePointer } from './common'
import { parseUnit } from 'Engine/units'
export default function Barème(nodeValue, explanation, _, unit) {
export default function Barème(nodeValue, explanation, unit) {
return (
<Node classes="mecanism barème" name="barème" value={nodeValue} unit={unit}>
<ul className="properties">
@ -22,7 +23,7 @@ export default function Barème(nodeValue, explanation, _, unit) {
</b>
<NodeValuePointer
data={(100 * nodeValue) / explanation.assiette.nodeValue}
unit="%"
unit={parseUnit('%')}
/>
</>
)}

View File

@ -72,6 +72,6 @@ let Comp = function Composantes({ nodeValue, explanation, unit }) {
}
// eslint-disable-next-line
export default (nodeValue, explanation, _, unit) => (
export default (nodeValue, explanation, unit) => (
<Comp {...{ nodeValue, explanation, unit }} />
)

View File

@ -3,7 +3,7 @@ import { BarèmeAttributes, TrancheTable } from './Barème'
import './Barème.css'
import { Node } from './common'
export default function Grille(nodeValue, explanation, _, unit) {
export default function Grille(nodeValue, explanation, unit) {
return (
<Node classes="mecanism barème" name="grille" value={nodeValue} unit={unit}>
<ul className="properties">

View File

@ -1,49 +1,53 @@
import { ShowValuesConsumer } from 'Components/rule/ShowValuesContext'
import { makeJsx } from 'Engine/evaluation'
import { Leaf } from 'Engine/mecanismViews/common'
import React from 'react'
import { Node } from './common'
import './InversionNumérique.css'
let Comp = function InversionNumérique({ nodeValue, explanation }) {
return (
<ShowValuesConsumer>
{showValues => (
<Node
classes="mecanism inversionNumérique"
name="inversion numérique"
value={nodeValue}
>
{!showValues || explanation.inversedWith?.value == null ? (
<>
<p>
Cette formule de calcul n'existe pas ! Mais on peut faire une
estimation à partir de&nbsp;:
</p>
<ul id="inversionsPossibles">
{explanation.avec.map(el => (
<li key={el.name}>{makeJsx(el)}</li>
))}
</ul>
</>
) : (
<>
{' '}
<p>
Cette valeur a été estimée à partir d'une autre variable qui
possède une formule de calcul et dont la valeur a été fixée dans
la simulation :
</p>
<Leaf
classes="variable"
dottedName={explanation.inversedWith.rule.dottedName}
value={explanation.inversedWith.value}
/>
</>
)}
</Node>
<Node
classes="mecanism inversionNumérique"
name="inversion numérique"
value={nodeValue}
>
{explanation.inversionFailed ? (
<>
{' '}
<p>
Cette valeur devrait pouvoir être estimée à partir d'une autre
variable qui possède une formule de calcul et dont la valeur a été
fixée dans la simulation :
</p>
{makeJsx(explanation.inversedWith)}
<p>
Malheureusement, il a été impossible de retrouver une valeur pour
cette formule qui permette d'atterir sur la valeur demandée.
</p>
</>
) : explanation.inversedWith ? (
<>
{' '}
<p>
Cette valeur a été estimée à partir d'une autre variable qui possède
une formule de calcul et dont la valeur a été fixée dans la
simulation :
</p>
{makeJsx(explanation.inversedWith)}
</>
) : (
<>
<p>
Cette formule de calcul n'existe pas, mais on peut la calculer par
inversion en utilisant les formules des règles suivantes :
</p>
<ul id="inversionsPossibles">
{explanation.inversionCandidates.map(el => (
<li key={el.dottedName}>{makeJsx(el)}</li>
))}
</ul>
</>
)}
</ShowValuesConsumer>
</Node>
)
}

View File

@ -4,7 +4,7 @@ import { Trans } from 'react-i18next'
import { Node } from './common'
import './InversionNumérique.css'
export default function ProductView(nodeValue, explanation, _, unit) {
export default function ProductView(nodeValue, explanation, unit) {
return (
// The rate and factor and threshold are given defaut neutral values. If there is nothing to explain, don't display them at all
<Node

Some files were not shown because too many files have changed in this diff Show More