refactor: rassemble la gestion des questions dans Redux
parent
e767348204
commit
54d49accd1
|
@ -61,8 +61,11 @@
|
|||
"@react-pdf/renderer": "^3.1.12",
|
||||
"@sentry/integrations": "^7.70.0",
|
||||
"@sentry/react": "^7.70.0",
|
||||
"@types/deep-eql": "^4.0.2",
|
||||
"algoliasearch": "^4.20.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"deep-eql": "^5.0.1",
|
||||
"effect": "^3.0.0",
|
||||
"exoneration-covid": "workspace:^",
|
||||
"focus-trap-react": "^10.2.1",
|
||||
"fuse.js": "^6.6.2",
|
||||
|
|
|
@ -63,7 +63,7 @@ export default function Root({
|
|||
return (
|
||||
<StrictMode>
|
||||
<EngineProvider value={engine}>
|
||||
<Provider basename={basename}>
|
||||
<Provider engine={engine} basename={basename}>
|
||||
<Redirections>
|
||||
<ErrorBoundary fallback={CatchOffline}>
|
||||
<Router />
|
||||
|
|
|
@ -2,7 +2,7 @@ import { Route, Routes, useNavigate } from 'react-router-dom'
|
|||
|
||||
import Popover from '@/design-system/popover/Popover'
|
||||
import Documentation from '@/pages/Documentation'
|
||||
import { EngineComparison } from '@/pages/simulateurs/comparaison-statuts/components/Comparateur'
|
||||
import { EngineComparison } from '@/pages/simulateurs/comparaison-statuts/EngineComparison'
|
||||
|
||||
export function EngineDocumentationRoutes({
|
||||
namedEngines,
|
||||
|
|
|
@ -43,14 +43,10 @@ function getNotifications(engine: Engine) {
|
|||
}))
|
||||
}
|
||||
|
||||
export default function Notifications({
|
||||
engines,
|
||||
}: {
|
||||
engines?: Array<Engine<DottedName>>
|
||||
}) {
|
||||
export default function Notifications() {
|
||||
const { t } = useTranslation()
|
||||
const engine = useEngine()
|
||||
const inversionFail = useInversionFail(engines)
|
||||
const inversionFail = useInversionFail()
|
||||
const hiddenNotifications = useSelector(
|
||||
(state: RootState) => state.simulation?.hiddenNotifications
|
||||
)
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { OverlayProvider } from '@react-aria/overlays'
|
||||
import { ErrorBoundary } from '@sentry/react'
|
||||
import i18next from 'i18next'
|
||||
import Engine from 'publicodes'
|
||||
import { createContext, ReactNode } from 'react'
|
||||
import { HelmetProvider } from 'react-helmet-async'
|
||||
import { I18nextProvider } from 'react-i18next'
|
||||
|
@ -13,7 +14,7 @@ import DesignSystemThemeProvider from '@/design-system/root'
|
|||
import { EmbededContextProvider } from '@/hooks/useIsEmbedded'
|
||||
|
||||
import * as safeLocalStorage from '../storage/safeLocalStorage'
|
||||
import { store } from '../store/store'
|
||||
import { makeStore } from '../store/store'
|
||||
import { TrackingContext } from './ATInternetTracking'
|
||||
import { createTracker } from './ATInternetTracking/Tracker'
|
||||
import { ErrorFallback } from './ErrorPage'
|
||||
|
@ -29,12 +30,16 @@ export const SiteNameContext = createContext<SiteName | null>(null)
|
|||
export type ProviderProps = {
|
||||
basename: SiteName
|
||||
children: ReactNode
|
||||
engine: Engine
|
||||
}
|
||||
|
||||
export default function Provider({
|
||||
basename,
|
||||
children,
|
||||
engine,
|
||||
}: ProviderProps): JSX.Element {
|
||||
const store = makeStore(engine)
|
||||
|
||||
return (
|
||||
<EmbededContextProvider>
|
||||
<DarkModeProvider>
|
||||
|
|
|
@ -6,12 +6,10 @@ import { Spacing } from '@/design-system/layout'
|
|||
import { Link } from '@/design-system/typography/link'
|
||||
import { SmallBody } from '@/design-system/typography/paragraphs'
|
||||
import { useNextQuestions } from '@/hooks/useNextQuestion'
|
||||
import { goToQuestion } from '@/store/actions/actions'
|
||||
import { vaÀLaQuestion } from '@/store/actions/actions'
|
||||
import { RootState } from '@/store/reducers/rootReducer'
|
||||
import {
|
||||
answeredQuestionsSelector,
|
||||
currentQuestionSelector,
|
||||
} from '@/store/selectors/simulationSelectors'
|
||||
import { currentQuestionSelector } from '@/store/selectors/currentQuestion.selector'
|
||||
import { answeredQuestionsSelector } from '@/store/selectors/simulationSelectors'
|
||||
|
||||
export default function QuickLinks() {
|
||||
const currentQuestion = useSelector(currentQuestionSelector)
|
||||
|
@ -43,7 +41,7 @@ export default function QuickLinks() {
|
|||
<StyledLink
|
||||
key={dottedName}
|
||||
$underline={dottedName === currentQuestion}
|
||||
onPress={() => dispatch(goToQuestion(dottedName))}
|
||||
onPress={() => dispatch(vaÀLaQuestion(dottedName))}
|
||||
aria-label={t('{{question}}, aller à la question : {{question}}', {
|
||||
question: label,
|
||||
})}
|
||||
|
|
|
@ -6,7 +6,7 @@ import { styled } from 'styled-components'
|
|||
import Banner from '@/components/Banner'
|
||||
import { EngineContext } from '@/components/utils/EngineContext'
|
||||
import { Link as DesignSystemLink } from '@/design-system/typography/link'
|
||||
import { updateSituation } from '@/store/actions/actions'
|
||||
import { enregistreLaRéponse } from '@/store/actions/actions'
|
||||
|
||||
const Bold = styled.span<{ $bold: boolean }>`
|
||||
${({ $bold }) => ($bold ? 'font-weight: bold;' : '')}
|
||||
|
@ -36,7 +36,7 @@ export const SelectSimulationYear = () => {
|
|||
<span key={year}>
|
||||
<DesignSystemLink
|
||||
onPress={() =>
|
||||
dispatch(updateSituation('date', `01/01/${year}`))
|
||||
dispatch(enregistreLaRéponse('date', `01/01/${year}`))
|
||||
}
|
||||
>
|
||||
{actualYear === 2024 ? (
|
||||
|
|
|
@ -15,6 +15,7 @@ import {
|
|||
} from '@/hooks/useCurrentSimulatorData'
|
||||
import { useIsEmbedded } from '@/hooks/useIsEmbedded'
|
||||
import useSimulationConfig from '@/hooks/useSimulationConfig'
|
||||
import { Simulation } from '@/store/reducers/simulation.reducer'
|
||||
import { situationSelector } from '@/store/selectors/simulationSelectors'
|
||||
import { Merge } from '@/types/utils'
|
||||
|
||||
|
@ -51,7 +52,7 @@ export default function SimulateurOrAssistantPage() {
|
|||
const inIframe = useIsEmbedded()
|
||||
useSimulationConfig({
|
||||
key: path,
|
||||
config: simulation,
|
||||
config: simulation as Simulation,
|
||||
autoloadLastSimulation,
|
||||
})
|
||||
useSearchParamsSimulationSharing()
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import { DottedName } from 'modele-social'
|
||||
import Engine from 'publicodes'
|
||||
import { Trans } from 'react-i18next'
|
||||
import { styled } from 'styled-components'
|
||||
|
||||
|
@ -8,7 +6,7 @@ import Conversation, {
|
|||
} from '@/components/conversation/Conversation'
|
||||
import Progress from '@/components/ui/Progress'
|
||||
import { Body } from '@/design-system/typography/paragraphs'
|
||||
import { useSimulationProgress } from '@/hooks/useNextQuestion'
|
||||
import { useSimulationProgress } from '@/hooks/useSimulationProgress'
|
||||
|
||||
const QuestionsContainer = styled.div`
|
||||
padding: ${({ theme }) => ` ${theme.spacings.xs} ${theme.spacings.lg}`};
|
||||
|
@ -27,10 +25,8 @@ const Notice = styled(Body)`
|
|||
|
||||
export function Questions({
|
||||
customEndMessages,
|
||||
engines,
|
||||
}: {
|
||||
customEndMessages?: ConversationProps['customEndMessages']
|
||||
engines?: Array<Engine<DottedName>>
|
||||
}) {
|
||||
const { numberCurrentStep, numberSteps } = useSimulationProgress()
|
||||
|
||||
|
@ -47,7 +43,7 @@ export function Questions({
|
|||
</Notice>
|
||||
)}
|
||||
</div>
|
||||
<Conversation customEndMessages={customEndMessages} engines={engines} />
|
||||
<Conversation customEndMessages={customEndMessages} />
|
||||
</QuestionsContainer>
|
||||
</>
|
||||
)
|
||||
|
|
|
@ -8,7 +8,7 @@ import { ForceThemeProvider } from '@/components/utils/DarkModeContext'
|
|||
import { Grid } from '@/design-system/layout'
|
||||
import { Strong } from '@/design-system/typography'
|
||||
import { Body, SmallBody } from '@/design-system/typography/paragraphs'
|
||||
import { updateSituation } from '@/store/actions/actions'
|
||||
import { enregistreLaRéponse } from '@/store/actions/actions'
|
||||
import { targetUnitSelector } from '@/store/selectors/simulationSelectors'
|
||||
|
||||
import { ExplicableRule } from '../conversation/Explicable'
|
||||
|
@ -60,7 +60,7 @@ export function SimulationGoal({
|
|||
const [isFocused, setFocused] = useState(false)
|
||||
const onChange = useCallback(
|
||||
(x?: PublicodesExpression) => {
|
||||
dispatch(updateSituation(dottedName, x))
|
||||
dispatch(enregistreLaRéponse(dottedName, x))
|
||||
onUpdateSituation?.(dottedName, x)
|
||||
},
|
||||
[dispatch, onUpdateSituation, dottedName]
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import { DottedName } from 'modele-social'
|
||||
import Engine from 'publicodes'
|
||||
import React from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { useSelector } from 'react-redux'
|
||||
|
@ -31,7 +29,6 @@ export { SimulationGoals } from './SimulationGoals'
|
|||
|
||||
type SimulationProps = {
|
||||
explanations?: React.ReactNode
|
||||
engines?: Array<Engine<DottedName>>
|
||||
results?: React.ReactNode
|
||||
children?: React.ReactNode
|
||||
afterQuestionsSlot?: React.ReactNode
|
||||
|
@ -59,7 +56,6 @@ export default function Simulation({
|
|||
afterQuestionsSlot,
|
||||
customEndMessages,
|
||||
showQuestionsFromBeginning,
|
||||
engines,
|
||||
hideDetails = false,
|
||||
fullWidth,
|
||||
id,
|
||||
|
@ -85,10 +81,7 @@ export default function Simulation({
|
|||
<div className="print-hidden">
|
||||
<FromTop>{results}</FromTop>
|
||||
</div>
|
||||
<Questions
|
||||
engines={engines}
|
||||
customEndMessages={customEndMessages}
|
||||
/>
|
||||
<Questions customEndMessages={customEndMessages} />
|
||||
</>
|
||||
)}
|
||||
<Spacing md />
|
||||
|
|
|
@ -17,7 +17,7 @@ import { Link } from '@/design-system/typography/link'
|
|||
import { Body, Intro } from '@/design-system/typography/paragraphs'
|
||||
import { useCurrentSimulatorData } from '@/hooks/useCurrentSimulatorData'
|
||||
import { useNextQuestions } from '@/hooks/useNextQuestion'
|
||||
import { answerQuestion, resetSimulation } from '@/store/actions/actions'
|
||||
import { enregistreLaRéponse, resetSimulation } from '@/store/actions/actions'
|
||||
import { resetCompany } from '@/store/actions/companyActions'
|
||||
import { isCompanyDottedName } from '@/store/reducers/companySituationReducer'
|
||||
import {
|
||||
|
@ -284,7 +284,8 @@ function AnswerElement(rule: RuleNode) {
|
|||
|
||||
const handleChange = useCallback(
|
||||
(value: PublicodesExpression | undefined) => {
|
||||
questionDottedName && dispatch(answerQuestion(questionDottedName, value))
|
||||
questionDottedName &&
|
||||
dispatch(enregistreLaRéponse(questionDottedName, value))
|
||||
},
|
||||
[dispatch, questionDottedName]
|
||||
)
|
||||
|
|
|
@ -1,96 +1,29 @@
|
|||
import { DottedName } from 'modele-social'
|
||||
import Engine, { PublicodesExpression } from 'publicodes'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
|
||||
import RuleInput from '@/components/conversation/RuleInput'
|
||||
import Notifications from '@/components/Notifications'
|
||||
import QuickLinks from '@/components/QuickLinks'
|
||||
import { useEngine } from '@/components/utils/EngineContext'
|
||||
import { Button } from '@/design-system/buttons'
|
||||
import { Emoji } from '@/design-system/emoji'
|
||||
import { Grid, Spacing } from '@/design-system/layout'
|
||||
import { H3 } from '@/design-system/typography/heading'
|
||||
import { Body } from '@/design-system/typography/paragraphs'
|
||||
import { useCurrentSimulatorData } from '@/hooks/useCurrentSimulatorData'
|
||||
import { answerQuestion } from '@/store/actions/actions'
|
||||
import {
|
||||
answeredQuestionsSelector,
|
||||
situationSelector,
|
||||
} from '@/store/selectors/simulationSelectors'
|
||||
import { evaluateQuestion } from '@/utils'
|
||||
import { QuestionEnCours } from '@/components/conversation/QuestionEnCours'
|
||||
import { VousAvezComplétéCetteSimulation } from '@/components/conversation/VousAvezComplétéCetteSimulation'
|
||||
import { answeredQuestionsSelector } from '@/store/selectors/simulationSelectors'
|
||||
|
||||
import { TrackPage } from '../ATInternetTracking'
|
||||
import { JeDonneMonAvis } from '../JeDonneMonAvis'
|
||||
import { FromTop } from '../ui/animate'
|
||||
import AnswerList from './AnswerList'
|
||||
import { ExplicableRule } from './Explicable'
|
||||
import SeeAnswersButton from './SeeAnswersButton'
|
||||
import { useNavigateQuestions } from './useNavigateQuestions'
|
||||
|
||||
export type ConversationProps = {
|
||||
customEndMessages?: React.ReactNode
|
||||
customSituationVisualisation?: React.ReactNode
|
||||
engines?: Array<Engine<DottedName>>
|
||||
}
|
||||
|
||||
export default function Conversation({
|
||||
customEndMessages,
|
||||
customSituationVisualisation,
|
||||
engines,
|
||||
}: ConversationProps) {
|
||||
const { currentSimulatorData } = useCurrentSimulatorData()
|
||||
const dispatch = useDispatch()
|
||||
const engine = useEngine()
|
||||
|
||||
const situation = useSelector(situationSelector)
|
||||
|
||||
const previousAnswers = useSelector(answeredQuestionsSelector)
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
currentQuestion,
|
||||
currentQuestionIsAnswered,
|
||||
goToPrevious: goToPreviousQuestion,
|
||||
goToNext: goToNextQuestion,
|
||||
} = useNavigateQuestions(engines)
|
||||
|
||||
const onChange = (
|
||||
value: PublicodesExpression | undefined,
|
||||
dottedName: DottedName
|
||||
) => {
|
||||
dispatch(answerQuestion(dottedName, value))
|
||||
}
|
||||
const { currentQuestion } = useNavigateQuestions()
|
||||
|
||||
const [firstRenderDone, setFirstRenderDone] = useState(false)
|
||||
useEffect(() => setFirstRenderDone(true), [])
|
||||
|
||||
const focusFirstElemInForm = useCallback(() => {
|
||||
setTimeout(() => {
|
||||
formRef.current
|
||||
?.querySelector<HTMLInputElement | HTMLButtonElement | HTMLLinkElement>(
|
||||
'input, button, a'
|
||||
)
|
||||
?.focus()
|
||||
}, 5)
|
||||
}, [])
|
||||
|
||||
const goToPrevious = useCallback(() => {
|
||||
goToPreviousQuestion()
|
||||
focusFirstElemInForm()
|
||||
}, [focusFirstElemInForm, goToPreviousQuestion])
|
||||
|
||||
const goToNext = useCallback(() => {
|
||||
goToNextQuestion()
|
||||
focusFirstElemInForm()
|
||||
}, [focusFirstElemInForm, goToNextQuestion])
|
||||
|
||||
const formRef = React.useRef<HTMLFormElement>(null)
|
||||
const isDateQuestion =
|
||||
currentQuestion && engine.getRule(currentQuestion).rawNode.type === 'date'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="print-only">
|
||||
|
@ -104,142 +37,17 @@ export default function Conversation({
|
|||
</div>
|
||||
<div className="print-hidden">
|
||||
{currentQuestion ? (
|
||||
<FromTop>
|
||||
{Object.keys(situation).length !== 0 && (
|
||||
<TrackPage name="simulation commencée" />
|
||||
)}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
goToNext()
|
||||
}}
|
||||
ref={formRef}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'baseline',
|
||||
}}
|
||||
>
|
||||
<H3 id="questionHeader" as="h2">
|
||||
{evaluateQuestion(engine, engine.getRule(currentQuestion))}
|
||||
<ExplicableRule light dottedName={currentQuestion} />
|
||||
</H3>
|
||||
</div>
|
||||
<fieldset>
|
||||
<legend className="sr-only">
|
||||
{t(
|
||||
'Répondez à quelques questions additionnelles afin de préciser votre résultat.'
|
||||
)}
|
||||
</legend>
|
||||
<RuleInput
|
||||
dottedName={currentQuestion}
|
||||
onChange={onChange}
|
||||
key={currentQuestion}
|
||||
onSubmit={goToNext}
|
||||
aria-labelledby="questionHeader"
|
||||
hideDefaultValue={isDateQuestion}
|
||||
/>
|
||||
</fieldset>
|
||||
<Grid container spacing={2}>
|
||||
{previousAnswers.length > 0 && (
|
||||
<Grid item xs={6} sm="auto">
|
||||
<Button
|
||||
color="primary"
|
||||
light
|
||||
onPress={goToPrevious}
|
||||
size="XS"
|
||||
>
|
||||
<span aria-hidden>←</span> <Trans>Précédent</Trans>
|
||||
</Button>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid item xs={6} sm="auto">
|
||||
<Button
|
||||
size="XS"
|
||||
onPress={goToNext}
|
||||
light={!currentQuestionIsAnswered ? true : undefined}
|
||||
aria-label={
|
||||
currentQuestionIsAnswered
|
||||
? t('Suivant, passer à la question suivante')
|
||||
: t('Passer, passer la question sans répondre')
|
||||
}
|
||||
>
|
||||
{currentQuestionIsAnswered ? (
|
||||
<Trans>Suivant</Trans>
|
||||
) : (
|
||||
<Trans>Passer</Trans>
|
||||
)}{' '}
|
||||
<span aria-hidden>→</span>
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
sm
|
||||
style={{
|
||||
justifyContent: 'flex-end',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
<SeeAnswersButton>
|
||||
{customSituationVisualisation}
|
||||
</SeeAnswersButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Notifications engines={engines} />
|
||||
</form>
|
||||
<QuickLinks />
|
||||
</FromTop>
|
||||
<QuestionEnCours
|
||||
previousAnswers={previousAnswers}
|
||||
customSituationVisualisation={customSituationVisualisation}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
{firstRenderDone && <TrackPage name="simulation terminée" />}
|
||||
<H3 as="h2">
|
||||
<Emoji emoji="🌟" />{' '}
|
||||
<Trans i18nKey="simulation-end.title">
|
||||
Vous avez complété cette simulation
|
||||
</Trans>
|
||||
</H3>
|
||||
<Body>
|
||||
{customEndMessages || (
|
||||
<Trans i18nKey="simulation-end.text">
|
||||
Vous avez maintenant accès à l'estimation la plus précise
|
||||
possible.
|
||||
</Trans>
|
||||
)}
|
||||
</Body>
|
||||
{currentSimulatorData?.pathId === 'simulateurs.salarié' && (
|
||||
<>
|
||||
<JeDonneMonAvis />
|
||||
<Spacing md />
|
||||
</>
|
||||
)}
|
||||
<Grid container spacing={2}>
|
||||
{previousAnswers.length > 0 && (
|
||||
<Grid item xs={6} sm="auto">
|
||||
<Button light onPress={goToPrevious} size="XS">
|
||||
<span aria-hidden>←</span> <Trans>Précédent</Trans>
|
||||
</Button>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid
|
||||
item
|
||||
xs={6}
|
||||
sm
|
||||
style={{
|
||||
justifyContent: 'flex-end',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
<SeeAnswersButton>
|
||||
{customSituationVisualisation}
|
||||
</SeeAnswersButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</div>
|
||||
<Notifications engines={engines} />
|
||||
</>
|
||||
<VousAvezComplétéCetteSimulation
|
||||
firstRenderDone={firstRenderDone}
|
||||
customEndMessages={customEndMessages}
|
||||
previousAnswers={previousAnswers}
|
||||
customSituationVisualisation={customSituationVisualisation}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
|
|
@ -0,0 +1,163 @@
|
|||
import { DottedName } from 'modele-social'
|
||||
import Engine, { PublicodesExpression } from 'publicodes'
|
||||
import React, { useCallback } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
|
||||
import { TrackPage } from '@/components/ATInternetTracking'
|
||||
import { ExplicableRule } from '@/components/conversation/Explicable'
|
||||
import RuleInput from '@/components/conversation/RuleInput'
|
||||
import SeeAnswersButton from '@/components/conversation/SeeAnswersButton'
|
||||
import { useNavigateQuestions } from '@/components/conversation/useNavigateQuestions'
|
||||
import Notifications from '@/components/Notifications'
|
||||
import QuickLinks from '@/components/QuickLinks'
|
||||
import { FromTop } from '@/components/ui/animate'
|
||||
import { useEngine } from '@/components/utils/EngineContext'
|
||||
import { Button } from '@/design-system/buttons'
|
||||
import { Grid } from '@/design-system/layout'
|
||||
import { H3 } from '@/design-system/typography/heading'
|
||||
import { enregistreLaRéponse } from '@/store/actions/actions'
|
||||
import { situationSelector } from '@/store/selectors/simulationSelectors'
|
||||
import { evaluateQuestion } from '@/utils'
|
||||
|
||||
interface Props {
|
||||
previousAnswers: DottedName[]
|
||||
customSituationVisualisation?: React.ReactNode
|
||||
}
|
||||
|
||||
export function QuestionEnCours({
|
||||
previousAnswers,
|
||||
customSituationVisualisation,
|
||||
}: Props) {
|
||||
const dispatch = useDispatch()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const engine = useEngine()
|
||||
|
||||
const situation = useSelector(situationSelector)
|
||||
|
||||
const { currentQuestion, currentQuestionIsAnswered, goToPrevious, goToNext } =
|
||||
useNavigateQuestions()
|
||||
|
||||
const formRef = React.useRef<HTMLFormElement>(null)
|
||||
|
||||
const focusFirstElemInForm = useCallback(() => {
|
||||
setTimeout(() => {
|
||||
formRef.current
|
||||
?.querySelector<HTMLInputElement | HTMLButtonElement | HTMLLinkElement>(
|
||||
'input, button, a'
|
||||
)
|
||||
?.focus()
|
||||
}, 5)
|
||||
}, [])
|
||||
|
||||
const handleGoToPrevious = useCallback(() => {
|
||||
goToPrevious()
|
||||
focusFirstElemInForm()
|
||||
}, [focusFirstElemInForm, goToPrevious])
|
||||
|
||||
const handleGoToNext = useCallback(() => {
|
||||
goToNext()
|
||||
focusFirstElemInForm()
|
||||
}, [focusFirstElemInForm, goToNext])
|
||||
|
||||
if (!currentQuestion) return null
|
||||
|
||||
const onChange = (
|
||||
value: PublicodesExpression | undefined,
|
||||
dottedName: DottedName
|
||||
) => {
|
||||
dispatch(enregistreLaRéponse(dottedName, value))
|
||||
}
|
||||
|
||||
const isDateQuestion =
|
||||
currentQuestion && engine.getRule(currentQuestion).rawNode.type === 'date'
|
||||
|
||||
return (
|
||||
<FromTop>
|
||||
{Object.keys(situation).length !== 0 && (
|
||||
<TrackPage name="simulation commencée" />
|
||||
)}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleGoToNext()
|
||||
}}
|
||||
ref={formRef}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'baseline',
|
||||
}}
|
||||
>
|
||||
<H3 id="questionHeader" as="h2">
|
||||
{evaluateQuestion(engine, engine.getRule(currentQuestion))}
|
||||
<ExplicableRule light dottedName={currentQuestion} />
|
||||
</H3>
|
||||
</div>
|
||||
<fieldset>
|
||||
<legend className="sr-only">
|
||||
{t(
|
||||
'Répondez à quelques questions additionnelles afin de préciser votre résultat.'
|
||||
)}
|
||||
</legend>
|
||||
<RuleInput
|
||||
dottedName={currentQuestion}
|
||||
onChange={onChange}
|
||||
key={currentQuestion}
|
||||
onSubmit={handleGoToNext}
|
||||
aria-labelledby="questionHeader"
|
||||
hideDefaultValue={isDateQuestion}
|
||||
/>
|
||||
</fieldset>
|
||||
<Grid container spacing={2}>
|
||||
{previousAnswers.length > 0 && (
|
||||
<Grid item xs={6} sm="auto">
|
||||
<Button
|
||||
color="primary"
|
||||
light
|
||||
onPress={handleGoToPrevious}
|
||||
size="XS"
|
||||
>
|
||||
<span aria-hidden>←</span> <Trans>Précédent</Trans>
|
||||
</Button>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid item xs={6} sm="auto">
|
||||
<Button
|
||||
size="XS"
|
||||
onPress={handleGoToNext}
|
||||
light={!currentQuestionIsAnswered ? true : undefined}
|
||||
aria-label={
|
||||
currentQuestionIsAnswered
|
||||
? t('Suivant, passer à la question suivante')
|
||||
: t('Passer, passer la question sans répondre')
|
||||
}
|
||||
>
|
||||
{currentQuestionIsAnswered ? (
|
||||
<Trans>Suivant</Trans>
|
||||
) : (
|
||||
<Trans>Passer</Trans>
|
||||
)}{' '}
|
||||
<span aria-hidden>→</span>
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
sm
|
||||
style={{
|
||||
justifyContent: 'flex-end',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
<SeeAnswersButton>{customSituationVisualisation}</SeeAnswersButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Notifications />
|
||||
</form>
|
||||
<QuickLinks />
|
||||
</FromTop>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,83 @@
|
|||
import { DottedName } from 'modele-social'
|
||||
import Engine from 'publicodes'
|
||||
import React from 'react'
|
||||
import { Trans } from 'react-i18next'
|
||||
|
||||
import { TrackPage } from '@/components/ATInternetTracking'
|
||||
import SeeAnswersButton from '@/components/conversation/SeeAnswersButton'
|
||||
import { useNavigateQuestions } from '@/components/conversation/useNavigateQuestions'
|
||||
import { JeDonneMonAvis } from '@/components/JeDonneMonAvis'
|
||||
import Notifications from '@/components/Notifications'
|
||||
import { Button } from '@/design-system/buttons'
|
||||
import { Emoji } from '@/design-system/emoji'
|
||||
import { Grid, Spacing } from '@/design-system/layout'
|
||||
import { H3 } from '@/design-system/typography/heading'
|
||||
import { Body } from '@/design-system/typography/paragraphs'
|
||||
import { useCurrentSimulatorData } from '@/hooks/useCurrentSimulatorData'
|
||||
|
||||
interface Props {
|
||||
firstRenderDone: boolean
|
||||
customEndMessages?: React.ReactNode
|
||||
previousAnswers: DottedName[]
|
||||
customSituationVisualisation?: React.ReactNode
|
||||
}
|
||||
|
||||
export function VousAvezComplétéCetteSimulation({
|
||||
firstRenderDone,
|
||||
customEndMessages,
|
||||
previousAnswers,
|
||||
customSituationVisualisation,
|
||||
}: Props) {
|
||||
const { currentSimulatorData } = useCurrentSimulatorData()
|
||||
|
||||
const { goToPrevious } = useNavigateQuestions()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
{firstRenderDone && <TrackPage name="simulation terminée" />}
|
||||
<H3 as="h2">
|
||||
<Emoji emoji="🌟" />{' '}
|
||||
<Trans i18nKey="simulation-end.title">
|
||||
Vous avez complété cette simulation
|
||||
</Trans>
|
||||
</H3>
|
||||
<Body>
|
||||
{customEndMessages || (
|
||||
<Trans i18nKey="simulation-end.text">
|
||||
Vous avez maintenant accès à l'estimation la plus précise
|
||||
possible.
|
||||
</Trans>
|
||||
)}
|
||||
</Body>
|
||||
{currentSimulatorData?.pathId === 'simulateurs.salarié' && (
|
||||
<>
|
||||
<JeDonneMonAvis />
|
||||
<Spacing md />
|
||||
</>
|
||||
)}
|
||||
<Grid container spacing={2}>
|
||||
{previousAnswers.length > 0 && (
|
||||
<Grid item xs={6} sm="auto">
|
||||
<Button light onPress={goToPrevious} size="XS">
|
||||
<span aria-hidden>←</span> <Trans>Précédent</Trans>
|
||||
</Button>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid
|
||||
item
|
||||
xs={6}
|
||||
sm
|
||||
style={{
|
||||
justifyContent: 'flex-end',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
<SeeAnswersButton>{customSituationVisualisation}</SeeAnswersButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</div>
|
||||
<Notifications />
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -1,58 +1,27 @@
|
|||
import { DottedName } from 'modele-social'
|
||||
import Engine from 'publicodes'
|
||||
import { useEffect } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
|
||||
import { useEngine } from '@/components/utils/EngineContext'
|
||||
import { useNextQuestions } from '@/hooks/useNextQuestion'
|
||||
import {
|
||||
goToQuestion,
|
||||
stepAction,
|
||||
updateShouldFocusField,
|
||||
retourneÀLaQuestionPrécédente,
|
||||
vaÀLaQuestionSuivante,
|
||||
} from '@/store/actions/actions'
|
||||
import {
|
||||
answeredQuestionsSelector,
|
||||
currentQuestionSelector,
|
||||
urlSelector,
|
||||
useMissingVariables,
|
||||
} from '@/store/selectors/simulationSelectors'
|
||||
import { currentQuestionSelector } from '@/store/selectors/currentQuestion.selector'
|
||||
import { questionEnCoursRépondueSelector } from '@/store/selectors/questionEnCoursRépondue.selector'
|
||||
|
||||
export function useNavigateQuestions(engines?: Array<Engine<DottedName>>) {
|
||||
export function useNavigateQuestions() {
|
||||
const dispatch = useDispatch()
|
||||
const engine = useEngine()
|
||||
const nextQuestion = useNextQuestions(engines)[0]
|
||||
const currentQuestion = useSelector(currentQuestionSelector)
|
||||
const url = useSelector(urlSelector)
|
||||
|
||||
const missingVariables = useMissingVariables({ engines: engines ?? [engine] })
|
||||
const currentQuestionIsAnswered =
|
||||
currentQuestion && !(currentQuestion in missingVariables)
|
||||
|
||||
const previousAnswers = useSelector(answeredQuestionsSelector)
|
||||
const currentQuestionIsAnswered = useSelector(questionEnCoursRépondueSelector)
|
||||
|
||||
const goToPrevious = () => {
|
||||
dispatch(updateShouldFocusField(true))
|
||||
dispatch(goToQuestion(previousAnswers.slice(-1)[0]))
|
||||
dispatch(retourneÀLaQuestionPrécédente())
|
||||
}
|
||||
const goToNext = () => {
|
||||
dispatch(updateShouldFocusField(true))
|
||||
if (currentQuestion) {
|
||||
dispatch(stepAction(currentQuestion))
|
||||
}
|
||||
dispatch(vaÀLaQuestionSuivante())
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentQuestion && nextQuestion) {
|
||||
dispatch(goToQuestion(nextQuestion))
|
||||
}
|
||||
}, [nextQuestion, currentQuestion])
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(goToQuestion(nextQuestion))
|
||||
}, [url])
|
||||
|
||||
return {
|
||||
currentQuestion: currentQuestion ?? nextQuestion,
|
||||
currentQuestion,
|
||||
currentQuestionIsAnswered,
|
||||
goToPrevious,
|
||||
goToNext,
|
||||
|
|
|
@ -6,7 +6,7 @@ import Engine, {
|
|||
Rule,
|
||||
RuleNode,
|
||||
} from 'publicodes'
|
||||
import { createContext, useContext, useMemo } from 'react'
|
||||
import { createContext, useContext } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
|
||||
import { deleteFromSituation } from '@/store/actions/actions'
|
||||
|
@ -14,6 +14,7 @@ import {
|
|||
companySituationSelector,
|
||||
configObjectifsSelector,
|
||||
configSituationSelector,
|
||||
rawSituationSelector,
|
||||
situationSelector,
|
||||
} from '@/store/selectors/simulationSelectors'
|
||||
import { omit } from '@/utils'
|
||||
|
@ -79,22 +80,7 @@ export function useEngine() {
|
|||
return useContext(EngineContext) as Engine<DottedName>
|
||||
}
|
||||
|
||||
export const useRawSituation = () => {
|
||||
const simulatorSituation = useSelector(situationSelector)
|
||||
const configSituation = useSelector(configSituationSelector)
|
||||
const companySituation = useSelector(companySituationSelector)
|
||||
|
||||
const situation: Partial<Record<DottedName, PublicodesExpression>> = useMemo(
|
||||
() => ({
|
||||
...companySituation,
|
||||
...configSituation,
|
||||
...simulatorSituation,
|
||||
}),
|
||||
[configSituation, simulatorSituation, companySituation]
|
||||
)
|
||||
|
||||
return situation
|
||||
}
|
||||
export const useRawSituation = () => useSelector(rawSituationSelector)
|
||||
|
||||
/**
|
||||
* Try to set situation and delete all rules with syntax/evaluation error
|
||||
|
@ -194,11 +180,10 @@ export const useSetupSafeSituation = (engine: Engine<DottedName>) => {
|
|||
}
|
||||
}
|
||||
|
||||
export function useInversionFail(engines?: Array<Engine<DottedName>>) {
|
||||
export function useInversionFail() {
|
||||
const engine = useEngine()
|
||||
const enginesToUse = engines ?? [engine]
|
||||
const objectifs = useSelector(configObjectifsSelector).flatMap((objectif) =>
|
||||
enginesToUse.map((e) => e.evaluate(objectif).nodeValue)
|
||||
const objectifs = useSelector(configObjectifsSelector).map(
|
||||
(objectif) => engine.evaluate(objectif).nodeValue
|
||||
)
|
||||
|
||||
const inversionFail =
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
import { Contexte } from '@/domaine/Contexte'
|
||||
|
||||
export const AssimiléSalariéContexte: Contexte = {
|
||||
'entreprise . imposition': "'IS'",
|
||||
'entreprise . catégorie juridique': "'SAS'",
|
||||
'entreprise . associés': "'unique'",
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
import { Contexte } from '@/domaine/Contexte'
|
||||
|
||||
export const AutoentrepreneurContexte: Contexte = {
|
||||
'entreprise . catégorie juridique': "'EI'",
|
||||
'entreprise . catégorie juridique . EI . auto-entrepreneur': 'oui',
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import { Contexte } from '@/domaine/Contexte'
|
||||
|
||||
export const IndépendantContexte: Contexte = {
|
||||
'entreprise . imposition': "'IS'",
|
||||
'entreprise . catégorie juridique': "'EI'",
|
||||
'entreprise . catégorie juridique . EI . auto-entrepreneur': 'non',
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
import { NonEmptyReadonlyArray } from 'effect/Array'
|
||||
|
||||
import { Contexte } from '@/domaine/Contexte'
|
||||
import { SimulationConfig } from '@/domaine/SimulationConfig'
|
||||
|
||||
export interface ComparateurConfig extends SimulationConfig {
|
||||
contextes: NonEmptyReadonlyArray<Contexte>
|
||||
}
|
||||
|
||||
export const isComparateurConfig = (
|
||||
config: SimulationConfig
|
||||
): config is ComparateurConfig => 'contextes' in config
|
|
@ -0,0 +1,3 @@
|
|||
import { Situation } from '@/domaine/Situation'
|
||||
|
||||
export type Contexte = Situation
|
|
@ -0,0 +1,51 @@
|
|||
import { DottedName } from 'modele-social'
|
||||
|
||||
import { Situation } from './Situation'
|
||||
|
||||
export type SimulationConfig = Partial<{
|
||||
/**
|
||||
* Objectifs exclusifs de la simulation : si une règle change dans la situation
|
||||
* et qu'elle est dans `objectifs exclusifs`, alors toute les autres règles
|
||||
* dans `objectifs exclusifs` seront supprimées de la situation
|
||||
*/
|
||||
'objectifs exclusifs': DottedName[]
|
||||
|
||||
/**
|
||||
* Objectifs de la simulation
|
||||
*/
|
||||
objectifs?: DottedName[]
|
||||
|
||||
/**
|
||||
* La situation de base du simulateur
|
||||
*/
|
||||
situation: Situation
|
||||
|
||||
questions: {
|
||||
/**
|
||||
* Question non prioritaires, elles aparraitront en fin de simulation
|
||||
*/
|
||||
'non prioritaires'?: DottedName[]
|
||||
|
||||
/**
|
||||
* Whitelist des questions qui sont affiché à l'utilisateur.
|
||||
* Cela peut également servir pour prioriser des questions
|
||||
* en mettant une string vide comme dernier élément
|
||||
*/
|
||||
liste?: (DottedName | '')[]
|
||||
|
||||
/**
|
||||
* Questions qui ne sont pas affiché à l'utilisateur
|
||||
*/
|
||||
'liste noire'?: DottedName[]
|
||||
|
||||
/**
|
||||
* Questions "raccourcis" sélectionnables en bas du simulateur
|
||||
*/
|
||||
"à l'affiche"?: {
|
||||
label: string
|
||||
dottedName: DottedName
|
||||
}[]
|
||||
}
|
||||
|
||||
'unité par défaut'?: string
|
||||
}>
|
|
@ -0,0 +1,6 @@
|
|||
import { DottedName } from 'modele-social'
|
||||
import { ASTNode, PublicodesExpression } from 'publicodes'
|
||||
|
||||
export type Situation = Partial<
|
||||
Record<DottedName, PublicodesExpression | ASTNode>
|
||||
>
|
|
@ -0,0 +1,69 @@
|
|||
import { Order, pipe } from 'effect'
|
||||
import { filter, map, sort } from 'effect/Array'
|
||||
import { DottedName } from 'modele-social'
|
||||
import Engine from 'publicodes'
|
||||
|
||||
import { ComparateurConfig } from '@/domaine/ComparateurConfig'
|
||||
import { listeLesVariablesManquantes } from '@/domaine/engine/listeLesVariablesManquantes'
|
||||
import { SimulationConfig } from '@/domaine/SimulationConfig'
|
||||
|
||||
export const détermineLesProchainesQuestions = (
|
||||
engine: Engine,
|
||||
config: SimulationConfig | ComparateurConfig,
|
||||
answeredQuestions: Array<DottedName> = []
|
||||
): Array<DottedName> => {
|
||||
const variablesManquantes = listeLesVariablesManquantes(
|
||||
engine,
|
||||
config.objectifs || [],
|
||||
'contextes' in config ? config.contextes : undefined
|
||||
)
|
||||
|
||||
const {
|
||||
liste = [],
|
||||
'liste noire': listeNoire = [],
|
||||
'non prioritaires': nonPrioritaires = [],
|
||||
} = config.questions || {}
|
||||
|
||||
const score = (question: string) => {
|
||||
const indexList = liste.findIndex((name) => question.startsWith(name)) + 1
|
||||
const indexNonPrioritaire =
|
||||
nonPrioritaires.findIndex((name) => question.startsWith(name)) + 1
|
||||
const différenceCoeff = questionDifference(
|
||||
question,
|
||||
answeredQuestions.slice(-1)[0]
|
||||
)
|
||||
|
||||
return indexList + indexNonPrioritaire + différenceCoeff
|
||||
}
|
||||
|
||||
const nextSteps = pipe(
|
||||
variablesManquantes,
|
||||
Object.entries,
|
||||
sort(([, a], [, b]) => Order.number(b, a)),
|
||||
map(([name]) => name as DottedName),
|
||||
filter((name) => !answeredQuestions.includes(name)),
|
||||
filter(
|
||||
(step) =>
|
||||
(!liste.length || liste.some((name) => step.startsWith(name))) &&
|
||||
(!listeNoire.length || !listeNoire.some((name) => step === name))
|
||||
),
|
||||
sort((a: DottedName, b: DottedName) => Order.number(score(a), score(b))),
|
||||
filter(
|
||||
(question) => engine.getRule(question).rawNode.question !== undefined
|
||||
)
|
||||
)
|
||||
|
||||
return nextSteps
|
||||
}
|
||||
|
||||
// Max : 1
|
||||
// Min -> 0
|
||||
const questionDifference = (ruleA = '', ruleB = '') => {
|
||||
if (ruleA === ruleB) {
|
||||
return 0
|
||||
}
|
||||
const partsA = ruleA.split(' . ')
|
||||
const partsB = ruleB.split(' . ')
|
||||
|
||||
return 1 / (1 + partsA.findIndex((val, i) => partsB?.[i] !== val))
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
import { pipe } from 'effect'
|
||||
import { flatMap, NonEmptyReadonlyArray, reduce } from 'effect/Array'
|
||||
import { DottedName } from 'modele-social'
|
||||
import Engine, { utils } from 'publicodes'
|
||||
|
||||
import { Contexte } from '@/domaine/Contexte'
|
||||
import { Situation } from '@/domaine/Situation'
|
||||
|
||||
export const evalueDansLeContexte =
|
||||
(engine: Engine, contexte: Contexte) => (expression: DottedName) =>
|
||||
engine.evaluate({
|
||||
value: expression,
|
||||
contexte,
|
||||
})
|
||||
|
||||
export type MissingVariables = Partial<Record<DottedName, number>> | undefined
|
||||
|
||||
export const listeLesVariablesManquantes = (
|
||||
engine: Engine,
|
||||
objectifs: ReadonlyArray<DottedName>,
|
||||
contextes?: NonEmptyReadonlyArray<Situation> | undefined
|
||||
) => {
|
||||
console.log('engine', engine)
|
||||
|
||||
return pipe(
|
||||
objectifs,
|
||||
flatMap((objectif) =>
|
||||
contextes
|
||||
? contextes.map(
|
||||
(contexte) =>
|
||||
evalueDansLeContexte(engine, contexte)(objectif)
|
||||
.missingVariables ?? {}
|
||||
)
|
||||
: [engine.evaluate(objectif).missingVariables ?? {}]
|
||||
),
|
||||
reduce({}, mergeMissing),
|
||||
treatAPIMissingVariables(engine)
|
||||
)
|
||||
}
|
||||
|
||||
const mergeMissing = (
|
||||
left: Record<string, number> | undefined = {},
|
||||
right: Record<string, number> | undefined = {}
|
||||
): Record<string, number> =>
|
||||
Object.fromEntries(
|
||||
[...Object.keys(left), ...Object.keys(right)].map((key) => [
|
||||
key,
|
||||
(left[key] ?? 0) + (right[key] ?? 0),
|
||||
])
|
||||
)
|
||||
|
||||
/**
|
||||
* Merge objectifs missings that depends on the same input field.
|
||||
*
|
||||
* For instance, the commune field (API) will fill `commune . nom` `commune . taux versement transport`, `commune . département`, etc.
|
||||
*/
|
||||
const treatAPIMissingVariables =
|
||||
<Name extends string>(engine: Engine<Name>) =>
|
||||
(
|
||||
missingVariables: Partial<Record<Name, number>>
|
||||
): Partial<Record<Name, number>> =>
|
||||
(Object.entries(missingVariables) as Array<[Name, number]>).reduce(
|
||||
(missings, [name, value]: [Name, number]) => {
|
||||
const parentName = utils.ruleParent(name) as Name
|
||||
if (parentName && engine.getRule(parentName).rawNode.API) {
|
||||
missings[parentName] = (missings[parentName] ?? 0) + value
|
||||
|
||||
return missings
|
||||
}
|
||||
missings[name] = value
|
||||
|
||||
return missings
|
||||
},
|
||||
{} as Partial<Record<Name, number>>
|
||||
)
|
|
@ -0,0 +1,37 @@
|
|||
import { DottedName } from 'modele-social'
|
||||
import { PublicodesExpression } from 'publicodes'
|
||||
|
||||
import { ImmutableType } from '@/types/utils'
|
||||
import { objectTransform, omit } from '@/utils'
|
||||
|
||||
import { SimulationConfig } from './SimulationConfig'
|
||||
import { Situation } from './Situation'
|
||||
|
||||
export function updateSituation(
|
||||
config: ImmutableType<SimulationConfig>,
|
||||
currentSituation: Situation,
|
||||
dottedName: DottedName,
|
||||
value: PublicodesExpression | undefined
|
||||
): Situation {
|
||||
if (value === undefined) {
|
||||
return omit(currentSituation, dottedName)
|
||||
}
|
||||
|
||||
const objectifsExclusifs = config['objectifs exclusifs'] ?? []
|
||||
|
||||
if (!objectifsExclusifs.includes(dottedName)) {
|
||||
return { ...currentSituation, [dottedName]: value }
|
||||
}
|
||||
|
||||
const objectifsToReset = objectifsExclusifs.filter(
|
||||
(name) => name !== dottedName
|
||||
)
|
||||
|
||||
const clearedSituation = objectTransform(currentSituation, (entries) =>
|
||||
entries.filter(
|
||||
([dottedName]) => !objectifsToReset.includes(dottedName as DottedName)
|
||||
)
|
||||
)
|
||||
|
||||
return { ...clearedSituation, [dottedName]: value }
|
||||
}
|
|
@ -1,105 +1,7 @@
|
|||
import { DottedName } from 'modele-social'
|
||||
import Engine from 'publicodes'
|
||||
import { useMemo } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
|
||||
import { SimulationConfig } from '@/store/reducers/rootReducer'
|
||||
import {
|
||||
answeredQuestionsSelector,
|
||||
configSelector,
|
||||
useMissingVariables,
|
||||
} from '@/store/selectors/simulationSelectors'
|
||||
import { ImmutableType } from '@/types/utils'
|
||||
import { questionsSuivantesSelector } from '@/store/selectors/questionsSuivantes.selector'
|
||||
|
||||
import { useEngine } from '../components/utils/EngineContext'
|
||||
|
||||
type MissingVariables = Partial<Record<DottedName, number>>
|
||||
|
||||
// Max : 1
|
||||
// Min -> 0
|
||||
const questionDifference = (ruleA = '', ruleB = '') => {
|
||||
if (ruleA === ruleB) {
|
||||
return 0
|
||||
}
|
||||
const partsA = ruleA.split(' . ')
|
||||
const partsB = ruleB.split(' . ')
|
||||
|
||||
return 1 / (1 + partsA.findIndex((val, i) => partsB?.[i] !== val))
|
||||
}
|
||||
|
||||
export function getNextQuestions(
|
||||
missingVariables: MissingVariables,
|
||||
questionConfig: ImmutableType<SimulationConfig['questions']> = {},
|
||||
answeredQuestions: Array<DottedName> = []
|
||||
): Array<DottedName> {
|
||||
const {
|
||||
'non prioritaires': notPriority = [],
|
||||
liste: whitelist = [],
|
||||
'liste noire': blacklist = [],
|
||||
} = questionConfig
|
||||
|
||||
const nextSteps = Object.entries(missingVariables)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([a]) => a as DottedName)
|
||||
.filter((name) => !answeredQuestions.includes(name))
|
||||
.filter(
|
||||
(step) =>
|
||||
(!whitelist.length ||
|
||||
whitelist.some((name) => step.startsWith(name))) &&
|
||||
(!blacklist.length || !blacklist.some((name) => step === name))
|
||||
)
|
||||
|
||||
const score = (question: string) => {
|
||||
const indexList =
|
||||
whitelist.findIndex((name) => question.startsWith(name)) + 1
|
||||
const indexNotPriority =
|
||||
notPriority.findIndex((name) => question.startsWith(name)) + 1
|
||||
const differenceCoeff = questionDifference(
|
||||
question,
|
||||
answeredQuestions.slice(-1)[0]
|
||||
)
|
||||
|
||||
return indexList + indexNotPriority + differenceCoeff
|
||||
}
|
||||
|
||||
// The higher the score, the less important the question
|
||||
return nextSteps.sort((a, b) => score(a) - score(b))
|
||||
}
|
||||
|
||||
export const useNextQuestions = function (
|
||||
engines?: Array<Engine<DottedName>>
|
||||
): Array<DottedName> {
|
||||
const answeredQuestions = useSelector(answeredQuestionsSelector)
|
||||
const config = useSelector(configSelector)
|
||||
const engine = useEngine()
|
||||
const missingVariables = useMissingVariables({ engines: engines ?? [engine] })
|
||||
const nextQuestions = useMemo(() => {
|
||||
const next = getNextQuestions(
|
||||
missingVariables,
|
||||
config.questions ?? {},
|
||||
answeredQuestions
|
||||
)
|
||||
|
||||
return next.filter(
|
||||
(question) => engine.getRule(question).rawNode.question !== undefined
|
||||
)
|
||||
}, [missingVariables, config.questions, answeredQuestions, engine])
|
||||
|
||||
return nextQuestions
|
||||
}
|
||||
|
||||
export function useSimulationProgress(): {
|
||||
progressRatio: number
|
||||
numberCurrentStep: number
|
||||
numberSteps: number
|
||||
} {
|
||||
const numberQuestionAnswered = useSelector(answeredQuestionsSelector).length
|
||||
const numberQuestionLeft = useNextQuestions().length
|
||||
|
||||
return {
|
||||
progressRatio:
|
||||
numberQuestionAnswered / (numberQuestionAnswered + numberQuestionLeft),
|
||||
numberCurrentStep: numberQuestionAnswered,
|
||||
numberSteps: numberQuestionAnswered + numberQuestionLeft,
|
||||
}
|
||||
}
|
||||
export const useNextQuestions = (): Array<DottedName> =>
|
||||
useSelector(questionsSuivantesSelector) || []
|
||||
|
|
|
@ -4,7 +4,7 @@ import { useDispatch, useSelector } from 'react-redux'
|
|||
|
||||
import { useEngine } from '@/components/utils/EngineContext'
|
||||
import { useNextQuestions } from '@/hooks/useNextQuestion'
|
||||
import { stepAction, updateSituation } from '@/store/actions/actions'
|
||||
import { enregistreLaRéponse } from '@/store/actions/actions'
|
||||
import { answeredQuestionsSelector } from '@/store/selectors/simulationSelectors'
|
||||
|
||||
export function useQuestionList(): [
|
||||
|
@ -25,10 +25,10 @@ export function useQuestionList(): [
|
|||
|
||||
const onQuestionAnswered =
|
||||
(dottedName: DottedName) => (value?: PublicodesExpression) => {
|
||||
if (!answeredQuestions.includes(dottedName)) {
|
||||
dispatch(stepAction(dottedName))
|
||||
}
|
||||
dispatch(updateSituation(dottedName, value))
|
||||
// if (!answeredQuestions.includes(dottedName)) {
|
||||
// dispatch(vaÀLaQuestionSuivante())
|
||||
// }
|
||||
dispatch(enregistreLaRéponse(dottedName, value))
|
||||
}
|
||||
|
||||
return [questions, onQuestionAnswered]
|
||||
|
|
|
@ -1,20 +0,0 @@
|
|||
import { useEffect } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
|
||||
import { updateShouldFocusField } from '@/store/actions/actions'
|
||||
import { shouldFocusFieldSelector } from '@/store/selectors/simulationSelectors'
|
||||
|
||||
// TODO: remove this hook, is not used anymore
|
||||
export const useShouldFocusField = () => {
|
||||
const dispatch = useDispatch()
|
||||
const shouldFocusField = useSelector(shouldFocusFieldSelector)
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
dispatch(updateShouldFocusField(false))
|
||||
}, 0)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return shouldFocusField
|
||||
}
|
|
@ -10,8 +10,7 @@ import {
|
|||
setSimulationConfig,
|
||||
} from '@/store/actions/actions'
|
||||
import { SimulationConfig } from '@/store/reducers/rootReducer'
|
||||
import { configSelector } from '@/store/selectors/simulationSelectors'
|
||||
import { ImmutableType } from '@/types/utils'
|
||||
import { configSelector } from '@/store/selectors/config.selector'
|
||||
|
||||
export default function useSimulationConfig({
|
||||
key,
|
||||
|
@ -19,7 +18,7 @@ export default function useSimulationConfig({
|
|||
autoloadLastSimulation = false,
|
||||
}: {
|
||||
key: string
|
||||
config?: ImmutableType<SimulationConfig>
|
||||
config?: SimulationConfig
|
||||
autoloadLastSimulation?: boolean
|
||||
}) {
|
||||
const dispatch = useDispatch()
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
import { useSelector } from 'react-redux'
|
||||
|
||||
import { useNextQuestions } from '@/hooks/useNextQuestion'
|
||||
import { answeredQuestionsSelector } from '@/store/selectors/simulationSelectors'
|
||||
|
||||
export function useSimulationProgress(): {
|
||||
progressRatio: number
|
||||
numberCurrentStep: number
|
||||
numberSteps: number
|
||||
} {
|
||||
const numberQuestionAnswered = useSelector(answeredQuestionsSelector).length
|
||||
const numberQuestionLeft = useNextQuestions().length
|
||||
|
||||
return {
|
||||
progressRatio:
|
||||
numberQuestionAnswered / (numberQuestionAnswered + numberQuestionLeft),
|
||||
numberCurrentStep: numberQuestionAnswered,
|
||||
numberSteps: numberQuestionAnswered + numberQuestionLeft,
|
||||
}
|
||||
}
|
|
@ -8,7 +8,10 @@ import { useEngine } from '@/components/utils/EngineContext'
|
|||
import { usePersistingState } from '@/components/utils/persistState'
|
||||
import { HelpButtonWithPopover } from '@/design-system/buttons'
|
||||
import { Body } from '@/design-system/typography/paragraphs'
|
||||
import { answerQuestion, batchUpdateSituation } from '@/store/actions/actions'
|
||||
import {
|
||||
answerBatchQuestion,
|
||||
batchUpdateSituation,
|
||||
} from '@/store/actions/actions'
|
||||
|
||||
import Layout from './_components/Layout'
|
||||
import Navigation from './_components/Navigation'
|
||||
|
@ -68,18 +71,12 @@ function useCommuneSelection(): [
|
|||
|
||||
const handleChange = (commune: CommuneType) => {
|
||||
setState({ commune })
|
||||
dispatch(
|
||||
answerQuestion('établissement . commune', { batchUpdate: commune })
|
||||
)
|
||||
dispatch(answerBatchQuestion('établissement . commune', commune))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
state.commune &&
|
||||
dispatch(
|
||||
answerQuestion('établissement . commune', {
|
||||
batchUpdate: state.commune,
|
||||
})
|
||||
)
|
||||
dispatch(answerBatchQuestion('établissement . commune', state.commune))
|
||||
}, [])
|
||||
|
||||
const reset = () => {
|
||||
|
|
|
@ -9,12 +9,11 @@ import { Button } from '@/design-system/buttons'
|
|||
import { Container, Grid, Spacing } from '@/design-system/layout'
|
||||
import { Strong } from '@/design-system/typography'
|
||||
import { Intro } from '@/design-system/typography/paragraphs'
|
||||
import { EngineComparison } from '@/pages/simulateurs/comparaison-statuts/components/Comparateur'
|
||||
import Détails from '@/pages/simulateurs/comparaison-statuts/components/Détails'
|
||||
import ModifierOptions from '@/pages/simulateurs/comparaison-statuts/components/ModifierOptions'
|
||||
import RevenuEstimé from '@/pages/simulateurs/comparaison-statuts/components/RevenuEstimé'
|
||||
import StatutChoice from '@/pages/simulateurs/comparaison-statuts/components/StatutChoice'
|
||||
import { useCasParticuliers } from '@/pages/simulateurs/comparaison-statuts/contexts/CasParticuliers'
|
||||
import { EngineComparison } from '@/pages/simulateurs/comparaison-statuts/EngineComparison'
|
||||
import { useSitePaths } from '@/sitePaths'
|
||||
import { Situation } from '@/store/reducers/rootReducer'
|
||||
|
||||
|
@ -83,7 +82,6 @@ export default function Comparateur() {
|
|||
* @param statut
|
||||
*/
|
||||
function useStatutComparaison(): EngineComparison {
|
||||
const { isAutoEntrepreneurACREEnabled } = useCasParticuliers()
|
||||
const possibleStatuts = usePossibleStatuts()
|
||||
const situation = useRawSituation()
|
||||
const engine = useEngine()
|
||||
|
@ -100,7 +98,7 @@ function useStatutComparaison(): EngineComparison {
|
|||
for (const { name, engine } of namedEngines) {
|
||||
engine.setSituation({
|
||||
...situation,
|
||||
...getSituationFromStatut(name, isAutoEntrepreneurACREEnabled),
|
||||
...getSituationFromStatut(name),
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -129,10 +127,7 @@ function usePossibleStatuts(): Array<StatutType> {
|
|||
}
|
||||
}
|
||||
|
||||
function getSituationFromStatut(
|
||||
statut: StatutType,
|
||||
AEAcre: boolean
|
||||
): Situation {
|
||||
function getSituationFromStatut(statut: StatutType): Situation {
|
||||
return {
|
||||
'entreprise . catégorie juridique . remplacements': 'oui',
|
||||
'entreprise . catégorie juridique':
|
||||
|
@ -152,8 +147,5 @@ function getSituationFromStatut(
|
|||
'entreprise . associés': ['SARL', 'SAS', 'SELAS', 'SELARL'].includes(statut)
|
||||
? "'multiple'"
|
||||
: "'unique'",
|
||||
...(statut === 'AE'
|
||||
? { 'dirigeant . exonérations . ACRE': AEAcre ? 'oui' : 'non' }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ import { H5 } from '@/design-system/typography/heading'
|
|||
import { Link } from '@/design-system/typography/link'
|
||||
import { Body } from '@/design-system/typography/paragraphs'
|
||||
import { useIsEmbedded } from '@/hooks/useIsEmbedded'
|
||||
import { resetSimulation, updateSituation } from '@/store/actions/actions'
|
||||
import { enregistreLaRéponse, resetSimulation } from '@/store/actions/actions'
|
||||
|
||||
import SearchCodeAPE from '../recherche-code-ape/SearchCodeAPE'
|
||||
import Layout from './_components/Layout'
|
||||
|
@ -32,7 +32,7 @@ export default function RechercheActivité() {
|
|||
currentStepIsComplete={!!codeApe}
|
||||
onNextStep={() => {
|
||||
dispatch(
|
||||
updateSituation(
|
||||
enregistreLaRéponse(
|
||||
'entreprise . activités . principale . code APE',
|
||||
`'${codeApe}'`
|
||||
)
|
||||
|
|
|
@ -14,7 +14,7 @@ import { Spacing } from '@/design-system/layout'
|
|||
import { H3 } from '@/design-system/typography/heading'
|
||||
import { Intro, SmallBody } from '@/design-system/typography/paragraphs'
|
||||
import { useNextQuestions } from '@/hooks/useNextQuestion'
|
||||
import { updateSituation } from '@/store/actions/actions'
|
||||
import { enregistreLaRéponse } from '@/store/actions/actions'
|
||||
import {
|
||||
situationSelector,
|
||||
targetUnitSelector,
|
||||
|
@ -90,7 +90,7 @@ export function SimpleField(props: SimpleFieldProps) {
|
|||
const meta = getMeta<{ requis?: 'oui' | 'non' }>(rule.rawNode, {})
|
||||
const dispatchValue = useCallback(
|
||||
(value: PublicodesExpression | undefined, dottedName: DottedName) => {
|
||||
dispatch(updateSituation(dottedName, value))
|
||||
dispatch(enregistreLaRéponse(dottedName, value))
|
||||
},
|
||||
[dispatch]
|
||||
)
|
||||
|
|
|
@ -19,7 +19,7 @@ import { Li, Ul } from '@/design-system/typography/list'
|
|||
import { Body, Intro, SmallBody } from '@/design-system/typography/paragraphs'
|
||||
import useSimulationConfig from '@/hooks/useSimulationConfig'
|
||||
import { useSitePaths } from '@/sitePaths'
|
||||
import { updateSituation } from '@/store/actions/actions'
|
||||
import { enregistreLaRéponse } from '@/store/actions/actions'
|
||||
import { SimulationConfig } from '@/store/reducers/rootReducer'
|
||||
import { situationSelector } from '@/store/selectors/simulationSelectors'
|
||||
|
||||
|
@ -195,7 +195,7 @@ function ImpositionSection() {
|
|||
|
||||
const setSituation = useCallback(
|
||||
(value: PublicodesExpression | undefined, dottedName: DottedName) => {
|
||||
dispatch(updateSituation(dottedName, value))
|
||||
dispatch(enregistreLaRéponse(dottedName, value))
|
||||
},
|
||||
[dispatch]
|
||||
)
|
||||
|
|
|
@ -1,13 +1,8 @@
|
|||
import type { TFunction } from 'i18next'
|
||||
import { DottedName } from 'modele-social'
|
||||
import { ASTNode, PublicodesExpression } from 'publicodes'
|
||||
|
||||
import { SimulationConfig } from '@/domaine/SimulationConfig'
|
||||
import { AbsoluteSitePaths } from '@/sitePaths'
|
||||
|
||||
export type Situation = Partial<
|
||||
Record<DottedName, PublicodesExpression | ASTNode>
|
||||
>
|
||||
|
||||
/**
|
||||
* Configuration d'une page de simulateur ou d'assistant
|
||||
*/
|
||||
|
@ -94,53 +89,6 @@ export interface PageConfig {
|
|||
seoExplanations?: () => JSX.Element
|
||||
}
|
||||
|
||||
export type SimulationConfig = Partial<{
|
||||
/**
|
||||
* Objectifs exclusifs de la simulation : si une règle change dans la situation
|
||||
* et qu'elle est dans `objectifs exclusifs`, alors toute les autres règles
|
||||
* dans `objectifs exclusifs` seront supprimées de la situation
|
||||
*/
|
||||
'objectifs exclusifs': DottedName[]
|
||||
|
||||
/**
|
||||
* Objectifs de la simulation
|
||||
*/
|
||||
objectifs?: DottedName[]
|
||||
|
||||
/**
|
||||
* La situation de base du simulateur
|
||||
*/
|
||||
situation: Situation
|
||||
|
||||
questions: {
|
||||
/**
|
||||
* Question non prioritaires, elles aparraitront en fin de simulation
|
||||
*/
|
||||
'non prioritaires'?: DottedName[]
|
||||
|
||||
/**
|
||||
* Whitelist des questions qui sont affiché à l'utilisateur.
|
||||
* Cela peut également servir pour prioriser des questions
|
||||
* en mettant une string vide comme dernier élément
|
||||
*/
|
||||
liste?: (DottedName | '')[]
|
||||
|
||||
/**
|
||||
* Questions qui ne sont pas affiché à l'utilisateur
|
||||
*/
|
||||
'liste noire'?: DottedName[]
|
||||
|
||||
/**
|
||||
* Questions "raccourcis" sélectionnables en bas du simulateur
|
||||
*/
|
||||
"à l'affiche"?: {
|
||||
label: string
|
||||
dottedName: DottedName
|
||||
}[]
|
||||
}
|
||||
|
||||
'unité par défaut'?: string
|
||||
}>
|
||||
/**
|
||||
* Les informations liées au tracking, utilisées pour les statistiques.
|
||||
*
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { SimulationConfig } from '../_configs/types'
|
||||
import { SimulationConfig } from '@/domaine/SimulationConfig'
|
||||
|
||||
export const configArtisteAuteur: SimulationConfig = {
|
||||
objectifs: [
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { SimulationConfig } from '../_configs/types'
|
||||
import { SimulationConfig } from '@/domaine/SimulationConfig'
|
||||
|
||||
export const configAutoEntrepreneur: SimulationConfig = {
|
||||
'objectifs exclusifs': [
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { SimulationConfig } from '../_configs/types'
|
||||
import { SimulationConfig } from '@/domaine/SimulationConfig'
|
||||
|
||||
export const configChômagePartiel: SimulationConfig = {
|
||||
objectifs: [
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { SimulationConfig } from '../_configs/types'
|
||||
import { SimulationConfig } from '@/domaine/SimulationConfig'
|
||||
|
||||
import { configProfessionLibérale } from '../profession-libérale/simulationConfig'
|
||||
|
||||
const cipavSimulationConfig: SimulationConfig = {
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
import { NamedEngine } from '@/pages/simulateurs/comparaison-statuts/NamedEngine'
|
||||
|
||||
export type EngineComparison =
|
||||
| [NamedEngine, NamedEngine, NamedEngine]
|
||||
| [NamedEngine, NamedEngine]
|
|
@ -0,0 +1,9 @@
|
|||
import { DottedName } from 'modele-social'
|
||||
import Engine from 'publicodes'
|
||||
|
||||
import { StatutType } from '@/components/StatutTag'
|
||||
|
||||
export type NamedEngine = {
|
||||
engine: Engine<DottedName>
|
||||
name: StatutType
|
||||
}
|
|
@ -1,5 +1,3 @@
|
|||
import { DottedName } from 'modele-social'
|
||||
import Engine from 'publicodes'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
|
||||
import { EngineDocumentationRoutes } from '@/components/EngineDocumentationRoutes'
|
||||
|
@ -9,42 +7,26 @@ import Simulation, {
|
|||
SimulationGoal,
|
||||
SimulationGoals,
|
||||
} from '@/components/Simulation'
|
||||
import { StatutType } from '@/components/StatutTag'
|
||||
import { Message } from '@/design-system'
|
||||
import { Container, Spacing } from '@/design-system/layout'
|
||||
import { H4 } from '@/design-system/typography/heading'
|
||||
import { Link } from '@/design-system/typography/link'
|
||||
import { Body } from '@/design-system/typography/paragraphs'
|
||||
import { EngineComparison } from '@/pages/simulateurs/comparaison-statuts/EngineComparison'
|
||||
import { useSitePaths } from '@/sitePaths'
|
||||
|
||||
import Détails from './Détails'
|
||||
import ModifierOptions from './ModifierOptions'
|
||||
import StatutChoice from './StatutChoice'
|
||||
|
||||
type NamedEngine = {
|
||||
engine: Engine<DottedName>
|
||||
name: StatutType
|
||||
}
|
||||
|
||||
export type EngineComparison =
|
||||
| [NamedEngine, NamedEngine, NamedEngine]
|
||||
| [NamedEngine, NamedEngine]
|
||||
|
||||
function Comparateur({ namedEngines }: { namedEngines: EngineComparison }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const engines = namedEngines.map(({ engine }) => engine) as [
|
||||
Engine<DottedName>,
|
||||
Engine<DottedName>,
|
||||
Engine<DottedName>,
|
||||
]
|
||||
|
||||
const { absoluteSitePaths } = useSitePaths()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Simulation
|
||||
engines={engines}
|
||||
hideDetails
|
||||
showQuestionsFromBeginning
|
||||
fullWidth
|
||||
|
|
|
@ -12,9 +12,9 @@ import { HelpIcon } from '@/design-system/icons'
|
|||
import { Grid } from '@/design-system/layout'
|
||||
import { Strong } from '@/design-system/typography'
|
||||
import { Body } from '@/design-system/typography/paragraphs'
|
||||
import { EngineComparison } from '@/pages/simulateurs/comparaison-statuts/EngineComparison'
|
||||
|
||||
import { getBestOption, OptionType } from '../utils'
|
||||
import { EngineComparison } from './Comparateur'
|
||||
import StatusCard from './StatusCard'
|
||||
|
||||
export const getGridSizes = (numberOptions: number, total: number) => {
|
||||
|
|
|
@ -14,8 +14,8 @@ import { H2, H4 } from '@/design-system/typography/heading'
|
|||
import { StyledLink } from '@/design-system/typography/link'
|
||||
import { Li, Ul } from '@/design-system/typography/list'
|
||||
import { Body } from '@/design-system/typography/paragraphs'
|
||||
import { EngineComparison } from '@/pages/simulateurs/comparaison-statuts/EngineComparison'
|
||||
|
||||
import { EngineComparison } from './Comparateur'
|
||||
import DetailsRowCards from './DetailsRowCards'
|
||||
import ItemTitle from './ItemTitle'
|
||||
import RevenuTable from './RevenuTable'
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { PublicodesExpression } from 'publicodes'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { styled } from 'styled-components'
|
||||
|
||||
import { SwitchInput } from '@/components/conversation/ChoicesInput'
|
||||
|
@ -16,10 +16,9 @@ import { Strong } from '@/design-system/typography'
|
|||
import { H2, H3, H5 } from '@/design-system/typography/heading'
|
||||
import { Link, StyledLink } from '@/design-system/typography/link'
|
||||
import { Body } from '@/design-system/typography/paragraphs'
|
||||
import { answerQuestion } from '@/store/actions/actions'
|
||||
|
||||
import { useCasParticuliers } from '../contexts/CasParticuliers'
|
||||
import { EngineComparison } from './Comparateur'
|
||||
import { EngineComparison } from '@/pages/simulateurs/comparaison-statuts/EngineComparison'
|
||||
import { enregistreLaRéponse, setACRE } from '@/store/actions/actions'
|
||||
import { acreActivéSelector } from '@/store/selectors/acreActivé.selector'
|
||||
|
||||
const DOTTEDNAME_SOCIETE_IMPOT = 'entreprise . imposition'
|
||||
const DOTTEDNAME_SOCIETE_VERSEMENT_LIBERATOIRE =
|
||||
|
@ -55,15 +54,16 @@ const ModifierOptions = ({
|
|||
defaultValueVersementLiberatoire
|
||||
)
|
||||
const [acreValue, setAcreValue] = useState(defaultValueACRE)
|
||||
const { isAutoEntrepreneurACREEnabled, setIsAutoEntrepreneurACREEnabled } =
|
||||
useCasParticuliers()
|
||||
|
||||
const isAutoEntrepreneurACREEnabled = useSelector(acreActivéSelector)
|
||||
const dispatch = useDispatch()
|
||||
const setIsAutoEntrepreneurACREEnabled = (activé: boolean) =>
|
||||
dispatch(setACRE(activé))
|
||||
|
||||
const [AEAcreValue, setAEAcreValue] = useState<boolean | null>(null)
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const dispatch = useDispatch()
|
||||
|
||||
const onCancel = useCallback(() => {
|
||||
setAcreValue(null)
|
||||
setVersementLiberatoireValue(null)
|
||||
|
@ -82,19 +82,14 @@ const ModifierOptions = ({
|
|||
)}
|
||||
confirmLabel="Enregistrer les options"
|
||||
onConfirm={() => {
|
||||
dispatch(
|
||||
answerQuestion(
|
||||
DOTTEDNAME_SOCIETE_IMPOT,
|
||||
impotValue as PublicodesExpression
|
||||
)
|
||||
)
|
||||
dispatch(enregistreLaRéponse(DOTTEDNAME_SOCIETE_IMPOT, impotValue))
|
||||
|
||||
const versementLibératoireValuePassed =
|
||||
versementLiberatoireValue === null
|
||||
? defaultValueVersementLiberatoire
|
||||
: versementLiberatoireValue
|
||||
dispatch(
|
||||
answerQuestion(
|
||||
enregistreLaRéponse(
|
||||
DOTTEDNAME_SOCIETE_VERSEMENT_LIBERATOIRE,
|
||||
versementLibératoireValuePassed ? 'oui' : 'non'
|
||||
)
|
||||
|
@ -103,7 +98,7 @@ const ModifierOptions = ({
|
|||
const acreValuePassed =
|
||||
acreValue === null ? defaultValueACRE : acreValue
|
||||
dispatch(
|
||||
answerQuestion(DOTTEDNAME_ACRE, acreValuePassed ? 'oui' : 'non')
|
||||
enregistreLaRéponse(DOTTEDNAME_ACRE, acreValuePassed ? 'oui' : 'non')
|
||||
)
|
||||
|
||||
if (!acreValuePassed) {
|
||||
|
|
|
@ -5,8 +5,7 @@ import Value from '@/components/EngineValue/Value'
|
|||
import { StatutTag } from '@/components/StatutTag'
|
||||
import { Tag } from '@/design-system/tag'
|
||||
import { Strong } from '@/design-system/typography'
|
||||
|
||||
import { EngineComparison } from './Comparateur'
|
||||
import { EngineComparison } from '@/pages/simulateurs/comparaison-statuts/EngineComparison'
|
||||
|
||||
export default function RevenuTable({
|
||||
namedEngines,
|
||||
|
|
|
@ -9,9 +9,9 @@ import { Grid, Spacing } from '@/design-system/layout'
|
|||
import { Strong } from '@/design-system/typography'
|
||||
import { H4 } from '@/design-system/typography/heading'
|
||||
import { Li, Ul } from '@/design-system/typography/list'
|
||||
import { EngineComparison } from '@/pages/simulateurs/comparaison-statuts/EngineComparison'
|
||||
import { useSitePaths } from '@/sitePaths'
|
||||
|
||||
import { EngineComparison } from './Comparateur'
|
||||
import { getGridSizes } from './DetailsRowCards'
|
||||
import StatusCard from './StatusCard'
|
||||
|
||||
|
|
|
@ -1,40 +0,0 @@
|
|||
import {
|
||||
createContext,
|
||||
Dispatch,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useContext,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
type CasParticuliersType = {
|
||||
isAutoEntrepreneurACREEnabled: boolean
|
||||
setIsAutoEntrepreneurACREEnabled: Dispatch<SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
const CasParticuliersContext = createContext<CasParticuliersType>({
|
||||
isAutoEntrepreneurACREEnabled: false,
|
||||
setIsAutoEntrepreneurACREEnabled: () => null,
|
||||
})
|
||||
|
||||
export const CasParticuliersProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode
|
||||
}) => {
|
||||
const [isAutoEntrepreneurACREEnabled, setIsAutoEntrepreneurACREEnabled] =
|
||||
useState(false)
|
||||
|
||||
return (
|
||||
<CasParticuliersContext.Provider
|
||||
value={{
|
||||
isAutoEntrepreneurACREEnabled,
|
||||
setIsAutoEntrepreneurACREEnabled,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</CasParticuliersContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useCasParticuliers = () => useContext(CasParticuliersContext)
|
|
@ -7,56 +7,43 @@ import { Emoji } from '@/design-system/emoji'
|
|||
import { Strong } from '@/design-system/typography'
|
||||
import { Link } from '@/design-system/typography/link'
|
||||
import { Body, Intro } from '@/design-system/typography/paragraphs'
|
||||
import { AssimiléSalariéContexte } from '@/domain/AssimiléSalariéContexte'
|
||||
import { AutoentrepreneurContexte } from '@/domain/AutoentrepreneurContexte'
|
||||
import { IndépendantContexte } from '@/domain/IndépendantContexte'
|
||||
import { useSitePaths } from '@/sitePaths'
|
||||
|
||||
import Comparateur, { EngineComparison } from './components/Comparateur'
|
||||
import {
|
||||
CasParticuliersProvider,
|
||||
useCasParticuliers,
|
||||
} from './contexts/CasParticuliers'
|
||||
import Comparateur from './components/Comparateur'
|
||||
import { EngineComparison } from './EngineComparison'
|
||||
|
||||
function ComparateurStatutsUI() {
|
||||
const engine = useEngine()
|
||||
const situation = useRawSituation()
|
||||
const { absoluteSitePaths } = useSitePaths()
|
||||
const { isAutoEntrepreneurACREEnabled } = useCasParticuliers()
|
||||
|
||||
const assimiléEngine = useMemo(
|
||||
() =>
|
||||
engine.shallowCopy().setSituation({
|
||||
...situation,
|
||||
'entreprise . imposition': "'IS'",
|
||||
'entreprise . catégorie juridique': "'SAS'",
|
||||
'entreprise . associés': "'unique'",
|
||||
...AssimiléSalariéContexte,
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[situation]
|
||||
[situation, engine]
|
||||
)
|
||||
const autoEntrepreneurEngine = useMemo(
|
||||
() =>
|
||||
engine.shallowCopy().setSituation({
|
||||
...situation,
|
||||
'entreprise . catégorie juridique': "'EI'",
|
||||
'entreprise . catégorie juridique . EI . auto-entrepreneur': 'oui',
|
||||
...(isAutoEntrepreneurACREEnabled
|
||||
? { 'dirigeant . exonérations . ACRE': 'oui' }
|
||||
: { 'dirigeant . exonérations . ACRE': 'non' }),
|
||||
...AutoentrepreneurContexte,
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[situation, isAutoEntrepreneurACREEnabled]
|
||||
[situation, engine]
|
||||
)
|
||||
|
||||
const indépendantEngine = useMemo(
|
||||
() =>
|
||||
engine.shallowCopy().setSituation({
|
||||
...situation,
|
||||
'entreprise . imposition':
|
||||
situation['entreprise . imposition'] ?? "'IS'",
|
||||
'entreprise . catégorie juridique': "'EI'",
|
||||
'entreprise . catégorie juridique . EI . auto-entrepreneur': 'non',
|
||||
...IndépendantContexte,
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[situation]
|
||||
[situation, engine]
|
||||
)
|
||||
|
||||
const engines = [
|
||||
|
@ -97,9 +84,5 @@ function ComparateurStatutsUI() {
|
|||
}
|
||||
|
||||
export default function ComparateurStatuts() {
|
||||
return (
|
||||
<CasParticuliersProvider>
|
||||
<ComparateurStatutsUI />
|
||||
</CasParticuliersProvider>
|
||||
)
|
||||
return <ComparateurStatutsUI />
|
||||
}
|
||||
|
|
|
@ -1,6 +1,14 @@
|
|||
import { SimulationConfig } from '../_configs/types'
|
||||
import { AssimiléSalariéContexte } from '@/domain/AssimiléSalariéContexte'
|
||||
import { AutoentrepreneurContexte } from '@/domain/AutoentrepreneurContexte'
|
||||
import { IndépendantContexte } from '@/domain/IndépendantContexte'
|
||||
import { ComparateurConfig } from '@/domaine/ComparateurConfig'
|
||||
|
||||
export const configComparateurStatuts: SimulationConfig = {
|
||||
export const configComparateurStatuts: ComparateurConfig = {
|
||||
contextes: [
|
||||
AssimiléSalariéContexte,
|
||||
AutoentrepreneurContexte,
|
||||
IndépendantContexte,
|
||||
],
|
||||
'objectifs exclusifs': [],
|
||||
objectifs: [
|
||||
'dirigeant . rémunération . net',
|
||||
|
|
|
@ -16,7 +16,7 @@ import { useEngine } from '@/components/utils/EngineContext'
|
|||
import { Radio, ToggleGroup } from '@/design-system/field'
|
||||
import { H2 } from '@/design-system/typography/heading'
|
||||
import { Body } from '@/design-system/typography/paragraphs'
|
||||
import { updateSituation } from '@/store/actions/actions'
|
||||
import { enregistreLaRéponse } from '@/store/actions/actions'
|
||||
|
||||
export default function DividendesSimulation() {
|
||||
return (
|
||||
|
@ -63,7 +63,7 @@ function OptionBarèmeSwitch() {
|
|||
value={currentOptionPFU}
|
||||
onChange={(value) => {
|
||||
setCurrentOptionPFU(value)
|
||||
dispatch(updateSituation(dottedName, `'${value}'`))
|
||||
dispatch(enregistreLaRéponse(dottedName, `'${value}'`))
|
||||
}}
|
||||
aria-label={t("Régime d'imposition")}
|
||||
>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { SimulationConfig } from '../_configs/types'
|
||||
import { SimulationConfig } from '@/domaine/SimulationConfig'
|
||||
|
||||
export const configDividendes: SimulationConfig = {
|
||||
'objectifs exclusifs': [
|
||||
|
|
|
@ -18,7 +18,10 @@ import Warning from '@/components/ui/WarningBlock'
|
|||
import { H2 } from '@/design-system/typography/heading'
|
||||
import { Link } from '@/design-system/typography/link'
|
||||
import { Body, Intro } from '@/design-system/typography/paragraphs'
|
||||
import { batchUpdateSituation, updateSituation } from '@/store/actions/actions'
|
||||
import {
|
||||
batchUpdateSituation,
|
||||
enregistreLaRéponse,
|
||||
} from '@/store/actions/actions'
|
||||
import { situationSelector } from '@/store/selectors/simulationSelectors'
|
||||
|
||||
export default function ISSimulation() {
|
||||
|
@ -105,13 +108,13 @@ function ExerciceDate() {
|
|||
<RuleInput
|
||||
dottedName={'entreprise . exercice . début'}
|
||||
onChange={(x) =>
|
||||
dispatch(updateSituation('entreprise . exercice . début', x))
|
||||
dispatch(enregistreLaRéponse('entreprise . exercice . début', x))
|
||||
}
|
||||
/>{' '}
|
||||
<RuleInput
|
||||
dottedName={'entreprise . exercice . fin'}
|
||||
onChange={(x) =>
|
||||
dispatch(updateSituation('entreprise . exercice . fin', x))
|
||||
dispatch(enregistreLaRéponse('entreprise . exercice . fin', x))
|
||||
}
|
||||
/>
|
||||
</ExerciceDateContainer>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { SimulationConfig } from '../_configs/types'
|
||||
import { SimulationConfig } from '@/domaine/SimulationConfig'
|
||||
|
||||
const ISSimulationConfig: SimulationConfig = {
|
||||
'unité par défaut': '€/an',
|
||||
|
|
|
@ -17,7 +17,7 @@ import { Message } from '@/design-system'
|
|||
import { Emoji } from '@/design-system/emoji'
|
||||
import { H2 } from '@/design-system/typography/heading'
|
||||
import { Body } from '@/design-system/typography/paragraphs'
|
||||
import { updateSituation } from '@/store/actions/actions'
|
||||
import { enregistreLaRéponse } from '@/store/actions/actions'
|
||||
|
||||
export function IndépendantPLSimulation() {
|
||||
return (
|
||||
|
@ -135,7 +135,7 @@ export default function IndépendantSimulation() {
|
|||
dottedName="entreprise . imposition"
|
||||
onChange={(imposition) => {
|
||||
dispatch(
|
||||
updateSituation('entreprise . imposition', imposition)
|
||||
enregistreLaRéponse('entreprise . imposition', imposition)
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { SimulationConfig } from '../_configs/types'
|
||||
import { SimulationConfig } from '@/domaine/SimulationConfig'
|
||||
|
||||
export const configIndépendant: SimulationConfig = {
|
||||
'objectifs exclusifs': [
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { SimulationConfig } from '../_configs/types'
|
||||
import { SimulationConfig } from '@/domaine/SimulationConfig'
|
||||
|
||||
import { configIndépendant } from '../indépendant/simulationConfig'
|
||||
|
||||
export const configProfessionLibérale: SimulationConfig = {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { SimulationConfig } from '../_configs/types'
|
||||
import { SimulationConfig } from '@/domaine/SimulationConfig'
|
||||
|
||||
export const configSalarié: SimulationConfig = {
|
||||
'objectifs exclusifs': [
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { SimulationConfig } from '../_configs/types'
|
||||
import { SimulationConfig } from '@/domaine/SimulationConfig'
|
||||
|
||||
export const configSASU: SimulationConfig = {
|
||||
'objectifs exclusifs': [
|
||||
|
|
|
@ -2,7 +2,6 @@ import { DottedName } from 'modele-social'
|
|||
import Engine, { PublicodesExpression } from 'publicodes'
|
||||
|
||||
import { SimulationConfig } from '@/store/reducers/rootReducer'
|
||||
import { ImmutableType } from '@/types/utils'
|
||||
import { buildSituationFromObject } from '@/utils'
|
||||
|
||||
import { CompanyActions } from './companyActions'
|
||||
|
@ -11,18 +10,19 @@ import { HiringChecklistAction } from './hiringChecklistAction'
|
|||
export type Action =
|
||||
| ReturnType<
|
||||
| typeof explainVariable
|
||||
| typeof goToQuestion
|
||||
| typeof vaÀLaQuestion
|
||||
| typeof hideNotification
|
||||
| typeof loadPreviousSimulation
|
||||
| typeof resetSimulation
|
||||
| typeof setActiveTarget
|
||||
| typeof setSimulationConfig
|
||||
| typeof stepAction
|
||||
| typeof updateSituation
|
||||
| typeof retourneÀLaQuestionPrécédente
|
||||
| typeof vaÀLaQuestionSuivante
|
||||
| typeof enregistreLaRéponse
|
||||
| typeof deleteFromSituation
|
||||
| typeof updateUnit
|
||||
| typeof batchUpdateSituation
|
||||
| typeof updateShouldFocusField
|
||||
| typeof questionsSuivantes
|
||||
>
|
||||
| CompanyActions
|
||||
| HiringChecklistAction
|
||||
|
@ -32,24 +32,19 @@ export const resetSimulation = () =>
|
|||
type: 'RESET_SIMULATION',
|
||||
}) as const
|
||||
|
||||
export const goToQuestion = (question: DottedName) =>
|
||||
export const vaÀLaQuestion = (question: DottedName) =>
|
||||
({
|
||||
type: 'STEP_ACTION',
|
||||
name: 'unfold',
|
||||
step: question,
|
||||
type: 'VA_À_LA_QUESTION',
|
||||
question,
|
||||
}) as const
|
||||
|
||||
export const stepAction = (step: DottedName) =>
|
||||
export const questionsSuivantes = (questionsSuivantes: Array<DottedName>) =>
|
||||
({
|
||||
type: 'STEP_ACTION',
|
||||
name: 'fold',
|
||||
step,
|
||||
type: 'QUESTIONS_SUIVANTES',
|
||||
questionsSuivantes,
|
||||
}) as const
|
||||
|
||||
export const setSimulationConfig = (
|
||||
config: ImmutableType<SimulationConfig>,
|
||||
url: string
|
||||
) =>
|
||||
export const setSimulationConfig = (config: SimulationConfig, url: string) =>
|
||||
({
|
||||
type: 'SET_SIMULATION',
|
||||
url,
|
||||
|
@ -62,11 +57,14 @@ export const setActiveTarget = (targetName: DottedName) =>
|
|||
name: targetName,
|
||||
}) as const
|
||||
|
||||
export const updateSituation = (fieldName: DottedName, value: unknown) =>
|
||||
export const enregistreLaRéponse = (
|
||||
fieldName: DottedName,
|
||||
value: PublicodesExpression | undefined
|
||||
) =>
|
||||
value === undefined
|
||||
? deleteFromSituation(fieldName)
|
||||
: ({
|
||||
type: 'UPDATE_SITUATION',
|
||||
type: 'ENREGISTRE_LA_RÉPONSE',
|
||||
fieldName,
|
||||
value,
|
||||
} as const)
|
||||
|
@ -107,30 +105,23 @@ export const explainVariable = (variableName: DottedName | null = null) =>
|
|||
variableName,
|
||||
}) as const
|
||||
|
||||
export const updateShouldFocusField = (shouldFocusField: boolean) =>
|
||||
export const retourneÀLaQuestionPrécédente = () =>
|
||||
({
|
||||
type: 'UPDATE_SHOULD_FOCUS_FIELD',
|
||||
shouldFocusField,
|
||||
type: 'RETOURNE_À_LA_QUESTION_PRÉCÉDENTE',
|
||||
}) as const
|
||||
|
||||
export const answerQuestion = (
|
||||
dottedName: DottedName,
|
||||
value:
|
||||
| PublicodesExpression
|
||||
| undefined
|
||||
| { batchUpdate: Record<string, PublicodesExpression> }
|
||||
) => {
|
||||
if (value && typeof value === 'object' && 'batchUpdate' in value) {
|
||||
return batchUpdateSituation(
|
||||
buildSituationFromObject(
|
||||
dottedName,
|
||||
value.batchUpdate as Record<string, PublicodesExpression>
|
||||
)
|
||||
)
|
||||
}
|
||||
export const vaÀLaQuestionSuivante = () =>
|
||||
({
|
||||
type: 'VA_À_LA_QUESTION_SUIVANTE',
|
||||
}) as const
|
||||
|
||||
return (value == null ? deleteFromSituation : updateSituation)(
|
||||
dottedName,
|
||||
value
|
||||
export const setACRE = (activé: boolean) =>
|
||||
enregistreLaRéponse(
|
||||
'dirigeant . exonérations . ACRE',
|
||||
activé ? "'oui'" : "'non'"
|
||||
)
|
||||
}
|
||||
|
||||
export const answerBatchQuestion = (
|
||||
dottedName: DottedName,
|
||||
value: Record<string, PublicodesExpression>
|
||||
) => batchUpdateSituation(buildSituationFromObject(dottedName, value))
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
import * as Array from 'effect/Array'
|
||||
import * as String from 'effect/String'
|
||||
import Engine from 'publicodes'
|
||||
import { Dispatch, Middleware } from 'redux'
|
||||
|
||||
import { détermineLesProchainesQuestions } from '@/domaine/engine/détermineLesProchainesQuestions'
|
||||
import { SimulationConfig } from '@/domaine/SimulationConfig'
|
||||
import { Action, questionsSuivantes } from '@/store/actions/actions'
|
||||
import { RootState } from '@/store/reducers/rootReducer'
|
||||
import { rawSituationSelector } from '@/store/selectors/simulationSelectors'
|
||||
import { complement } from '@/utils/complement'
|
||||
|
||||
export const prendLaProchaineQuestionMiddleware =
|
||||
(engine: Engine): Middleware<object, RootState, Dispatch<Action>> =>
|
||||
(store) =>
|
||||
(next) =>
|
||||
(action) => {
|
||||
const result = next(action)
|
||||
|
||||
const newState = store.getState()
|
||||
|
||||
const config = newState.simulation?.config
|
||||
const situation = rawSituationSelector(newState)
|
||||
const questionsRépondues = newState.simulation?.answeredQuestions
|
||||
const questionsSuivantesActuelles =
|
||||
newState.simulation?.questionsSuivantes || []
|
||||
|
||||
if (situation && config) {
|
||||
engine.setSituation(situation)
|
||||
|
||||
const prochainesQuestions = détermineLesProchainesQuestions(
|
||||
engine,
|
||||
config as SimulationConfig,
|
||||
questionsRépondues
|
||||
)
|
||||
|
||||
if (
|
||||
arraysAreDifferent(prochainesQuestions, questionsSuivantesActuelles)
|
||||
) {
|
||||
store.dispatch(questionsSuivantes(prochainesQuestions))
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const arraysAreDifferent = complement(Array.getEquivalence(String.Equivalence))
|
|
@ -39,7 +39,7 @@ export function isCompanyDottedName(dottedName: DottedName) {
|
|||
|
||||
export function companySituation(state: Situation = {}, action: Action) {
|
||||
switch (action.type) {
|
||||
case 'UPDATE_SITUATION':
|
||||
case 'ENREGISTRE_LA_RÉPONSE':
|
||||
if (isCompanyDottedName(action.fieldName)) {
|
||||
return {
|
||||
...state,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { retrievePersistedSimulation } from '@/storage/persistSimulation'
|
||||
import { Action } from '@/store/actions/actions'
|
||||
import { Simulation } from '@/store/reducers/rootReducer'
|
||||
import { Simulation } from '@/store/reducers/simulation.reducer'
|
||||
|
||||
import { RootState } from './rootReducer'
|
||||
|
||||
|
@ -13,7 +13,7 @@ export const createStateFromPreviousSimulation = (
|
|||
simulation: {
|
||||
...state.simulation,
|
||||
situation: state.previousSimulation.situation || {},
|
||||
foldedSteps: state.previousSimulation.foldedSteps,
|
||||
answeredQuestions: state.previousSimulation.foldedSteps,
|
||||
} as Simulation,
|
||||
previousSimulation: null,
|
||||
}
|
||||
|
|
|
@ -2,11 +2,14 @@ import { DottedName } from 'modele-social'
|
|||
import reduceReducers from 'reduce-reducers'
|
||||
import { combineReducers, Reducer } from 'redux'
|
||||
|
||||
import { SimulationConfig, Situation } from '@/pages/simulateurs/_configs/types'
|
||||
import { Action, updateSituation } from '@/store/actions/actions'
|
||||
import { SimulationConfig } from '@/domaine/SimulationConfig'
|
||||
import { Situation } from '@/domaine/Situation'
|
||||
import {
|
||||
Action,
|
||||
enregistreLaRéponse as updateSituationAction,
|
||||
} from '@/store/actions/actions'
|
||||
import { simulationReducer } from '@/store/reducers/simulation.reducer'
|
||||
import { PreviousSimulation } from '@/store/selectors/previousSimulationSelectors'
|
||||
import { ImmutableType } from '@/types/utils'
|
||||
import { objectTransform, omit } from '@/utils'
|
||||
|
||||
import choixStatutJuridique from './choixStatutJuridiqueReducer'
|
||||
import { companySituation } from './companySituationReducer'
|
||||
|
@ -14,20 +17,6 @@ import previousSimulationRootReducer from './previousSimulationRootReducer'
|
|||
|
||||
export type { SimulationConfig, Situation }
|
||||
|
||||
function explainedVariable(
|
||||
state: DottedName | null = null,
|
||||
action: Action
|
||||
): DottedName | null {
|
||||
switch (action.type) {
|
||||
case 'EXPLAIN_VARIABLE':
|
||||
return action.variableName
|
||||
case 'STEP_ACTION':
|
||||
return null
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
function activeTargetInput(state: DottedName | null = null, action: Action) {
|
||||
switch (action.type) {
|
||||
case 'SET_ACTIVE_TARGET_INPUT':
|
||||
|
@ -39,128 +28,6 @@ function activeTargetInput(state: DottedName | null = null, action: Action) {
|
|||
}
|
||||
}
|
||||
|
||||
export type Simulation = {
|
||||
config: ImmutableType<SimulationConfig>
|
||||
url: string
|
||||
hiddenNotifications: Array<string>
|
||||
situation: Situation
|
||||
targetUnit: string
|
||||
foldedSteps: Array<DottedName>
|
||||
unfoldedStep?: DottedName | null
|
||||
shouldFocusField: boolean
|
||||
}
|
||||
|
||||
function simulation(
|
||||
state: Simulation | null = null,
|
||||
action: Action
|
||||
): Simulation | null {
|
||||
if (action.type === 'SET_SIMULATION') {
|
||||
const { config, url } = action
|
||||
|
||||
return {
|
||||
config,
|
||||
url,
|
||||
hiddenNotifications: [],
|
||||
situation: {},
|
||||
targetUnit: config['unité par défaut'] || '€/mois',
|
||||
foldedSteps: [],
|
||||
unfoldedStep: null,
|
||||
shouldFocusField: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (state === null) {
|
||||
return state
|
||||
}
|
||||
|
||||
switch (action.type) {
|
||||
case 'HIDE_NOTIFICATION':
|
||||
return {
|
||||
...state,
|
||||
hiddenNotifications: [...state.hiddenNotifications, action.id],
|
||||
}
|
||||
case 'RESET_SIMULATION':
|
||||
return {
|
||||
...state,
|
||||
hiddenNotifications: [],
|
||||
situation: {},
|
||||
foldedSteps: [],
|
||||
unfoldedStep: null,
|
||||
}
|
||||
|
||||
case 'UPDATE_SITUATION': {
|
||||
const situation = state.situation
|
||||
const { fieldName: dottedName, value } = action
|
||||
|
||||
if (value === undefined) {
|
||||
return { ...state, situation: omit(situation, dottedName) }
|
||||
}
|
||||
|
||||
const objectifsExclusifs = state.config['objectifs exclusifs'] ?? []
|
||||
|
||||
if (objectifsExclusifs.includes(dottedName)) {
|
||||
const objectifsToReset = objectifsExclusifs.filter(
|
||||
(name) => name !== dottedName
|
||||
)
|
||||
|
||||
const newSituation = objectTransform(situation, (entries) =>
|
||||
entries.filter(
|
||||
([dottedName]) =>
|
||||
!objectifsToReset.includes(dottedName as DottedName)
|
||||
)
|
||||
)
|
||||
|
||||
return { ...state, situation: { ...newSituation, [dottedName]: value } }
|
||||
}
|
||||
|
||||
return { ...state, situation: { ...situation, [dottedName]: value } }
|
||||
}
|
||||
case 'DELETE_FROM_SITUATION': {
|
||||
const newState = {
|
||||
...state,
|
||||
situation: omit(
|
||||
state.situation,
|
||||
action.fieldName
|
||||
) as Simulation['situation'],
|
||||
}
|
||||
|
||||
return newState
|
||||
}
|
||||
case 'STEP_ACTION': {
|
||||
const { name, step } = action
|
||||
if (name === 'fold') {
|
||||
return {
|
||||
...state,
|
||||
foldedSteps: [...state.foldedSteps, step],
|
||||
unfoldedStep: null,
|
||||
}
|
||||
}
|
||||
if (name === 'unfold') {
|
||||
return {
|
||||
...state,
|
||||
foldedSteps: state.foldedSteps.filter((name) => name !== step),
|
||||
unfoldedStep: step,
|
||||
}
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
case 'UPDATE_TARGET_UNIT':
|
||||
return {
|
||||
...state,
|
||||
targetUnit: action.targetUnit,
|
||||
}
|
||||
|
||||
case 'UPDATE_SHOULD_FOCUS_FIELD':
|
||||
return {
|
||||
...state,
|
||||
shouldFocusField: action.shouldFocusField,
|
||||
}
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
function batchUpdateSituationReducer(state: RootState, action: Action) {
|
||||
if (action.type !== 'BATCH_UPDATE_SITUATION') {
|
||||
return state
|
||||
|
@ -170,15 +37,14 @@ function batchUpdateSituationReducer(state: RootState, action: Action) {
|
|||
(newState, [fieldName, value]) =>
|
||||
mainReducer(
|
||||
newState ?? undefined,
|
||||
updateSituation(fieldName as DottedName, value)
|
||||
updateSituationAction(fieldName as DottedName, value)
|
||||
),
|
||||
state
|
||||
)
|
||||
}
|
||||
|
||||
const mainReducer = combineReducers({
|
||||
explainedVariable,
|
||||
simulation,
|
||||
simulation: simulationReducer,
|
||||
companySituation,
|
||||
previousSimulation: ((p) => p ?? null) as Reducer<PreviousSimulation | null>,
|
||||
activeTargetInput,
|
||||
|
|
|
@ -0,0 +1,215 @@
|
|||
import { DottedName } from 'modele-social'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
Simulation,
|
||||
simulationReducer,
|
||||
} from '@/store/reducers/simulation.reducer'
|
||||
|
||||
import {
|
||||
deleteFromSituation,
|
||||
enregistreLaRéponse,
|
||||
retourneÀLaQuestionPrécédente,
|
||||
vaÀLaQuestion,
|
||||
vaÀLaQuestionSuivante,
|
||||
} from '../actions/actions'
|
||||
|
||||
const previousQuestionAction = retourneÀLaQuestionPrécédente()
|
||||
const nextQuestionAction = vaÀLaQuestionSuivante()
|
||||
|
||||
describe('simulationReducer', () => {
|
||||
describe('RETOURNE_À_LA_QUESTION_PRÉCÉDENTE', () => {
|
||||
it('fonctionne quand la question en cours est la dernière posée', () => {
|
||||
const state = {
|
||||
currentQuestion: 'b',
|
||||
answeredQuestions: ['a', 'b'],
|
||||
}
|
||||
expect(
|
||||
simulationReducer(state as Simulation, previousQuestionAction)
|
||||
).toEqual({
|
||||
currentQuestion: 'a',
|
||||
answeredQuestions: ['a', 'b'],
|
||||
})
|
||||
})
|
||||
it('fonctionne quand la question en cours n’a pas été répondue', () => {
|
||||
const state = {
|
||||
currentQuestion: 'c',
|
||||
answeredQuestions: ['a', 'b'],
|
||||
}
|
||||
expect(
|
||||
simulationReducer(state as Simulation, previousQuestionAction)
|
||||
).toEqual({
|
||||
currentQuestion: 'b',
|
||||
answeredQuestions: ['a', 'b'],
|
||||
})
|
||||
})
|
||||
|
||||
it('fonctionne quand on est au milieu de l’historique', () => {
|
||||
const state = {
|
||||
currentQuestion: 'b',
|
||||
answeredQuestions: ['a', 'b', 'c'],
|
||||
}
|
||||
expect(
|
||||
simulationReducer(
|
||||
state as unknown as Simulation,
|
||||
previousQuestionAction
|
||||
)
|
||||
).toEqual({
|
||||
currentQuestion: 'a',
|
||||
answeredQuestions: ['a', 'b', 'c'],
|
||||
})
|
||||
})
|
||||
|
||||
it('fonctionne quand on a déjà répondu à toutes les questions', () => {
|
||||
const state = {
|
||||
currentQuestion: null,
|
||||
answeredQuestions: ['a', 'b', 'c'],
|
||||
}
|
||||
expect(
|
||||
simulationReducer(
|
||||
state as unknown as Simulation,
|
||||
previousQuestionAction
|
||||
)
|
||||
).toEqual({
|
||||
currentQuestion: 'c',
|
||||
answeredQuestions: ['a', 'b', 'c'],
|
||||
})
|
||||
})
|
||||
|
||||
it('ne fait rien si on est sur la première question', () => {
|
||||
const state = {
|
||||
currentQuestion: 'a',
|
||||
answeredQuestions: [],
|
||||
}
|
||||
expect(
|
||||
simulationReducer(
|
||||
state as unknown as Simulation,
|
||||
previousQuestionAction
|
||||
)
|
||||
).toEqual(state)
|
||||
})
|
||||
|
||||
it('ne fait rien quand on est revenu à la première question', () => {
|
||||
const state = {
|
||||
currentQuestion: 'a',
|
||||
answeredQuestions: ['a', 'b'],
|
||||
}
|
||||
expect(
|
||||
simulationReducer(
|
||||
state as unknown as Simulation,
|
||||
previousQuestionAction
|
||||
)
|
||||
).toEqual(state)
|
||||
})
|
||||
})
|
||||
describe('VA_À_LA_QUESTION_SUIVANTE', () => {
|
||||
it('va à la prochaine question déjà répondue de l’historique le cas échéant', () => {
|
||||
const state = {
|
||||
currentQuestion: 'b',
|
||||
answeredQuestions: ['a', 'b', 'c'],
|
||||
}
|
||||
expect(
|
||||
simulationReducer(state as unknown as Simulation, nextQuestionAction)
|
||||
).toEqual({
|
||||
currentQuestion: 'c',
|
||||
answeredQuestions: ['a', 'b', 'c'],
|
||||
})
|
||||
})
|
||||
it('demande une nouvelle question si par défaut', () => {
|
||||
const state = {
|
||||
currentQuestion: 'b',
|
||||
answeredQuestions: ['a', 'b'],
|
||||
}
|
||||
expect(
|
||||
simulationReducer(state as unknown as Simulation, nextQuestionAction)
|
||||
).toEqual({
|
||||
currentQuestion: null,
|
||||
answeredQuestions: ['a', 'b'],
|
||||
})
|
||||
})
|
||||
it('ne fait rien si la question actuelle n’a pas été répondue', () => {
|
||||
const state = {
|
||||
currentQuestion: 'c',
|
||||
answeredQuestions: ['a', 'b'],
|
||||
}
|
||||
expect(
|
||||
simulationReducer(state as unknown as Simulation, nextQuestionAction)
|
||||
).toEqual(state)
|
||||
})
|
||||
})
|
||||
describe('VA_À_LA_QUESTION', () => {
|
||||
it('va à la question demandée', () => {
|
||||
const state = {
|
||||
currentQuestion: 'b',
|
||||
answeredQuestions: ['a', 'b'],
|
||||
}
|
||||
expect(
|
||||
simulationReducer(
|
||||
state as unknown as Simulation,
|
||||
vaÀLaQuestion('c' as DottedName)
|
||||
)
|
||||
).toEqual({
|
||||
currentQuestion: 'c',
|
||||
answeredQuestions: ['a', 'b'],
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('ENREGISTRE_LA_RÉPONSE', () => {
|
||||
it('marque la question répondue si une réponse est fournie', () => {
|
||||
const state = {
|
||||
answeredQuestions: ['a', 'b'],
|
||||
config: {
|
||||
'objectifs exclusifs': [],
|
||||
},
|
||||
}
|
||||
expect(
|
||||
simulationReducer(
|
||||
state as unknown as Simulation,
|
||||
enregistreLaRéponse('c' as DottedName, 42)
|
||||
)
|
||||
).toMatchObject({
|
||||
answeredQuestions: ['a', 'b', 'c'],
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('DELETE_FROM_SITUATION', () => {
|
||||
it('supprime la question des questions répondues si réponse effacée', () => {
|
||||
const state = {
|
||||
answeredQuestions: ['a', 'b', 'c'],
|
||||
config: {
|
||||
'objectifs exclusifs': [],
|
||||
},
|
||||
situation: {
|
||||
c: 42,
|
||||
},
|
||||
}
|
||||
expect(
|
||||
simulationReducer(
|
||||
state as unknown as Simulation,
|
||||
deleteFromSituation('c' as DottedName)
|
||||
)
|
||||
).toMatchObject({
|
||||
answeredQuestions: ['a', 'b'],
|
||||
})
|
||||
})
|
||||
it('ne change rien sinon', () => {
|
||||
const state = {
|
||||
answeredQuestions: ['a', 'b', 'c'],
|
||||
config: {
|
||||
'objectifs exclusifs': [],
|
||||
},
|
||||
situation: {
|
||||
c: 42,
|
||||
},
|
||||
}
|
||||
expect(
|
||||
simulationReducer(
|
||||
state as unknown as Simulation,
|
||||
deleteFromSituation('d' as DottedName)
|
||||
)
|
||||
).toMatchObject({
|
||||
answeredQuestions: ['a', 'b', 'c'],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,191 @@
|
|||
import { DottedName } from 'modele-social'
|
||||
|
||||
import { SimulationConfig } from '@/domaine/SimulationConfig'
|
||||
import { Situation } from '@/domaine/Situation'
|
||||
import { updateSituation } from '@/domaine/updateSituation'
|
||||
import { Action } from '@/store/actions/actions'
|
||||
import { omit, reject } from '@/utils'
|
||||
|
||||
export type Simulation = {
|
||||
config: SimulationConfig
|
||||
url: string
|
||||
hiddenNotifications: Array<string>
|
||||
situation: Situation
|
||||
targetUnit: string
|
||||
answeredQuestions: Array<DottedName>
|
||||
questionsSuivantes?: Array<DottedName>
|
||||
currentQuestion?: DottedName | null
|
||||
}
|
||||
|
||||
export function simulationReducer(
|
||||
state: Simulation | null = null,
|
||||
action: Action
|
||||
): Simulation | null {
|
||||
if (action.type === 'SET_SIMULATION') {
|
||||
const { config, url } = action
|
||||
|
||||
return {
|
||||
config,
|
||||
url,
|
||||
hiddenNotifications: [],
|
||||
situation: {},
|
||||
targetUnit: config['unité par défaut'] || '€/mois',
|
||||
answeredQuestions: [],
|
||||
currentQuestion: null,
|
||||
}
|
||||
}
|
||||
|
||||
if (state === null) {
|
||||
return state
|
||||
}
|
||||
|
||||
switch (action.type) {
|
||||
case 'HIDE_NOTIFICATION':
|
||||
return {
|
||||
...state,
|
||||
hiddenNotifications: [...state.hiddenNotifications, action.id],
|
||||
}
|
||||
case 'RESET_SIMULATION':
|
||||
return {
|
||||
...state,
|
||||
hiddenNotifications: [],
|
||||
situation: {},
|
||||
answeredQuestions: [],
|
||||
currentQuestion: null,
|
||||
}
|
||||
|
||||
case 'ENREGISTRE_LA_RÉPONSE': {
|
||||
const answeredQuestions = state.answeredQuestions.includes(
|
||||
action.fieldName
|
||||
)
|
||||
? state.answeredQuestions
|
||||
: [...state.answeredQuestions, action.fieldName]
|
||||
|
||||
return {
|
||||
...state,
|
||||
answeredQuestions,
|
||||
situation: updateSituation(
|
||||
state.config,
|
||||
state.situation,
|
||||
action.fieldName,
|
||||
action.value
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
case 'DELETE_FROM_SITUATION': {
|
||||
const newState = {
|
||||
...state,
|
||||
answeredQuestions: reject(
|
||||
state.answeredQuestions,
|
||||
(q) => q === action.fieldName
|
||||
),
|
||||
situation: omit(
|
||||
state.situation,
|
||||
action.fieldName
|
||||
) as Simulation['situation'],
|
||||
}
|
||||
|
||||
return newState
|
||||
}
|
||||
case 'RETOURNE_À_LA_QUESTION_PRÉCÉDENTE': {
|
||||
if (state.answeredQuestions.length === 0) {
|
||||
return state
|
||||
}
|
||||
|
||||
const currentIndex = state.currentQuestion
|
||||
? state.answeredQuestions.indexOf(state.currentQuestion)
|
||||
: -1
|
||||
|
||||
if (currentIndex === -1) {
|
||||
return {
|
||||
...state,
|
||||
currentQuestion: state.answeredQuestions.at(-1),
|
||||
}
|
||||
}
|
||||
|
||||
const previousQuestion = state.answeredQuestions[currentIndex - 1]
|
||||
if (previousQuestion === undefined) {
|
||||
return state
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentQuestion: previousQuestion,
|
||||
}
|
||||
}
|
||||
|
||||
case 'VA_À_LA_QUESTION_SUIVANTE': {
|
||||
const currentIndex = state.currentQuestion
|
||||
? state.answeredQuestions.indexOf(state.currentQuestion)
|
||||
: -1
|
||||
|
||||
// La question en cours n'est pas répondue
|
||||
if (currentIndex === -1) {
|
||||
console.log(
|
||||
'Passer à la question suivante ne devrait pas être autorisé'
|
||||
)
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
// On était sur la dernière question posée, on en prend une nouvelle
|
||||
if (currentIndex === state.answeredQuestions.length - 1) {
|
||||
const questionEnCours = state.questionsSuivantes?.length
|
||||
? state.questionsSuivantes[0]
|
||||
: null
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentQuestion: questionEnCours,
|
||||
}
|
||||
}
|
||||
|
||||
// Sinon, on navigue simplement à la questions déjà répondue suivante
|
||||
return {
|
||||
...state,
|
||||
currentQuestion: state.answeredQuestions[currentIndex + 1],
|
||||
}
|
||||
}
|
||||
|
||||
case 'VA_À_LA_QUESTION': {
|
||||
return {
|
||||
...state,
|
||||
currentQuestion: action.question,
|
||||
}
|
||||
}
|
||||
|
||||
case 'QUESTIONS_SUIVANTES': {
|
||||
const currentQuestion = state.currentQuestion
|
||||
const pasDeQuestionEnCours = !currentQuestion
|
||||
const questionEnCoursNEstPasÀRépondre = currentQuestion
|
||||
? !action.questionsSuivantes.includes(currentQuestion)
|
||||
: false
|
||||
const questionEnCoursNEstPasRépondue = currentQuestion
|
||||
? !state.answeredQuestions?.includes(currentQuestion)
|
||||
: false
|
||||
const questionEnCoursPlusNécessaire =
|
||||
questionEnCoursNEstPasÀRépondre && questionEnCoursNEstPasRépondue
|
||||
|
||||
const nouvelleQuestionEnCours =
|
||||
action.questionsSuivantes.length &&
|
||||
(pasDeQuestionEnCours || questionEnCoursPlusNécessaire)
|
||||
? action.questionsSuivantes[0]
|
||||
: currentQuestion
|
||||
|
||||
return {
|
||||
...state,
|
||||
questionsSuivantes: action.questionsSuivantes,
|
||||
currentQuestion: nouvelleQuestionEnCours,
|
||||
}
|
||||
}
|
||||
|
||||
case 'UPDATE_TARGET_UNIT':
|
||||
return {
|
||||
...state,
|
||||
targetUnit: action.targetUnit,
|
||||
}
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
import { createSelector } from 'reselect'
|
||||
|
||||
import { situationSelector } from '@/store/selectors/simulationSelectors'
|
||||
|
||||
export const acreActivéSelector = createSelector(
|
||||
[situationSelector],
|
||||
(situation) =>
|
||||
(situation['dirigeant . exonérations . ACRE'] || "'non'") === "'oui'"
|
||||
)
|
|
@ -0,0 +1,8 @@
|
|||
import { createSelector } from 'reselect'
|
||||
|
||||
import { simulationSelector } from '@/store/selectors/simulation.selector'
|
||||
|
||||
export const configSelector = createSelector(
|
||||
[simulationSelector],
|
||||
(simulation) => simulation?.config ?? {}
|
||||
)
|
|
@ -0,0 +1,8 @@
|
|||
import { createSelector } from 'reselect'
|
||||
|
||||
import { simulationSelector } from '@/store/selectors/simulation.selector'
|
||||
|
||||
export const currentQuestionSelector = createSelector(
|
||||
[simulationSelector],
|
||||
(simulation) => simulation?.currentQuestion ?? null
|
||||
)
|
|
@ -1,6 +1,7 @@
|
|||
import { DottedName } from 'modele-social'
|
||||
|
||||
import { RootState, Simulation } from '@/store/reducers/rootReducer'
|
||||
import { RootState } from '@/store/reducers/rootReducer'
|
||||
import { Simulation } from '@/store/reducers/simulation.reducer'
|
||||
|
||||
export type PreviousSimulation = {
|
||||
situation: Simulation['situation']
|
||||
|
@ -14,6 +15,6 @@ export const currentSimulationSelector = (
|
|||
return {
|
||||
situation: state.simulation?.situation ?? {},
|
||||
activeTargetInput: state.activeTargetInput,
|
||||
foldedSteps: state.simulation?.foldedSteps,
|
||||
foldedSteps: state.simulation?.answeredQuestions,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
import { createSelector } from 'reselect'
|
||||
|
||||
import { currentQuestionSelector } from '@/store/selectors/currentQuestion.selector'
|
||||
import { questionsSuivantesSelector } from '@/store/selectors/questionsSuivantes.selector'
|
||||
|
||||
export const questionEnCoursRépondueSelector = createSelector(
|
||||
[currentQuestionSelector, questionsSuivantesSelector],
|
||||
(questionEnCours, questionsSuivantes) =>
|
||||
!!questionEnCours &&
|
||||
!!questionsSuivantes &&
|
||||
!questionsSuivantes.includes(questionEnCours)
|
||||
)
|
|
@ -0,0 +1,8 @@
|
|||
import { createSelector } from 'reselect'
|
||||
|
||||
import { simulationSelector } from '@/store/selectors/simulation.selector'
|
||||
|
||||
export const questionsSuivantesSelector = createSelector(
|
||||
[simulationSelector],
|
||||
(simulation) => simulation?.questionsSuivantes || []
|
||||
)
|
|
@ -0,0 +1,3 @@
|
|||
import { RootState } from '@/store/reducers/rootReducer'
|
||||
|
||||
export const simulationSelector = (state: RootState) => state.simulation
|
|
@ -1,13 +1,9 @@
|
|||
import { DottedName } from 'modele-social'
|
||||
import Engine, { utils } from 'publicodes'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { createSelector } from 'reselect'
|
||||
|
||||
import { useEngine } from '@/components/utils/EngineContext'
|
||||
import { isComparateurConfig } from '@/domaine/ComparateurConfig'
|
||||
import { RootState, Situation } from '@/store/reducers/rootReducer'
|
||||
|
||||
export const configSelector = (state: RootState) =>
|
||||
state.simulation?.config ?? {}
|
||||
import { configSelector } from '@/store/selectors/config.selector'
|
||||
import { NonEmptyArray } from 'effect/Array'
|
||||
|
||||
export const configObjectifsSelector = createSelector(
|
||||
[
|
||||
|
@ -22,31 +18,40 @@ export const configObjectifsSelector = createSelector(
|
|||
|
||||
const emptySituation: Situation = {}
|
||||
|
||||
export const useMissingVariables = ({
|
||||
engines,
|
||||
}: {
|
||||
engines: Array<Engine<DottedName>>
|
||||
}): Partial<Record<DottedName, number>> => {
|
||||
const objectifs = useSelector(configObjectifsSelector)
|
||||
|
||||
return treatAPIMissingVariables(
|
||||
objectifs
|
||||
.flatMap((objectif) =>
|
||||
engines.map((e) => e.evaluate(objectif).missingVariables ?? {})
|
||||
)
|
||||
.reduce(mergeMissing, {}),
|
||||
useEngine()
|
||||
)
|
||||
}
|
||||
export const situationSelector = (state: RootState) =>
|
||||
state.simulation?.situation ?? emptySituation
|
||||
|
||||
export const configSituationSelector = (state: RootState) =>
|
||||
configSelector(state).situation ?? emptySituation
|
||||
|
||||
export const configContextesSelector = createSelector(
|
||||
[configSelector],
|
||||
(config) => (isComparateurConfig(config) ? config.contextes : undefined)
|
||||
)
|
||||
|
||||
export const companySituationSelector = (state: RootState) =>
|
||||
state.companySituation
|
||||
|
||||
export const rawSituationSelector = createSelector(
|
||||
[situationSelector, configSituationSelector, companySituationSelector],
|
||||
(simulatorSituation, configSituation, companySituation) => ({
|
||||
...companySituation,
|
||||
...configSituation,
|
||||
...simulatorSituation,
|
||||
})
|
||||
)
|
||||
|
||||
export const rawSituationsSelonContextesSelector = createSelector(
|
||||
[rawSituationSelector, configContextesSelector],
|
||||
(rawSituation, contextes) =>
|
||||
(contextes
|
||||
? contextes.map((contexte) => ({
|
||||
...rawSituation,
|
||||
...contexte,
|
||||
}))
|
||||
: [rawSituation]) as NonEmptyArray<Situation>
|
||||
)
|
||||
|
||||
export const firstStepCompletedSelector = (state: RootState) => {
|
||||
const situation = situationSelector(state)
|
||||
|
||||
|
@ -61,48 +66,7 @@ export const firstStepCompletedSelector = (state: RootState) => {
|
|||
export const targetUnitSelector = (state: RootState) =>
|
||||
state.simulation?.targetUnit ?? '€/mois'
|
||||
|
||||
export const currentQuestionSelector = (state: RootState) =>
|
||||
state.simulation?.unfoldedStep ?? null
|
||||
|
||||
export const answeredQuestionsSelector = (state: RootState) =>
|
||||
state.simulation?.foldedSteps ?? []
|
||||
|
||||
export const shouldFocusFieldSelector = (state: RootState) =>
|
||||
state.simulation?.shouldFocusField ?? false
|
||||
state.simulation?.answeredQuestions ?? []
|
||||
|
||||
export const urlSelector = (state: RootState) => state.simulation?.url
|
||||
|
||||
/**
|
||||
* Merge objectifs missings that depends on the same input field.
|
||||
*
|
||||
* For instance, the commune field (API) will fill `commune . nom` `commune . taux versement transport`, `commune . département`, etc.
|
||||
*/
|
||||
function treatAPIMissingVariables<Name extends string>(
|
||||
missingVariables: Partial<Record<Name, number>>,
|
||||
engine: Engine<Name>
|
||||
): Partial<Record<Name, number>> {
|
||||
return (Object.entries(missingVariables) as Array<[Name, number]>).reduce(
|
||||
(missings, [name, value]: [Name, number]) => {
|
||||
const parentName = utils.ruleParent(name) as Name
|
||||
if (parentName && engine.getRule(parentName).rawNode.API) {
|
||||
missings[parentName] = (missings[parentName] ?? 0) + value
|
||||
|
||||
return missings
|
||||
}
|
||||
missings[name] = value
|
||||
|
||||
return missings
|
||||
},
|
||||
{} as Partial<Record<Name, number>>
|
||||
)
|
||||
}
|
||||
const mergeMissing = (
|
||||
left: Record<string, number> | undefined = {},
|
||||
right: Record<string, number> | undefined = {}
|
||||
): Record<string, number> =>
|
||||
Object.fromEntries(
|
||||
[...Object.keys(left), ...Object.keys(right)].map((key) => [
|
||||
key,
|
||||
(left[key] ?? 0) + (right[key] ?? 0),
|
||||
])
|
||||
)
|
||||
|
|
|
@ -1,18 +1,19 @@
|
|||
import { composeWithDevToolsDevelopmentOnly } from '@redux-devtools/extension'
|
||||
import { createReduxEnhancer } from '@sentry/react'
|
||||
import Engine from 'publicodes'
|
||||
import { applyMiddleware, createStore, StoreEnhancer } from 'redux'
|
||||
|
||||
import reducers from '@/store/reducers/rootReducer'
|
||||
|
||||
import {
|
||||
retrievePersistedChoixStatutJuridique,
|
||||
setupChoixStatutJuridiquePersistence,
|
||||
} from '../storage/persistChoixStatutJuridique'
|
||||
} from '@/storage/persistChoixStatutJuridique'
|
||||
import {
|
||||
retrievePersistedCompanySituation,
|
||||
setupCompanySituationPersistence,
|
||||
} from '../storage/persistCompanySituation'
|
||||
import { setupSimulationPersistence } from '../storage/persistSimulation'
|
||||
} from '@/storage/persistCompanySituation'
|
||||
import { setupSimulationPersistence } from '@/storage/persistSimulation'
|
||||
import { prendLaProchaineQuestionMiddleware } from '@/store/middlewares/prendLaProchaineQuestion.middleware'
|
||||
import reducers from '@/store/reducers/rootReducer'
|
||||
|
||||
const initialStore = {
|
||||
choixStatutJuridique: retrievePersistedChoixStatutJuridique(),
|
||||
|
@ -25,10 +26,17 @@ const composeEnhancers = composeWithDevToolsDevelopmentOnly(
|
|||
|
||||
const sentryReduxEnhancer = createReduxEnhancer({}) as StoreEnhancer
|
||||
|
||||
const storeEnhancer = composeEnhancers(applyMiddleware(), sentryReduxEnhancer)
|
||||
export const makeStore = (engine: Engine) => {
|
||||
const storeEnhancer = composeEnhancers(
|
||||
applyMiddleware(prendLaProchaineQuestionMiddleware(engine)),
|
||||
sentryReduxEnhancer
|
||||
)
|
||||
|
||||
export const store = createStore(reducers, initialStore, storeEnhancer)
|
||||
const store = createStore(reducers, initialStore, storeEnhancer)
|
||||
|
||||
setupChoixStatutJuridiquePersistence(store)
|
||||
setupCompanySituationPersistence(store)
|
||||
setupSimulationPersistence(store)
|
||||
setupChoixStatutJuridiquePersistence(store)
|
||||
setupCompanySituationPersistence(store)
|
||||
setupSimulationPersistence(store)
|
||||
|
||||
return store
|
||||
}
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
type Not<B extends boolean> = B extends true ? false : true
|
||||
|
||||
export const complement =
|
||||
<A extends unknown[], B extends boolean>(f: (...args: A) => B) =>
|
||||
(...args: A) =>
|
||||
!f(...args) as Not<B>
|
|
@ -120,6 +120,17 @@ export function omit<T extends object, K extends keyof T>(
|
|||
return ret
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out elements from the given array that satisfy the provided predicate.
|
||||
*
|
||||
* @param {Array} array - The array to filter.
|
||||
* @param {Function} predicate - The function used to test each element.
|
||||
* @returns {Array} - An array containing the elements that do not satisfy the predicate.
|
||||
*/
|
||||
export function reject<T>(array: T[], predicate: (value: T) => boolean): T[] {
|
||||
return array.filter((value) => !predicate(value))
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms an object into entries which is then passed to the transform function to be
|
||||
* modified as desired with map, filter, etc., then transformed back into an object
|
||||
|
|
|
@ -1,75 +0,0 @@
|
|||
import rules from 'modele-social'
|
||||
import Engine from 'publicodes'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getNextQuestions } from '../source/hooks/useNextQuestion'
|
||||
|
||||
describe('conversation', function () {
|
||||
it('should start with the first missing variable', function () {
|
||||
const missingVariables = new Engine({
|
||||
// TODO - this won't work without the indirection, figure out why
|
||||
top: 'oui',
|
||||
'top . startHere': { formule: { somme: ['a', 'b'] } },
|
||||
'top . a': { question: '?', titre: 'a', unité: '€' },
|
||||
'top . b': { question: '?', titre: 'b', unité: '€' },
|
||||
}).evaluate('top . startHere').missingVariables
|
||||
expect(getNextQuestions(missingVariables)[0]).toBe('top . a')
|
||||
})
|
||||
it('should first ask for questions without defaults, then those with defaults', function () {
|
||||
const engine = new Engine({
|
||||
net: { formule: 'brut - cotisation' },
|
||||
brut: {
|
||||
question: 'Quel est le salaire brut ?',
|
||||
unité: '€/an',
|
||||
},
|
||||
cotisation: {
|
||||
formule: {
|
||||
produit: [
|
||||
'brut',
|
||||
{
|
||||
variations: [
|
||||
{
|
||||
si: 'cadre',
|
||||
alors: '77%',
|
||||
},
|
||||
{
|
||||
sinon: '80%',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
cadre: {
|
||||
question: 'Est-ce un cadre ?',
|
||||
},
|
||||
})
|
||||
|
||||
expect(getNextQuestions(engine.evaluate('net').missingVariables)[0]).toBe(
|
||||
'brut'
|
||||
)
|
||||
|
||||
engine.setSituation({
|
||||
brut: 2300,
|
||||
})
|
||||
|
||||
expect(getNextQuestions(engine.evaluate('net').missingVariables)[0]).toBe(
|
||||
'cadre'
|
||||
)
|
||||
})
|
||||
|
||||
it('should ask "motif CDD" if "CDD" applies', function () {
|
||||
const result = Object.keys(
|
||||
new Engine(rules)
|
||||
.setSituation({
|
||||
salarié: 'oui',
|
||||
'salarié . contrat . CDD': 'oui',
|
||||
'salarié . contrat . salaire brut': '2300',
|
||||
})
|
||||
.evaluate('salarié . rémunération . net . à payer avant impôt')
|
||||
.missingVariables
|
||||
)
|
||||
|
||||
expect(result).toContain('salarié . contrat . CDD . motif')
|
||||
})
|
||||
})
|
|
@ -6,14 +6,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||
import { setupSimulationPersistence } from '@/storage/persistSimulation'
|
||||
import * as safeLocalStorage from '@/storage/safeLocalStorage'
|
||||
import {
|
||||
enregistreLaRéponse,
|
||||
loadPreviousSimulation,
|
||||
setSimulationConfig,
|
||||
updateSituation,
|
||||
} from '@/store/actions/actions'
|
||||
import reducers, {
|
||||
Simulation,
|
||||
SimulationConfig,
|
||||
} from '@/store/reducers/rootReducer'
|
||||
import reducers, { SimulationConfig } from '@/store/reducers/rootReducer'
|
||||
import { Simulation } from '@/store/reducers/simulation.reducer'
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
@ -31,9 +29,8 @@ const initialSimulation: Simulation = {
|
|||
hiddenNotifications: [],
|
||||
situation: {},
|
||||
targetUnit: '€/mois',
|
||||
foldedSteps: ['somestep' as DottedName],
|
||||
unfoldedStep: null,
|
||||
shouldFocusField: false,
|
||||
answeredQuestions: ['somestep' as DottedName],
|
||||
currentQuestion: null,
|
||||
}
|
||||
|
||||
describe('[persistence] When simulation persistence is setup', () => {
|
||||
|
@ -51,7 +48,7 @@ describe('[persistence] When simulation persistence is setup', () => {
|
|||
|
||||
describe('when the state is changed with some data that is persistable', () => {
|
||||
beforeEach(async () => {
|
||||
store.dispatch(updateSituation('dotted name' as DottedName, '42'))
|
||||
store.dispatch(enregistreLaRéponse('dotted name' as DottedName, '42'))
|
||||
await delay(0)
|
||||
})
|
||||
it('saves state in localStorage with all fields', () => {
|
||||
|
|
|
@ -9,7 +9,7 @@ import { EvaluatedNode, Evaluation } from 'publicodes'
|
|||
import { expect } from 'vitest'
|
||||
|
||||
import { engineFactory } from '@/components/utils/EngineContext'
|
||||
import { Simulation } from '@/store/reducers/rootReducer'
|
||||
import { Simulation } from '@/store/reducers/simulation.reducer'
|
||||
|
||||
type SituationsSpecs = Record<string, Simulation['situation'][]>
|
||||
|
||||
|
|
24
yarn.lock
24
yarn.lock
|
@ -12234,6 +12234,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/deep-eql@npm:^4.0.2":
|
||||
version: 4.0.2
|
||||
resolution: "@types/deep-eql@npm:4.0.2"
|
||||
checksum: 249a27b0bb22f6aa28461db56afa21ec044fa0e303221a62dff81831b20c8530502175f1a49060f7099e7be06181078548ac47c668de79ff9880241968d43d0c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/detect-port@npm:^1.3.0":
|
||||
version: 1.3.2
|
||||
resolution: "@types/detect-port@npm:1.3.2"
|
||||
|
@ -16998,6 +17005,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"deep-eql@npm:^5.0.1":
|
||||
version: 5.0.1
|
||||
resolution: "deep-eql@npm:5.0.1"
|
||||
checksum: 8009e8a8bf3e0f591a122e7788e304a2bed1299b7774f039be96f9ef35c00fb254292fb1568952651aea0c1d1eb23d0bca484bbdd2cf4fcee685c6f2c43670f3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"deep-equal@npm:^2.0.5":
|
||||
version: 2.2.0
|
||||
resolution: "deep-equal@npm:2.2.0"
|
||||
|
@ -17575,6 +17589,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"effect@npm:^3.0.0":
|
||||
version: 3.0.1
|
||||
resolution: "effect@npm:3.0.1"
|
||||
checksum: e2284b2789fc73095653ff1a44628cf57a4dd11de61ed458c16807f83132caa91abf3a6f8238a6021efdbd3c8805d696c859d5daecbe7da092fab0d58aaa6ab8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ejs@npm:^3.1.6":
|
||||
version: 3.1.8
|
||||
resolution: "ejs@npm:3.1.8"
|
||||
|
@ -29212,6 +29233,7 @@ __metadata:
|
|||
"@storybook/testing-library": ^0.1.0
|
||||
"@testing-library/jest-dom": ^6.4.2
|
||||
"@testing-library/react": ^14.2.1
|
||||
"@types/deep-eql": ^4.0.2
|
||||
"@types/history": ^5.0.0
|
||||
"@types/react": ^18.2.22
|
||||
"@types/react-dom": ^18.2.7
|
||||
|
@ -29227,7 +29249,9 @@ __metadata:
|
|||
cypress-plugin-tab: ^1.0.5
|
||||
cypress-wait-until: ^1.7.2
|
||||
date-fns: ^3.6.0
|
||||
deep-eql: ^5.0.1
|
||||
dotenv: ^16.3.1
|
||||
effect: ^3.0.0
|
||||
eslint-plugin-testing-library: ^6.2.0
|
||||
exoneration-covid: "workspace:^"
|
||||
focus-trap-react: ^10.2.1
|
||||
|
|
Loading…
Reference in New Issue