[moteur] Premier POC simulateur CDD

L'UI du simulateur de coût d'embauche, conversationnelle, y a été greffée.
Le graphe des dépendance des variable est calaculé pour afficher les bonnes questions, et le résultat.
Seule une variable est prise en compte, le CIF CDD :-D
pull/6/head
Mael Thomas 2017-01-10 19:22:44 +01:00
parent 79dde7e555
commit 45fcb5f118
46 changed files with 1993 additions and 858 deletions

View File

@ -24,7 +24,9 @@
"react-json-tree": "^0.10.0",
"react-redux": "^4.4.5",
"react-router": "^2.6.1",
"reduce-reducers": "^0.1.2",
"redux": "^3.5.2",
"redux-form": "^6.4.3",
"redux-saga": "^0.10.5",
"reselect": "^2.5.2",
"whatwg-fetch": "^1.0.0"

View File

@ -10,9 +10,10 @@
- CDD poursuivi en CDI
# Types de CDD
- CDD type saisonnier
- contrat jeune vacances
- contrat aidé # voir la définition précise dans indemnité de fin de contrat
- apprentissage
# TODO Commentés pour le développement de la démo CDD seulement
# - contrat jeune vacances
# - contrat aidé # voir la définition précise dans indemnité de fin de contrat
# - apprentissage
formule:
linéaire:

View File

@ -10,6 +10,6 @@
- Variable: assiette cotisations sociales
description: L'assiette de la plupart des cotisations sociales pour le calcul des cotisations sociales sur le travail salarié.
formule:
somme: ? # donc type numérique
somme: ? # juste pour le type numérique
# - salaire brut
# - primes etc.

View File

@ -0,0 +1,29 @@
// The input "conversation" is composed of "steps"
// The state keeps track of which of them have been submitted
// The user can also come back to one of his answers and edit it
export const STEP_ACTION = 'STEP_ACTION'
export function stepAction(name, newState) {
return {type: STEP_ACTION, name, newState}
}
export const START_CONVERSATION = 'START_CONVERSATION'
// Reset the form
export const UNSUBMIT_ALL = 'UNSUBMIT_ALL'
// Collect the input information from the forms, send them to the simulation engine API
// then update the results in the UI
export const SIMULATION_UPDATE_REQUEST = 'SIMULATION_UPDATE_REQUEST'
export const SIMULATION_UPDATE_SUCCESS = 'SIMULATION_UPDATE_SUCCESS'
export const SIMULATION_UPDATE_FAILURE = 'SIMULATION_UPDATE_FAIL'
// Modify the UI parts displayed to the user
export const TOGGLE_TOP_SECTION = 'TOGGLE_TOP_SECTION'
export const TOGGLE_ADVANCED_SECTION = 'TOGGLE_ADVANCED_SECTION'
// The initial request triggers the display of results based on default input information (not filled by the user)
export const INITIAL_REQUEST = 'INITIAL_REQUEST'
export const CHANGE_THEME_COLOUR = 'CHANGE_THEME_COLOUR'
export function changeThemeColour(colour) {return {type: CHANGE_THEME_COLOUR, colour}}

View File

@ -2,20 +2,22 @@
padding: 2em;
}
#sim section {
padding: 2em;
}
#conversation {
margin: 3em auto;
font-size: 120%;
line-height: normal;
display: flex;
justify-content: space-around;
min-height: 10em;
margin: 3em 0;
max-width: 80%;
}
#questions-answers {
background: blue;
min-width: 50%;
}
@ -25,6 +27,16 @@
}
#results {
width: 100%;
width: 90%;
background: purple;
}
#results ul {
list-style: none;
}
#results li {
display: inline-block;
border: 1px solid;
padding: .6em 2em;
}

View File

@ -1,47 +1,55 @@
import React, { Component } from 'react'
import {analyseSituation, variableType} from '../traverse'
import './CDD.css'
import IntroCDD from './IntroCDD'
import Results from './Results'
import {reduxForm, formValueSelector} from 'redux-form'
import {connect} from 'react-redux'
import './conversation/conversation.css'
import {START_CONVERSATION} from '../actions'
@connect(({form: {conversation}}) => ({conversationState: conversation && conversation.values}))
class Aide extends Component {
render() {
return <section id="help">
{JSON.stringify(this.props.conversationState)}
</section>
}
}
let situationSelector = formValueSelector('conversation')
@reduxForm(
{form: 'conversation'}
)
@connect(state => ({
situation: variableName => situationSelector(state, variableName),
steps: state.steps,
themeColours: state.themeColours,
analysedSituation: state.analysedSituation
}), dispatch => ({
startConversation: () => dispatch({type: START_CONVERSATION})
}))
export default class CDD extends Component {
state = {
situation: {}
componentDidMount() {
this.props.startConversation()
}
render() {
let [missingVariable] = analyseSituation(this.state.situation)
let type = variableType(missingVariable)
let {steps} = this.props
let conversation = steps.map(step =>
<step.component key={step.name} {...step}/>
)
return (
<div id="sim">
<section id="introduction">
<p>
Le CDD en France est un contrat d'exception au CDI. On y a donc recours sous certaines conditions seulement. Cet outil vous aidera à respecter ces conditions et à calculer le prix mensuel de l'embauche, qui en dépend, en vous proposant une suite de questions.
Ici, vous avez le droit de ne pas savoir : certaines questions sont complexes, elles seront toujours accompagnées d'une aide contextuelle. Si ce n'est pas le cas, engueulez-nous* !
</p>
<p>
*: écrivez à contact@contact.contact (on fera mieux après). La loi française est complexe, souvent à raison. Nous ne la changerons pas, mais pouvons la rendre plus transparente.
</p>
</section>
<IntroCDD />
<div id="conversation">
<section id="questions-answers">
<form onSubmit={e => e.preventDefault()}>
<label>
{missingVariable}
<input type="text"
value={this.state.value}
onChange={e => this.setState({situation: {[missingVariable]: true}}) } />
</label>
<input type="submit" value="Submit" />
</form>
</section>
<section id="help">
Aide
{conversation}
</section>
<Aide />
</div>
<section id="results">
Résultats
</section>
<Results {...this.props}/>
</div>
)
}

View File

@ -0,0 +1,15 @@
import React, {Component} from 'react'
export default DecoratedComponent =>
class extends Component {
state = {
hover: false,
}
toggleHover = () =>
this.setState({hover: !this.state.hover})
render() {
return <span onMouseEnter={this.toggleHover} onMouseLeave={this.toggleHover} >
<DecoratedComponent {...this.props} hover={this.state.hover}/>
</span>
}
}

View File

@ -0,0 +1,13 @@
import React from 'react'
export default () =>
<section id="introduction">
<p>
Le CDD en France est un contrat d'exception au CDI. On y a donc recours sous certaines conditions seulement. Cet outil vous aidera à respecter ces conditions et à calculer le prix mensuel de l'embauche, qui en dépend, en vous proposant une suite de questions.
Ici, vous avez le droit de ne pas savoir : certaines questions sont complexes, elles seront toujours accompagnées d'une aide contextuelle. Si ce n'est pas le cas, engueulez-nous* !
</p>
<p>
*: écrivez à contact@contact.contact (on fera mieux après). La loi française est complexe, souvent à raison. Nous ne la changerons pas, mais pouvons la rendre plus transparente.
</p>
</section>

View File

@ -0,0 +1,27 @@
import React, { Component } from 'react'
export default class Results extends Component {
render() {
let {analysedSituation} = this.props
return (
<section id="results">
<h2>Cotisations</h2>
<ul>
{analysedSituation.map(({name, type, derived: [dependencies, value]}) =>
<li key={name}>
<h3>{type} {name}</h3>
<p>
{dependencies && dependencies.length ?
'Répondez aux questions !'
: value != null ?
value + '€'
: 'Non applicable'
}
</p>
</li>
)}
</ul>
</section>
)
}
}

View File

@ -1,50 +0,0 @@
#calculable-items>li {
margin-top: 4em;
padding-bottom: 2em;
border-bottom: 1px solid #eee;
}
.item .left {
display: inline-block;
width: 30%;
}
.item .right {
display: inline-block;
width: 40%;
}
/* Display formulas */
.calc {
text-align: center;
}
.calc h3 {
font-weight: 300;
font-size: 100%;
color: #666;
text-transform: uppercase;
}
.linear {
font-size: 130%;
}
.linear .label {
font-size: 70%;
color: #333;
color: #2980b9;
}
.linear .operator {
margin: 0 1em;
color: #2980b9;
font-size: 150%;
}
.linear .limit {
margin-top: 1em;
}
.linear .limit .label {
margin-right: 1em;
}
.linear .base, .calc .rate {
display: inline-block;
}

View File

@ -1,106 +0,0 @@
import React, { Component } from 'react'
import './SelectedVariable.css'
import TagMap from './TagMap'
import R from 'ramda'
export default class SelectedVariable extends Component {
render() {
let {
variable: {
name,
first: {
description
},
tags,
calculable
},
selectedTags
} = this.props,
tagsList = R.pluck('tags', calculable),
commonTags = R.tail(tagsList).reduce(
(result, next) => R.intersection(result, R.toPairs(next)),
R.toPairs(R.head(tagsList))
),
itemsWithUniqueTags = R.map(item => [item, R.fromPairs(R.difference(R.toPairs(item.tags), commonTags))], calculable)
return (
<section id="selected-variable">
<h1>{name}</h1>
<p>{description}</p>
<TagMap data={commonTags} />
{/*
<ul>
{Object.keys(tags)
.filter(name => !selectedTags.find(([n]) => name == n))
.map(name =>
<li key={name}>
{name + ': ' + tags[name]}
</li>
)}
</ul>
*/}
<Items itemsWithUniqueTags={itemsWithUniqueTags}/>
</section>)
}
}
class Items extends Component {
render() {
let {itemsWithUniqueTags} = this.props
return (
<ul id="calculable-items">
{itemsWithUniqueTags.map(([item, tags], i) =>
<Item key={i} item={item} tags={tags}/>
)}
</ul>
)
}
}
let Item = ({item: {linear, marginalRateTaxScale}, tags}) =>
<li className="item">
<div className="left">
<TagMap data={tags} />
</div>
<div className="right">
{ linear && <Linear data={linear}/>
|| marginalRateTaxScale && <TaxScale data={marginalRateTaxScale}/>
}
</div>
</li>
let Linear = ({data: {
base,
limit,
historique
}}) => <div className="calc">
<h3>Calcul linéaire</h3>
<div className="linear">
<div className="base">
<div className="label">base</div>
<div className="value">{base}</div>
</div>
<span className="operator">
</span>
<div className="rate">
<div className="label">Taux</div>
<div className="value">{
historique[(Object.keys(historique).sort()[0])]
}</div>
</div>
{ limit != null && <div className="limit">
<span className="label">dans la limite de : </span>
<span className="value"> {limit}</span>
</div> }
</div>
</div>
let TaxScale = ({data}) => <div className="calc tax-scale">
<h3>Règle de calcul: barème</h3>
{JSON.stringify(data)}
</div>

View File

@ -1,13 +0,0 @@
import React from 'react'
import R from 'ramda'
let TagMap = ({data}) =>
<ul className="tag-map">
{R.unless(R.isArrayLike, R.toPairs)(data).map(([name, value]) => <li key={name}>
<span>{name}</span> :
<span className="tag-value">{value}</span>
</li>)
}
</ul>
export default TagMap

View File

@ -1,48 +0,0 @@
import React from 'react'
import TagMap from './TagMap'
export default class TagNavigation extends React.Component {
render(){
let {tagsToSelect, selectedTags, selectTag, resetTags} = this.props
return (
<section id="tag-navigation">
<h2> Explorez par catégorie</h2>
<div className="content">
{selectedTags.length > 0 &&
<div id="selected">
<TagMap data={selectedTags} />
<button onClick={resetTags}>Effacer ma sélection </button>
</div>
}
<ul id="to-select">
{tagsToSelect.map(tag =>
<Tag selectTag={selectTag} key={tag.name} tag={tag} />
)}
</ul>
</div>
</section>
)
}
}
class Tag extends React.Component {
render(){
let {tag: {name, choices, number}, selectTag} = this.props
return (<li>
<span className="name">
{name}
<span className="nb">
({number} variable{number > 1 ? 's' : ''})
</span>
</span>
<ul className="choices">
{[...choices].map(c =>
<li className="tag-value" key={c} onClick={() => selectTag(name, c)}>
{c}
</li>
)}
</ul>
</li>)
}
}

View File

@ -1,49 +0,0 @@
import React from 'react'
import SelectedVariable from './SelectedVariable'
import colors from './variable-colors.yaml'
console.log(colors.length);
import R from 'ramda'
function convertHex(hex,opacity){
let r = parseInt(hex.substring(0,2), 16),
g = parseInt(hex.substring(2,4), 16),
b = parseInt(hex.substring(4,6), 16),
result =`rgba(${r},${g},${b},${opacity})`
return result
}
const Variable = ({color, name, selectVariable}) =>
<li
className="variable" style={{background: convertHex(color, .2)}}
onClick={() => selectVariable(name)} >
<h3>{name}</h3>
</li>
export default class Variables extends React.Component {
render(){
let {variables, selectedTags, selectedVariable, selectVariable} = this.props
console.log('variables prop in <Variables', variables)
// let
// variableSet =
// variables.reduce((set, {variable}) => set.add(variable), new Set()), // get unique variable names
// variableColors = [...variableSet].reduce((correspondance, v, i) => Object.assign(correspondance, {[v]: colors[i]}), {})
console.log('selectedVariable',selectedVariable)
if (selectedVariable != null)
return <SelectedVariable
variable={R.find(R.propEq('name', selectedVariable))(variables)}
selectedTags={selectedTags}
/>
return <ul id="variables">
{variables.map((v, i) =>
<Variable key={i}
color={colors[i]} name={v.name}
selectVariable={selectVariable}
/>
)}
</ul>
}
}

View File

@ -0,0 +1,124 @@
import React, {Component} from 'react'
import { connect } from 'react-redux'
import Question from '../components/Forms/Question'
import Input from '../components/Forms/Input'
import SelectCommune from '../components/Forms/SelectCommune'
import SelectTauxRisque from '../components/Forms/SelectTauxRisque'
import RhetoricalQuestion from '../components/Forms/RhetoricalQuestion'
import TextArea from '../components/Forms/TextArea'
import Group from '../components/Group'
import ResultATMP from '../components/ResultATMP'
import {reduxForm, formValueSelector} from 'redux-form'
import { percentage } from '../formValueTypes.js'
import validate from '../conversation-validate'
let advancedInputSelector = formValueSelector('advancedQuestions'),
basicInputSelector = formValueSelector('basicInput')
@reduxForm({
form: 'advancedQuestions',
validate,
})
@connect(state => ({
formValue: (field, simple) => simple ? basicInputSelector(state, field): advancedInputSelector(state, field),
steps: state.steps,
themeColours: state.themeColours
}))
class Conversation extends Component {
render() {
let { formValue, steps, themeColours: {colour, textColour}} = this.props
let effectifEntreprise = formValue('effectifEntreprise', 'basicInput')
/* C'est ici qu'est définie la suite de questions à poser. */
return (
<div id="conversation">
<SelectCommune
visible={effectifEntreprise >= 10}
title="Commune"
question="Quelle est la commune de l'embauche ?"
name="codeINSEE" />
<Input
title="Complémentaire santé"
question="Quel est le montant total par salarié de la complémentaire santé obligatoire de l'entreprise ?"
visible={effectifEntreprise < 10 || steps.get('codeINSEE')}
name="mutuelle" />
<Group
text="Risques professionnels"
visible={steps.get('mutuelle')}
foldTrigger="tauxRisque"
valueType={percentage}
>
<Question
visible={true}
title="Taux de risque connu"
question="Connaissez-vous votre taux de risque AT/MP ?"
name="tauxRisqueConnu" />
<Input
title="Taux de risque"
question="Entrez votre taux de risque"
visible={formValue('tauxRisqueConnu') == 'Oui'}
name="tauxRisque" />
<Group name="tauxInconnu" visible={formValue('tauxRisqueConnu')== 'Non'}>
<SelectTauxRisque
visible={true}
title="Code de risque sélectionné"
question="Quelle est la catégorie de risque de votre entreprise ?"
name="selectTauxRisque" />
<ResultATMP
name="resultATMP"
selectedTauxRisque={formValue('selectTauxRisque')}
formValue={formValue}
{...{steps}}
effectif={formValue('effectifEntreprise', 'basicInput')} />
</Group>
</Group>
<Input
title="Pourcentage d'alternants"
question="Quel est le pourcentage d'alternants dans votre entreprise ?"
visible={effectifEntreprise >= 249 && steps.get('tauxRisque')}
name="pourcentage_alternants" />
<Question
visible={
(effectifEntreprise < 249 && steps.get('tauxRisque'))
|| steps.get('pourcentage_alternants')}
title="Régime Alsace-Moselle"
question="Le salarié est-il affilié au régime d'Alsace-Moselle ?"
name="alsaceMoselle" />
<Question
title="Pénibilité du travail"
question="Le salarié est-il exposé à des facteurs de pénibilité au-delà des seuils d'exposition ?"
visible={steps.get('alsaceMoselle')}
name="penibilite" />
<Question
title="Exonération Jeune Entreprise Innovante"
question="Profitez-vous du statut Jeune Entreprise Innovante pour cette embauche ?"
visible={steps.get('penibilite')}
name="jei" />
<Question
title="Votre avis"
question="Votre estimation est terminée. En êtes-vous satisfait ?"
visible={steps.get('jei')}
name="serviceUtile" />
<RhetoricalQuestion
visible={formValue('serviceUtile') === ':-)'}
name="partage"
question={<span>
Merci. N'hésitez pas à partager le simulateur !
</span>
} />
<TextArea
visible={formValue('serviceUtile') === ':-|'}
name="remarque"
title="Votre remarque"
question={'Que pouvons-nous faire pour l\'améliorer ?'}
/>
</div>)
}
}
export default Conversation

View File

@ -0,0 +1,172 @@
import React, { Component } from 'react'
import classNames from 'classnames'
import { connect } from 'react-redux'
import {Field, change} from 'redux-form'
import {stepAction} from '../../actions'
import IgnoreStepButton from './IgnoreStepButton'
import StepAnswer from './StepAnswer'
/*
This higher order component wraps "Form" components (e.g. Question.js), that represent user inputs,
with a header, click actions and more goodies.
Read https://medium.com/@dan_abramov/mixins-are-dead-long-live-higher-order-components-94a0d2f9e750
to understand those precious higher order components.
*/
export var FormDecorator = formType => RenderField =>
@connect( //... this helper directly to the redux state to avoid passing more props
state => ({
steps: state.steps,
answers: state.form.conversation && state.form.conversation.values,
themeColours: state.themeColours
}),
dispatch => ({
stepAction: (name, newState) => dispatch(stepAction(name, newState)),
setFormValue: (field, value) => dispatch(change('conversation', field, value))
})
)
class extends Component {
state = {
helpVisible: false
}
render() {
let {
name,
visible,
steps,
stepAction,
possibleChoice, // should be found in the question set theoritically, but it is used for a single choice question -> the question itself is dynamic and cannot be input as code,
themeColours,
// formerly in conversation-steps
valueType,
defaultValue,
attributes,
choices,
optionsURL,
human,
helpText
} = this.props
this.step = steps.find(s => s.name == name)
let ignoreStep = () => {
// Renseigne automatiquement la valeur de la saisie (en se plongeant dans les entrailles de redux-form)
this.props.setFormValue(name, defaultValue)
stepAction(name, 'ignored')
}
/* La saisie peut être cachée car ce n'est pas encore son tour,
ou parce qu'elle a déjà été remplie. Dans ce dernier cas, un résumé
de la réponse est affiché */
let stepState = this.step.state,
completed = stepState && stepState != 'editing',
unfolded = !completed
if (!visible) return null
/* Nos propriétés personnalisées à envoyer au RenderField.
Elles sont regroupées dans un objet précis pour pouvoir être enlevées des
props passées à ce dernier, car React 15.2 n'aime pas les attributes inconnus
des balises html, <input> dans notre cas.
*/
let stepProps = {
attributes, /* Input component's html attributes */
choices, /* Question component's radio choices */
optionsURL, /* Select component's data source */
possibleChoice, /* RhetoricalQuestion component's only choice :'-( */
//TODO hack, enables redux-form/CHANGE to update the form state before the traverse functions are run
submit: () => setTimeout(() => stepAction(name, 'filled'), 1),
valueType
}
/* There won't be any answer zone here, widen the question zone */
let wideQuestion = formType == 'rhetorical-question' && !possibleChoice
return (
<div className={classNames('step', {unfolded}, formType)} >
{this.state.helpVisible && this.renderHelpBox(helpText)}
<div style={{visibility: this.state.helpVisible ? 'hidden' : 'visible'}}>
{this.renderHeader(unfolded, valueType, human, helpText, wideQuestion)}
{unfolded &&
<fieldset>
{ defaultValue &&
<IgnoreStepButton name={name} action={ignoreStep}/>
}
<Field
component={RenderField}
name={name}
stepProps={stepProps}
themeColours={themeColours}
/>
</fieldset>
}
</div>
</div>
)
}
/*
< Le titre de ma question > ----------- < (? bulle d'aide) OU résultat >
*/
renderHeader(unfolded, valueType, human, helpText, wideQuestion) {
return (
<span className="form-header" >
{ unfolded ? this.renderQuestion(unfolded, helpText, wideQuestion) : this.renderTitleAndAnswer(valueType, human)}
</span>
)
}
renderQuestion = (unfolded, helpText, wideQuestion) =>
<span>
<h1
style={{
border: '2px solid ' + this.props.themeColours.colour, // higher border width and colour to emphasize focus
background: 'none',
color: this.props.themeColours.textColourOnWhite,
maxWidth: wideQuestion ? '95%' : ''
}}
>{this.props.question}</h1>
{helpText &&
<span
className="help-button"
onClick={() => this.setState({helpVisible: true})}>
aide
</span>
}
</span>
renderTitleAndAnswer(valueType, human) {
let {
name,
stepAction,
answers,
themeColours
} = this.props,
value = answers[name],
ignored = this.step.state === 'ignored'
return (
<span onClick={() => stepAction(name, 'editing')}>
<h1>{this.props.title}</h1>
<StepAnswer {...{value, human, valueType, ignored, themeColours}} />
</span>)
}
renderHelpBox(helpText) {
let helpComponent =
typeof helpText === 'string' ?
(<p>{helpText}</p>) :
helpText
return <div className="help-box">
<a
className="close-help"
onClick={() => this.setState({helpVisible: false})}>
<span className="close-text">revenir <span className="icon">&#x2715;</span></span>
</a>
{helpComponent}
</div>
}
}

View File

@ -0,0 +1,84 @@
import React, {Component} from 'react'
import GroupTitle from './GroupTitle'
import classnames from 'classnames'
import { connect } from 'react-redux'
import { change} from 'redux-form'
import {submitStep, editStep} from '../actions'
import ReactCSSTransitionGroup from 'react-addons-css-transition-group'
import IgnoreStepButton from './Forms/IgnoreStepButton'
import conversationData from '../conversation-steps'
import {formValueSelector} from 'redux-form'
import StepAnswer from './Forms/StepAnswer'
/* Groups can be used only to avoid repeating conditions for all its children,
or to gather a set of questions that will be eventually collapsed to a final @value,
marked with the 'explicit' class */
@connect(state => ({
steps: state.steps,
formValue: field => formValueSelector('advancedQuestions')(state, field),
themeColours: state.themeColours
}), dispatch => ({
editStep: name => dispatch(editStep(name)),
submitStep: (name, ignored) => dispatch(submitStep(name, ignored)),
setFormValue: (field, value) => dispatch(change('advancedQuestions', field, value)),
}))
export default class Group extends Component {
render() {
let {visible, steps, foldTrigger, children, text, themeColours: {colour}} = this.props,
folded = foldTrigger ? steps.get(foldTrigger) && steps.get(foldTrigger) != 'editing' : false
if (!visible) return null
return (
<div className={classnames('form-group', {folded, unfolded: !folded, explicit: text})}>
{this.renderHeader(folded)}
<div className="group-content" style={!folded && text ? {borderLeft: '1px dashed' + colour} : {}}>
<ReactCSSTransitionGroup
transitionName="group-animated"
transitionEnterTimeout={300}
transitionLeaveTimeout={200} >
{!folded && <ul className="group-items">
{children.map(child =>
<li key={child.props.name}>
{child}
</li>)}
</ul>}
</ReactCSSTransitionGroup>
</div>
</div>
)
}
renderHeader(folded) {
let {
steps, foldTrigger, editStep, setFormValue, submitStep,
text, valueType, formValue, themeColours
} = this.props
if (!text) return null
let
headerClick = () => editStep(foldTrigger),
{defaultValue, human} = conversationData[foldTrigger],
ignoreGroup = () => {
setFormValue(foldTrigger, defaultValue)
submitStep(foldTrigger, true)
},
value = formValue(foldTrigger),
ignored = steps.get(name) === 'ignored'
return (
<div className="header">
<GroupTitle themeColours={themeColours} {...{text, folded}} onClick={headerClick} />
{ !folded && <IgnoreStepButton action={ignoreGroup} /> }
{ folded &&
<StepAnswer {...{value, human, valueType, ignored, themeColours}} />
}
</div>
)
}
}

View File

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

View File

@ -0,0 +1,22 @@
import React, { Component } from 'react'
export default class IgnoreStepButton extends Component {
componentDidMount() {
// removeEventListener will need the exact same function instance
this.boundHandleKeyDown = this.handleKeyDown.bind(this)
window.addEventListener('keydown', this.boundHandleKeyDown)
}
handleKeyDown({key}) {
if (key !== 'Escape') return
this.props.action()
}
componentWillUnmount() {
window.removeEventListener('keydown', this.boundHandleKeyDown)
}
render() {
return <a className="ignore" onClick={this.props.action}>
passer
</a>
}
}

View File

@ -0,0 +1,53 @@
import React, {Component} from 'react'
import {FormDecorator} from './FormDecorator'
import classnames from 'classnames'
@FormDecorator('input')
export default class Input extends Component {
render() {
let {
name,
input,
stepProps: {attributes, submit, valueType},
meta: {
touched, error, active,
},
themeColours
} = this.props,
answerSuffix = valueType.suffix,
suffixed = answerSuffix != null,
inputError = touched && error,
sendButtonDisabled = !input.value || inputError
return (
<span>
<span className="answer">
<input
type="text" {...input}
className={classnames({suffixed})}
id={'step-' + name}
{...attributes}
style={{borderColor: themeColours.colour}}
onKeyDown={({key}) =>
key == 'Enter' && input.value && (
!error ?
submit() :
input.onBlur() // blur will trigger the error
)}
/>
{ suffixed &&
<label className="suffix" htmlFor={'step-' + name} style={!active ? {color: '#aaa'} : {}}>
{answerSuffix}
</label>
}
<button className="send" style={{visibility: sendButtonDisabled ? 'hidden' : 'visible', color: themeColours.textColour, background: themeColours.colour}}
onClick={() => !error ? submit() : null} >
<span className="text">valider</span>
<span className="icon">&#10003;</span>
</button>
</span>
{inputError && <span className="step-input-error">{error}</span>}
</span>
)
}
}

View File

@ -0,0 +1,49 @@
import React, {Component} from 'react'
import {FormDecorator} from './FormDecorator'
import {answer, answered} from './userAnswerButtonStyle'
import HoverDecorator from '../HoverDecorator'
@HoverDecorator
class RadioLabel extends Component {
render() {
let {choice, input, submit, hover, themeColours} = this.props,
labelStyle =
Object.assign(
(choice === input.value || hover) ? answered(themeColours) : answer(themeColours),
)
return (
<label
style={labelStyle}
className="radio" >
<input
type="radio" {...input} onClick={submit}
value={choice} checked={choice === input.value ? 'checked' : ''} />
{choice}
</label>
)
}
}
/* Ceci est une saisie de type "radio" : l'utilisateur choisit une réponse dans une liste.
FormDecorator permet de factoriser du code partagé par les différents types de saisie,
dont Question est un example */
@FormDecorator('question')
export default class Question extends Component {
render() {
let {
input,
stepProps: {submit, choices},
themeColours
} = this.props
return (
<span>
{ choices.map((choice) =>
<RadioLabel key={choice} {...{choice, input, submit, themeColours}}/>
)}
</span>
)
}
}

View File

@ -0,0 +1,30 @@
import React, {Component} from 'react'
import {FormDecorator} from './FormDecorator'
import {answer} from './userAnswerButtonStyle'
@FormDecorator('rhetorical-question')
export default class RhetoricalQuestion extends Component {
render() {
let {
input,
stepProps: {submit, possibleChoice},
themeColours
} = this.props
if (!possibleChoice) return null // No action possible, don't render an answer
let {text, value} = possibleChoice
return (
<span className="answer">
<label key={value} className="radio" style={answer(themeColours)}>
<input
type="radio" {...input} onClick={submit}
value={value} />
{text}
</label>
</span>
)
}
}

View File

@ -0,0 +1,20 @@
import React, { Component } from 'react'
import {answered} from './userAnswerButtonStyle'
export default class StepAnswer extends Component {
render() {
let {
value, human, valueType, ignored, themeColours
} = this.props,
// Show a beautiful answer to the user, rather than the technical form value
humanFunc = human || valueType && valueType.human || (v => v)
return (
<span key="1" className="resume" style={answered(themeColours)} >
{humanFunc(value)}
{ignored && <span className="answer-ignored">(défaut)</span>}
</span>
)
}
}

View File

@ -0,0 +1,44 @@
import React, {Component} from 'react'
import {FormDecorator} from './FormDecorator'
@FormDecorator('text-area')
export default class Input extends Component {
render() {
let {
name,
input,
stepProps: {submit, attributes},
meta: {
touched, error,
},
themeColours
} = this.props,
inputError = touched && error,
sendButtonDisabled = !input.value || inputError
return (
<span>
<span className="answer">
<textarea
{...attributes}
{...input}
id={'step-' + name}
onKeyDown={({key, ctrlKey}) =>
key == 'Enter' && ctrlKey && input.value && (
!error ?
submit() :
input.onBlur() // blur will trigger the error
)}
/>
<button className="send"
style={{visibility: sendButtonDisabled ? 'hidden' : 'visible', color: themeColours.textColour, background: themeColours.colour}}
onClick={() => !error ? submit() : null} >
<span className="text">valider</span>
<span className="icon"></span>
</button>
</span>
{inputError && <span className="step-input-error">{error}</span>}
</span>
)
}
}

View File

@ -0,0 +1,208 @@
import React from 'react'
import { percentage, euro } from './formValueTypes.js'
import {simulationDate} from './openfisca.js'
export default {
// DEFAULTS : These inputs do not exist, but the API needs them
'defaults': {
adapt: () => ({
allegement_fillon_mode_recouvrement: 'anticipe_regularisation_fin_de_periode',
allegement_cotisation_allocations_familiales_mode_recouvrement: 'anticipe_regularisation_fin_de_periode',
contrat_de_travail_debut: simulationDate(),
}),
},
/*****************************
BASIC INPUT FORM FIELDS */
/* Le type d'entreprise association 190X n'est pas défini comme une catégorie dans OpenFisca,
mais comme un booléen */
'typeEntreprise': {
initial: 'entreprise',
adapt: raw => raw === 'entreprise_est_association_non_lucrative' && {
'entreprise_est_association_non_lucrative': true,
},
},
/* Nous simulons une embauche, donc nous incrémentons l'effectif */
'effectifEntreprise': {
initial: 0,
adapt: raw => ({'effectif_entreprise': +raw + 1}),
},
/* Nous voulons un ratio : on multiplie donc le nombre d'heures par semaine capté par
(la durée légale mensuelle divisée par la durée légale hebdomadaire) */
'heuresParSemaine': {
initial: 30,
adapt: raw => ({ 'heures_remunerees_volume': raw * (151.66 / 35)}),
},
'typeSalaireEntré': {
initial: 'brut',
adapt: () => ({}),
},
'salaire': {
initial: 2300,
adapt: (raw, value, values) => ({
// Use other values to determine the name of this key
[values['typeSalaireEntré'] == 'brut' ?
'salaire_de_base' :
'salaire_net_a_payer'
]: value }),
},
'tempsDeTravail': {
initial: 'temps_plein',
adapt: raw => ({'contrat_de_travail': raw}),
},
'categorieSalarié': {
initial: 'prive_non_cadre',
adapt: raw => ({'categorie_salarie': raw}),
},
/****************************
ADVANCED VIEW STEPS
One step is a Question, and an Answer field for the user.
The steps, called in Conversation.js, use these data.
The value stored in the state is the raw user input.
The value is validated (with a "pre" normalisation step) to be able to submit the step.
It is then 'adapted' into an object fragment. Object fragments are merged to be sent to the API.
*/
'mutuelle': { // the name of the form field. Data is stored in state.form.advancedQuestions.mutuelle
// The attributes of the HTML form field
attributes: {
/* We use 'text' inputs : browser behaviour with input=number
doesn't quite work with our "update simulation on input change"... */
inputMode: 'numeric',
placeholder: 'votre réponse', // help for the first input
},
valueType: euro, /* Will give the input a suffix (), a human representation
that will be used in the form resume, and a validation function */
defaultValue: '40', // The user can pass steps in the advanced view, this value is set
helpText: // What will be displayed in the help box
<p>
L'employeur a l'obligation en 2016 de proposer et financer à 50% une offre
de complémentaire santé. Son montant est libre, tant qu'elle couvre un panier légal de soins.
<br/>
<a href="https://www.service-public.fr/professionnels-entreprises/vosdroits/F33754" target="_blank">
Voir les détails (service-public.fr)
</a>
</p>,
adapt: (raw, validated) => ({'complementaire_sante_montant': validated}),
},
'alsaceMoselle': {
choices: [ 'Oui', 'Non' ],
defaultValue: 'Non',
helpText:
<p>
Cette affiliation est obligatoire si l'activité est exercée dans les départements du Bas-Rhin, du Haut-Rhin et de la Moselle. Elle l'est aussi dans certains autres cas, expliqués sur <a href="http://regime-local.fr/salaries/" target="_blank">cette page.</a>
<br/>
</p>,
adapt: raw => ({salarie_regime_alsace_moselle: raw === 'Oui' ? 1 : 0}),
},
'codeINSEE': {
defaultValue: {codeInsee: '29019', nomCommune: 'Ville de 100 000 habitants'},
human: v => v.nomCommune,
helpText: <p>Quelle est la commune du lieu de travail effectif du salarié ?</p>,
adapt: (selectObject) => ({'depcom_entreprise': selectObject && selectObject.codeInsee || ''}),
},
'pourcentage_alternants': {
attributes: {
inputMode: 'numeric',
},
valueType: percentage,
defaultValue: '0',
helpText: <p>Ce pourcentage de l'ensemble de vos salariés nous permet de calculer le montant de la Contribution Supplémentaire à l'Apprentissage, destinée à encourager cette forme d'emploi.</p>,
adapt: (raw, validated) => ({'ratio_alternants': validated / 100}),
},
'tauxRisqueConnu': {
choices: [ 'Oui', 'Non' ],
helpText:
<p>
C'est le taux de la cotisation accidents du travail (AT) et maladies professionnelles (MP). Il est accessible sur&nbsp;<a href="http://www.net-entreprises.fr/html/compte-accident-travail.htm" target="_blank">net-entreprises.fr</a> ou reçu par courrier.
</p>,
},
'tauxRisque': {
attributes: {
inputMode: 'numeric',
placeholder: 'Par ex. 1,1',
},
valueType: percentage,
defaultValue: '1',
adapt: validated => ({taux_accident_travail: validated / 100}),
},
'selectTauxRisque': {
fields: [ 'resume' ],
human: v => v.text,
optionsURL: 'https://cdn.rawgit.com/sgmap/taux-collectifs-cotisation-atmp/master/taux-2016.json',
},
'penibilite': {
choices: [ 'Plusieurs facteurs', 'Un facteur', 'Non'],
defaultValue: 'Non',
helpText: <p>
Les employeurs qui exposent un salarié à un facteur de pénibilité au-delà des seuils prévus est redevable d'une cotisation de pénibilité additionnelle. Elle est doublée si les facteurs sont multiples.
<br/>
<a href="https://www.service-public.fr/professionnels-entreprises/vosdroits/F33777" target="_blank">
Comprendre la cotisation pénibilité (service-public.fr)
</a>
</p>,
adapt: raw => ({
exposition_penibilite: {
'Non': 0, 'Un facteur': 1, 'Plusieurs facteurs': 2
}[raw]
}),
},
'jei': {
choices: [ 'Oui', 'Non' ],
defaultValue: 'Non',
helpText: <p>
Votre entreprise doit être éligible à ce statut, et votre employé doit notamment être fortement impliqué dans le projet de R&D.
<br/>
<a href="https://www.service-public.fr/professionnels-entreprises/vosdroits/F31188" target="_blank">
Voir toutes les conditions (service-public.fr)
</a>
</p>,
adapt: raw => ({jeune_entreprise_innovante: raw === 'Oui' ? 1 : 0}),
},
'serviceUtile': {
choices: [ ':-|', ':-)' ],
defaultValue: null,
helpText: <p>
Dites-nous si ce simulateur vous a été utile
</p>
},
'partage': {},
'remarque': {
attributes: {
cols: 30,
rows: 6,
placeholder: 'Votre remarque, accompagnée de votre email si vous voulez un retour.',
},
validator: {
test: v => v !== '',
error: 'Entrez votre remarque',
},
human: v => v.substring(0,20) + '...',
},
}

View File

@ -0,0 +1,14 @@
import steps from './conversation-steps'
export default values =>
values == null ? {} :
Object.keys(values).reduce((final, next) => {
let value = values[next],
{valueType = {}, validator} = steps[next],
{pre = (v => v), test, error} = Object.assign({}, validator, valueType.validator)
if (!test) return final
let valid = test(pre(value))
return Object.assign(final, valid ? null : {[next]: error})
}, {})

View File

@ -0,0 +1,358 @@
.step {
position: relative;
margin-bottom: 4.5em;
}
.form-group {
position: relative;
margin-bottom: 4.5em;
}
.form-group.explicit > .group-content {
margin-left: 1em;
padding: .8em 0 0 1.3em;
}
.form-group.folded .group-content {
border: none;
}
.form-group .header {
margin: 4em 0 .6em 0;
}
.group-items {
list-style-type: none;
margin: 0;
padding: 0;
}
/* Group items animation */
.group-animated-enter {
opacity: 0;
}
.group-animated-enter.group-animated-enter-active {
opacity: 1;
transition: opacity 300ms ease-in;
}
.group-animated-leave {
opacity: 1;
transform: scaleY(1);
}
.group-animated-leave.group-animated-leave-active {
opacity: 0;
transform: scaleY(0);
transform-origin: top;
transition: opacity 200ms ease, transform 200ms ease-out;
}
/* END Group items animation */
/* Group ignore buttons */
.form-group .header .ignore {
margin-left: 3em;
}
.group-title {
position: relative;
display: inline-block;
}
.group-title h1 {
font-size: 100%;
font-weight: 400;
padding: .5em .8em;
margin: 0;
border-radius: 1em;
position: relative;
width: 100%;
border: 1px solid rgba(0,0,0,.3);
}
.step h1 {
display: inline-block;
max-width: 55%;
font-size: 100%;
font-weight: 400;
padding: .5em 1em;
margin: 0;
border-radius: 1.4em;
position: relative;
cursor: pointer;
border: 1px solid #aaa;
}
/* The unfolded step is the currently focused
question : make it visible with colors */
.step.unfolded h1 {
cursor: default;
}
/* Our little help icon */
.help-button {
display: inline-block;
float: right;
margin-top: .5em;
line-height: 1.1em;
border-radius: 1em;
font-size: 90%;
color: #777;
border: 1px solid;
background: none;
text-align: center;
cursor: pointer;
color: #aaa;
text-transform: uppercase;
font-size: 60%;
padding: .25em .6em;
}
.help-button:hover {
color: #333;
border: 1px solid #333;
}
.step fieldset {
margin: .8em 0;
}
.step label.radio,
/* A resume of what's been answered */
.resume {
text-align: center;
margin-left: 1em;
margin-top: 2em;
cursor: pointer;
background: none;
padding: 0 .8em;
line-height: 1.8em;
border-radius: 1em;
float: right;
}
.resume {
transition: 1s display;
}
.answer-ignored {
font-size: 80%;
opacity: .8;
margin-left: .4em;
vertical-align: middle;
}
.ignore, .ignore:visited {
cursor: pointer;
color: #888;
font-weight: 200;
text-decoration: none;
border-bottom: 1px solid #ccc;
font-size: 80%;
line-height: 1.2em;
font-style: italic;
}
/* step ignore buttons */
fieldset > .ignore {
position: absolute;
bottom: 0em;
}
.step input[type=radio] {
display : none;
}
.step input[type=text] {
border-bottom: 1px solid;
display: inline-block;
line-height: 1.6em;
height: 1.6em; /* IE 11 needs this */
font-size: 100%;
padding: 0;
padding-right: .4em;
width: 8em;
text-align: right;
padding-left: .2em;
}
/* Remove IE's clear button the appears before our suffix */
.step input::-ms-clear {
width : 0;
height: 0;
}
.step input::placeholder {
font-style: italic;
}
.step input.suffixed {
padding-right: 1.5em;
}
.step label.suffix {
position: relative;
left: -1.5em;
font-size: 85%;
vertical-align: middle;
transition: color .1s;
}
.step input[type=text]:focus,
.step input[type=number]:focus {
border-color: #D0D4D8;
outline: none;
}
.help-box {
position: absolute;
width: 100%;
height: 100%;
z-index: 1;
text-align: center;
}
.help-box p {
padding: 1em;
font-size: 90%;
font-style: italic;
}
.close-help {
cursor: pointer;
position: absolute;
top: 0;
right: 0;
font-size: 105%;
}
.close-text {
text-transform: uppercase;
font-size: 60%;
}
.close-text .icon {
font-size: 150%;
vertical-align: middle;
}
.step .send {
padding: 0 .1em 0 .5em;
background: none;
cursor: pointer;
border: 1px solid;
border-radius: .2em;
line-height: 0em;
}
.step .send .icon {
margin-left: .3em;
font-size: 135%;
vertical-align: middle;
}
.step .send .text {
text-transform: uppercase;
font-size: 90%;
line-height: 2em;
}
.answer {
float: right;
}
.step-input-error {
position: absolute;
right: 0;
bottom: -1.5em;
font-size: .8em;
font-style: italic;
}
.step textarea {
vertical-align: middle;
margin-right: 1em;
}
#share-link {
color: white;
padding: .3em .3em;
display: inline-block;
margin-top: .3em;
border-radius: .25em;
}
#share-icon {
font-size: 200%;
vertical-align: middle;
line-height: 0em;
margin-left: .3em;
}
.select-answer.commune .Select {
width: 50%;
}
.info-zone {
font-size: 65%;
text-align: center;
font-style: italic;
color: #666;
line-height: 1.6em;
}
.input-tip {
height: 2em;
}
.input-tip p {
margin: .1em;
}
#show-advanced {
font-weight: bold;
}
/* Positioning the animated elements absolutely + transition-delay will make it possible
for the appearing element to appear without stacking up below the first one */
#user-next-action {
position: relative;
height: 2em;
margin-bottom: 3em;
}
.input-tip {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
margin: auto;
}
#reinit {
position: static;
display: block;
text-align: center;
font-size: 65%;
}
.user-next-action-animation-enter {
opacity: 0;
}
.user-next-action-animation-enter.user-next-action-animation-enter-active {
opacity: 1;
transition: opacity 700ms;
transition-delay: 700ms;
}
.user-next-action-animation-leave {
opacity: 1;
}
.user-next-action-animation-leave.user-next-action-animation-leave-active {
opacity: 0;
transition: opacity 700ms ease;
}

View File

@ -0,0 +1,17 @@
import { number } from './validators'
/*
Here are common formats that can be attached to Form components
*/
export let percentage = {
suffix: '%',
human: value => value + ' ' + '%',
validator: number
}
export let euro = {
suffix: '€',
human: value => value + ' ' + '€',
validator: number
}

View File

@ -0,0 +1,45 @@
.step .select-answer {
margin-top: 1em;
}
.select-answer .Select {
width: 90%;
display: inline-block;
float: right;
}
/* TODO : should probably use tables here to center
spans vertically */
.select-option {
}
.select-option span {
display: inline-block;
}
.option-title-container {
width: 65%;
font-size: 85%;
}
.option-secondary-container {
width: 10%;
min-width: 3em;
}
.option-secondary-container span {
color: #333;
}
.option-label-container {
width: 20%;
font-size: 85%;
background-color: #ddd;
color: #333;
border-radius: .25em;
padding: .5em;
text-align: center;
}

View File

@ -0,0 +1,54 @@
import React, { Component } from 'react'
import {FormDecorator} from './FormDecorator'
import ReactSelect from 'react-select'
import 'react-select/dist/react-select.css'
import './Select.css'
let getOptions = input =>
input.length !== 5 ?
Promise.resolve({}) :
fetch(`https://apicarto.sgmap.fr/codes-postaux/communes/${input}`)
.then(response => {
if (!response.ok)
return [ {nomCommune: 'Aucune commune trouvée', disabled: true} ]
return response.json()
})
.then(json => ({options: json}))
.catch(function(error) {
console.log('Erreur dans la recherche de communes à partir du code postal', error) // eslint-disable-line no-console
return {options: []}
})
@FormDecorator('select')
export default class SelectCommune extends Component {
render() {
let {
input: {
onChange,
},
stepProps: {submit}
} = this.props,
submitOnChange =
option => {
onChange(option)
submit()
}
return (
<div className="select-answer commune">
<ReactSelect.Async
onChange={submitOnChange}
labelKey="codePostal"
optionRenderer={({nomCommune}) => nomCommune}
valueKey="codeInsee"
placeholder="Entrez votre code postal"
noResultsText="Nous n'avons trouvé aucune commune"
searchPromptText={null}
loadingPlaceholder="Recherche en cours..."
loadOptions={getOptions}
/>
</div>
)
}
}

View File

@ -0,0 +1,15 @@
import React from 'react'
export default (option) => (
<div className="select-option">
<span className="option-title-container">
{option['Nature du risque']}
</span>
<span className="option-secondary-container">
<span>{option['Taux net'] + ' %'}</span>
</span>
<span className="option-label-container">
{option['Catégorie']}
</span>
</div>
)

View File

@ -0,0 +1,79 @@
import React, { Component } from 'react'
import {FormDecorator} from './FormDecorator'
import ReactSelect from 'react-select'
import SelectOption from './SelectOption.js'
import 'react-select/dist/react-select.css'
import './Select.css'
class ReactSelectWrapper extends Component {
render() {
let {
value, onBlur, onChange, submit,
options,
submitOnChange =
option => {
option.text = option['Taux net'] + ' %'
onChange(option)
submit()
},
selectValue = value && value['Code risque'],
// but ReactSelect obviously needs a unique identifier
} = this.props
if (!options) return null
return (
// For redux-form integration, checkout https://github.com/erikras/redux-form/issues/82#issuecomment-143164199
<ReactSelect
options={options}
onChange={submitOnChange}
labelKey="Nature du risque"
valueKey="Code risque"
placeholder="Tapez des mots ou déroulez la liste complète"
optionRenderer={SelectOption}
valueRenderer={(value) => value['Nature du risque'].substring(0, 50) + '...'}
clearable={false}
value={selectValue}
onBlur={() => onBlur(value)}
/>
)
}
}
@FormDecorator('select')
export default class Select extends Component {
state = {
options: null,
}
render() {
let {
input,
stepProps: {submit},
} = this.props
return (
<div className="select-answer">
<ReactSelectWrapper {...input} options={this.state.options} submit={submit} />
</div>
)
}
componentDidMount() {
fetch(this.props.stepProps.optionsURL)
.then(response => {
if (!response.ok) {
let error = new Error(response.statusText)
error.response = response
throw error
}
return response.json()
})
.then(json => this.setState({options: json}))
.catch(error =>
console.log('Erreur dans la récupération des codes risques', error) // eslint-disable-line no-console
)
}
}

View File

@ -0,0 +1,11 @@
export let answered = ({colour, textColour}) => ({
background: colour,
border: '1px solid ' + colour,
color: textColour,
})
export let answer = ({textColourOnWhite}) => ({
color: textColourOnWhite,
border: '1px solid ' + textColourOnWhite,
})

View File

@ -0,0 +1,9 @@
// Regexps used to validate processed user inputs
export let number = {
pre: v =>
v.replace(/,/g, '.') // commas -> dots
.replace(/\s/g, ''), // remove spaces
test: v => /^[0-9]+(\.[0-9]+)?$/.test(v),
error: 'Vous devez entrer un nombre',
}

View File

@ -0,0 +1,32 @@
/* Given a backgorund color, should you write on it in black or white ?
Taken from http://stackoverflow.com/questions/3942878/how-to-decide-font-color-in-white-or-black-depending-on-background-color#comment61936401_3943023
*/
export default function(color, simple) {
let r = hexToR(color),
g = hexToG(color),
b = hexToB(color)
if (simple) { // The YIQ formula
return ((r * 0.299 + g * 0.587 + b * 0.114) > 128) ?
'#000000' : '#ffffff'
} // else complex formula
let
uicolors = [ r / 255, g / 255, b / 255 ],
c = uicolors.map(c =>
c <= 0.03928 ?
c / 12.92 :
Math.pow((c + 0.055) / 1.055, 2.4)
),
L = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
return (L > 0.179) ? '#000000' : '#ffffff'
}
/* Hex to RGB conversion:
* http://www.javascripter.net/faq/hextorgb.htm
*/
let cutHex = h => (h.charAt(0) == '#') ? h.substring(1, 7) : h,
hexToR = h => parseInt((cutHex(h)).substring(0, 2),16),
hexToG = h => parseInt((cutHex(h)).substring(2, 4),16),
hexToB = h => parseInt((cutHex(h)).substring(4, 6),16)

View File

@ -0,0 +1,27 @@
import findContrastedTextColour from './findContrastedTextColour'
export default forcedThemeColour => {
let
scriptColour = () => {
let script = document.currentScript || [ ...document.getElementsByTagName('script') ].pop()
return script && script.getAttribute('couleur')
},
// Use the default theme colour if the host page hasn't made a choice
defaultColour = '#4A89DC',
colour = forcedThemeColour || scriptColour() || defaultColour,
textColour = findContrastedTextColour(colour, true), // the 'simple' version feels better...
inverseTextColour = textColour === '#ffffff' ? '#000' : '#fff',
lightenTextColour = textColour => textColour === '#ffffff' ? 'rgba(255, 255, 255, .85)' : '#333',
lighterTextColour = lightenTextColour(textColour),
lighterInverseTextColour = lightenTextColour(inverseTextColour),
textColourOnWhite = textColour === '#ffffff' ? colour : '#333'
return {
colour,
textColour,
inverseTextColour,
lighterTextColour,
lighterInverseTextColour,
textColourOnWhite
}
}

View File

@ -1,464 +0,0 @@
- 4C2F27
- 0048BA
- B0BF1A
- 7CB9E8
- B284BE
- 5D8AA8
- 00308F
- 72A0C1
- AF002A
- 84DE02
- E32636
- C46210
- E52B50
- 9F2B68
- F19CBB
- AB274F
- D3212D
- 3B7A57
- 00C4B0
- FFBF00
- FF7E00
- FF033E
- 9966CC
- A4C639
- F2F3F4
- CD9575
- 665D1E
- 915C83
- 841B2D
- FAEBD7
- 008000
- 8DB600
- FBCEB1
- 00FFFF
- 7FFFD4
- D0FF14
- 4B5320
- 3B444B
- 8F9779
- E9D66B
- B2BEB5
- 87A96B
- FF9966
- A52A2A
- FDEE00
- 6E7F80
- 568203
- FF2052
- C39953
- 007FFF
- F0FFFF
- F0FFFF
- DBE9F4
- 2E5894
- 89CFF0
- A1CAF1
- F4C2C2
- FEFEFA
- FF91AF
- 21ABCD
- FAE7B5
- FFE135
- 006A4E
- E0218A
- 7C0A02
- 1DACD6
- 848482
- 98777B
- BCD4E6
- 9F8170
- FA6E79
- F5F5DC
- 9C2542
- E88E5A
- FFE4C4
- 3D2B1F
- 967117
- CAE00D
- BFFF00
- FE6F5E
- BF4F51
- 000000
- 3D0C02
- 54626F
- 253529
- 3B3C36
- BFAFB2
- FFEBCD
- A57164
- 318CE7
- ACE5EE
- FAF0BE
- 0000FF
- 1F75FE
- 0093AF
- 0087BD
- 0018A8
- 333399
- 0247FE
- A2A2D0
- 00B9FB
- 5DADEC
- ACE5EE
- 126180
- 5072A7
- 6699CC
- 0D98BA
- 553592
- 8A2BE2
- 4F86F7
- 1C1CF0
- DE5D83
- 79443B
- 0095B6
- E3DAC9
- DDE26A
- CC0000
- 006A4E
- 873260
- 0070FF
- B5A642
- CB4154
- 1DACD6
- 66FF00
- BF94E4
- D891EF
- C32148
- 1974D2
- FF007F
- 08E8DE
- D19FE8
- FFAA1D
- 3399FF
- F4BBFF
- FF55A3
- FB607F
- 004225
- CD7F32
- 737000
- 964B00
- A52A2A
- AF6E4D
- cc9966
- 6B4423
- 1B4D3E
- FFC1CC
- E7FEFF
- 7BB661
- F0DC82
- 480607
- 800020
- DEB887
- A17A74
- CC5500
- E97451
- 8A3324
- BD33A4
- 702963
- 536872
- 5F9EA0
- 91A3B0
- 006B3C
- ED872D
- E30022
- FFF600
- A67B5B
- 4B3621
- 1E4D2B
- A3C1AD
- C19A6B
- EFBBCC
- 78866B
- FFFF99
- FFEF00
- FF0800
- E4717A
- 00BFFF
- 592720
- C41E3A
- 00CC99
- 960018
- D70040
- EB4C42
- FF0038
- FFA6C9
- B31B1B
- 56A0D3
- ED9121
- 00563F
- 062A78
- 703642
- C95A49
- 92A1CF
- ACE1AF
- 007BA7
- 2F847C
- B2FFFF
- 4997D0
- DE3163
- EC3B83
- 007BA7
- 2A52BE
- 6D9BC3
- 007AA5
- E03C31
- A0785A
- F7E7CE
- F1DDCF
- 36454F
- 232B2B
- E68FAC
- DFFF00
- 7FFF00
- DE3163
- FFB7C5
- 954535
- DE6FA1
- A8516E
- AA381E
- 856088
- 4AFF00
- 7B3F00
- D2691E
- FFA700
- 98817B
- E34234
- CD607E
- D2691E
- E4D00A
- 9FA91F
- 7F1734
- FBCCE7
- 0047AB
- D2691E
- 965A3E
- 6F4E37
- C4D8E2
- F88379
- 002E63
- 8C92AC
- B87333
- DA8A67
- AD6F69
- CB6D51
- 996666
- FF3800
- FF7F50
- F88379
- FF4040
- FD7C6E
- 893F45
- FBEC5D
- B31B1B
- 6495ED
- FFF8DC
- 2E2D88
- FFF8E7
- FFBCD9
- 81613C
- FFFDD0
- DC143C
- BE0032
- 990000
- F5F5F5
- 00FFFF
- 00B7EB
- 4E82B4
- 28589C
- 188BC2
- 4682BF
- 58427C
- FFD300
- F56FA1
- FFFF31
- F0E130
- 00008B
- 666699
- 654321
- 88654E
- 5D3954
- A40000
- 08457E
- 986960
- CD5B45
- 008B8B
- 536878
- B8860B
- A9A9A9
- 013220
- 006400
- 1F262A
- 00416A
- 00147E
- 1A2421
- BDB76B
- 483C32
- 734F96
- 534B4F
- 543D37
- 8B008B
- A9A9A9
- 003366
- 4A5D23
- 556B2F
- FF8C00
- 9932CC
- 779ECB
- 03C03C
- 966FD6
- C23B22
- E75480
- 003399
- 4F3A3C
- 301934
- 872657
- 8B0000
- E9967A
- 560319
- 8FBC8F
- 3C1414
- 8CBED6
- 483D8B
- 2F4F4F
- 177245
- 918151
- FFA812
- 483C32
- CC4E5C
- 00CED1
- D1BEA8
- 9400D3
- 9B870C
- 00703C
- 555555
- D70A53
- 40826D
- A9203E
- EF3038
- E9692C
- DA3287
- FAD6A5
- B94E48
- 704241
- C154C1
- 056608
- 0E7C61
- 004B49
- 333366
- F5C71A
- 9955BB
- CC00CC
- 820000
- D473D4
- 355E3B
- FFCBA4
- FF1493
- A95C68
- 850101
- 843F5B
- FF9933
- 00BFFF
- 4A646C
- 556B2F
- 7E5E60
- 66424D
- 330066
- BA8759
- 1560BD
- 2243B6
- 669999
- C19A6B
- EDC9AF
- EA3C53
- B9F2FF
- 696969
- C53151
- 9B7653
- 1E90FF
- D71868
- 85BB65
- 828E84
- 664C28
- 967117
- 00009C
- E5CCC9
- EFDFBB
- E1A95F
- 555D50
- C2B280
- 1B1B1B
- 614051
- F0EAD6
- 1034A6
- 7DF9FF
- FF003F
- 00FFFF
- 00FF00
- 6F00FF
- F4BBFF
- CCFF00
- BF00FF
- 3F00FF
- 8F00FF
- FFFF33
- 50C878
- 6C3082
- 1B4D3E
- B48395
- AB4B52
- CC474B
- 563C5C
- 96C8A2
- 44D7A8
- C19A6B
- 801818
- B53389
- DE5285
- F400A1
- E5AA70
- 4D5D53
- FDD5B1
- 4F7942
- FF2800
- 6C541E
- FF5470
- CE2029
- B22222
- E25822
- FC8EAC
- 6B4423
- F7E98E
- EEDC82
- A2006D
- FFFAF0
- FFBF00
- FF1493
- CCFF00
- FF004F
- 014421
- 228B22
- A67B5B
- 856D4D
- 0072BB
- FD3F92
- 86608E
- 9EFD38
- D473D4
- FD6C9E
- 811453
- 4E1609
- C72C48
- F64A8A
- 77B5FE
- 8806CE
- AC1E44
- A6E7FF
- E936A7
- FF00FF
- C154C1
- FF77FF
- CC397B
- C74375
- E48400
- CC6666

View File

@ -5,6 +5,7 @@ import DevTools from '../DevTools'
import routes from '../routes'
import {Router, browserHistory} from 'react-router'
export default class App extends Component {
render() {
const { store } = this.props

View File

@ -1,5 +1,6 @@
import React, { Component } from 'react'
import './Layout.css'
import './reset.css'
import {Link} from 'react-router'
export default class Layout extends Component {

View File

@ -0,0 +1,39 @@
/*
Unify browser styles and reset opinionated defaults;
Reset style decisions that would break (rather than just customize)
the widget's form layout.
*/
[hidden],
.js-only {
display: none;
}
/* Reset fieldset style */
fieldset {
border: 0;
padding: 0;
padding-top: .01em;
margin: 0;
min-width: 0;
}
/* Remove spinner controls from Firefox */
input[type="number"] {
appearance: textfield;
}
select {
width: auto;
height: auto;
}
input {
line-height: normal;
height: auto;
}
label {
font-size: 100%;
font-weight: normal;
}

View File

@ -2,11 +2,11 @@ import rules from './load-rules'
import possibleVariableTypes from './possibleVariableTypes.yaml'
export let findRuleByName = name =>
export let findRuleByName = search =>
rules
.map(extractRuleTypeAndName)
.find( ([, n]) =>
n === name
.find( ({name}) =>
name === search
)
export let searchRules = searchInput =>
@ -19,7 +19,7 @@ export let searchRules = searchInput =>
export let extractRuleTypeAndName = rule => {
let type = possibleVariableTypes.find(t => rule[t])
return [type, rule[type], rule]
return {type, name: rule[type], rule}
}
export let hasKnownRuleType = rule => rule && extractRuleTypeAndName(rule)[0]
export let hasKnownRuleType = rule => rule && extractRuleTypeAndName(rule).type

View File

@ -0,0 +1,97 @@
import { combineReducers } from 'redux'
import {reducer as formReducer} from 'redux-form'
import { SUBMIT_STEP, EDIT_STEP, UNSUBMIT_ALL} from './actions'
import {
SIMULATION_UPDATE_REQUEST, SIMULATION_UPDATE_SUCCESS,
TOGGLE_TOP_SECTION, TOGGLE_ADVANCED_SECTION,
} from './actions'
import computeThemeColours from './themeColours'
import {change} from 'redux-form'
function steps(state = new Map(), {type, name, ignored}) {
switch (type) {
case SUBMIT_STEP:
return new Map([ ...state ]).set(
name,
ignored ? 'ignored' : 'filled'
)
case EDIT_STEP:
return new Map([ ...state ]).set(
name,
'editing'
)
case UNSUBMIT_ALL:
return new Map()
default:
return state
}
}
function pending(state = false, action) {
switch (action.type) {
case SIMULATION_UPDATE_REQUEST:
return true
case SIMULATION_UPDATE_SUCCESS:
return false
default:
return state
}
}
function results(state = {}, {type, results}) {
switch (type) {
case SIMULATION_UPDATE_SUCCESS:
return results.values
default:
return state
}
}
function activeSections(state = {top: 'input', advanced: false}, {type}) {
switch (type) {
// What is the active top section, input or details ?
case TOGGLE_TOP_SECTION:
return Object.assign({}, state, {top: state.top === 'input' ? 'details' : 'input' })
// Is the advanced input active ?
case TOGGLE_ADVANCED_SECTION:
return Object.assign({}, state, {advanced: !state.advanced})
default:
return state
}
}
function inputChanged(state = false, {type}) {
switch(type) {
case change().type:
return true
default:
return state
}
}
function themeColours(state = computeThemeColours(), {type, colour}) {
if (type == 'CHANGE_THEME_COLOUR')
return computeThemeColours(colour)
else return state
}
export default combineReducers({
// this is handled by redux-form, pas touche !
form: formReducer,
/* Have forms been filled or ignored ?
false means the user is reconsidering its previous input */
steps,
// Is an (advanced simulation) request pending ?
pending,
results,
activeSections,
// Has the user edited one form field ?
inputChanged,
themeColours,
})

View File

@ -1,9 +1,110 @@
import React from 'react'
import { combineReducers } from 'redux'
import { } from './actions'
import reduceReducers from 'reduce-reducers'
import {reducer as formReducer, formValueSelector} from 'redux-form'
import {analyseSituation, variableType} from './traverse'
import { euro } from './components/conversation/formValueTypes.js'
import Question from './components/conversation/Question'
import Input from './components/conversation/Input'
import RhetoricalQuestion from './components/conversation/RhetoricalQuestion'
import { STEP_ACTION, UNSUBMIT_ALL, START_CONVERSATION} from './actions'
import R from 'ramda'
import computeThemeColours from './components/themeColours'
function steps(steps = [], {type}) {
switch (type) {
case UNSUBMIT_ALL:
return []
default:
return steps
}
}
function themeColours(state = computeThemeColours(), {type, colour}) {
if (type == 'CHANGE_THEME_COLOUR')
return computeThemeColours(colour)
else return state
}
export default reduceReducers(
combineReducers({
// this is handled by redux-form, pas touche !
form: formReducer,
/* Have forms been filled or ignored ?
false means the user is reconsidering its previous input */
steps,
analysedSituation: (state = []) => state,
themeColours
}),
// cross-cutting concerns because here `state` is the whole state tree
(state, action) => {
if (action.type == STEP_ACTION || action.type == START_CONVERSATION) {
let {newState, name} = action
// une étape vient d'être validée : on va changer son état
let newSteps = R.pipe(
R.map(step => step.name == name ? {...step, state: newState} : step),
R.reject(R.whereEq({theEnd: true}))
)(state.steps)
// on calcule la prochaine étape, à ajouter sur la pile
let analysedSituation = analyseSituation(name => formValueSelector('conversation')(state, name)),
[firstMissingVariable] = R.pipe(
R.map(({derived: [missingVariables]}) => missingVariables),
R.flatten
)(analysedSituation),
type = variableType(firstMissingVariable),
stepData = Object.assign({
name: firstMissingVariable,
state: null,
dependecyOfVariable: null, //TODO
title: firstMissingVariable,
question: firstMissingVariable,
visible: true,
helpText: <p>
The impossible is possible, at Zombo.com
<br/>
<a href="https://www.service-public.fr/professionnels-entreprises/vosdroits/F33777" target="_blank">
Comprendre comment tout ce bordel se ficelle
</a>
</p>
}, type == 'boolean' ? {
component: Question,
choices: ['non', 'oui'],
defaultValue: 'Non'
}: type == 'numeric' ? {
component: Input,
defaultValue: 0,
valueType: euro,
attributes: {
/* We use 'text' inputs : browser behaviour with input=number
doesn't quite work with our "update simulation on input change"... */
inputMode: 'numeric',
placeholder: 'votre réponse'
}
} : firstMissingVariable == undefined ? {
theEnd: true,
component: RhetoricalQuestion,
question: <span>
{'Merci. N\'hésitez pas à partager le simulateur !'}
</span>,
helpText: null
}: {})
return {...state, steps: [...newSteps, stepData], analysedSituation}
// ... do stuff
} else {
return state
}
export default combineReducers({
yo: (state = null) => state
})
}
)

View File

@ -5,7 +5,6 @@ import Promise from 'core-js/fn/promise'
// Nothing happening here !
function* handleSubmitStep() {
console.log('salut')
}
function* watchSteps() {

View File

@ -0,0 +1,3 @@
import { createSelector } from 'reselect'
import React from 'react'

View File

@ -1,7 +1,7 @@
import removeDiacritics from './utils/remove-diacritics'
import R from 'ramda'
import rules from './load-rules'
import initialSituation from './initialSituation'
// import initialSituation from './initialSituation'
import {findRuleByName, extractRuleTypeAndName} from './model'
/*
@ -53,126 +53,147 @@ let recognizeExpression = rawValue => {
match = expressionTests['negatedVariable'](value)
if (match) {
let [, variableName] = match
return [variableName, `!${variableName}`]
// return [variableName, `!${variableName}`]
return [variableName, situation => situation(variableName) == 'non']
}
match = expressionTests['variableComparedToNumber'](value)
if (match) {
let [, variableName, symbol, number] = match
return [variableName, `situation[${variableName}] ${symbol} ${number}`]
return [variableName, situation => eval(`situation("${variableName}") ${symbol} ${number}`)] //eslint-disable-line no-unused-vars
}
match = expressionTests['numberComparedToVariable'](value)
if (match) {
let [, number, symbol, variableName] = match
return [variableName, `${number} ${symbol} situation[${variableName}]`]
return [variableName, situation => eval(`${number} ${symbol} situation("${variableName}")`)] //eslint-disable-line no-unused-vars
}
match = expressionTests['variableEqualsNumber'](value)
if (match) {
let [, variableName, number] = match
return [variableName, `situation[${variableName}] == ${number}`]
return [variableName, situation => situation(variableName) == number]
}
match = expressionTests['variable'](value)
if (match) {
let [variableName] = match
return [variableName, `situation["${variableName}"]`]
return [variableName, situation => situation(variableName) == 'oui']
}
}
let knownVariable = (situation, variableName) => (typeof situation(variableName) !== 'undefined')
let deriveRule = situation => R.pipe(
R.toPairs,
// Reduce to [variables needed to compute that variable, computed variable value]
R.reduce(([variableNames, result], [key, value]) => {
if (key === 'concerne'){
let [variableName, evaluation] = recognizeExpression(value)
// Si cette variable a été renseignée
if (knownVariable(situation, variableName)){
// Si l'expression n'est pas vraie...
if (!evaluation(situation)){
// On court-circuite toute la variable, et on n'a besoin d'aucune information !
return R.reduced([[]])
} else {
// Sinon, on continue
return [variableNames]
}
// sinon on demande la valeur de cette variable
} else return [[...variableNames, variableName]]
}
if (key === 'non applicable si') {
let conditions = value['l\'une de ces conditions']
let [subVariableNames, reduced] = R.reduce(([variableNames], expression) => {
let [variableName, evaluation] = recognizeExpression(expression)
if (knownVariable(situation, variableName)){
if (evaluation(situation)) {
return R.reduced([[], true])
} else {
return [variableNames]
}
}
return [[...variableNames, variableName]]
}, [[], null])(conditions)
if (reduced) return R.reduced([[]])
else return [variableNames.concat(subVariableNames)]
}
if (key === 'formule'){
if (value['linéaire']){
let {assiette, taux} = value['linéaire']
// A propos de l'assiette
let assietteVariableName = removeDiacritics(assiette),
assietteValue = situation(assietteVariableName),
unknownAssiette = assietteValue == undefined
if (unknownAssiette){
return [[...variableNames, assietteVariableName]]
} else {
if (variableNames.length > 0) {
return [variableNames]
}
}
// Arrivés là, cette formule devrait être calculable !
// A propos du taux
if (typeof taux !== 'string' && typeof taux !== 'number'){
throw 'Oups, pas de taux compliqués s\'il-vous-plaît'
}
let tauxValue = taux.indexOf('%') > -1 ?
+taux.replace('%', '')/100 :
+taux
return R.reduced([null, assietteValue * tauxValue])
}
}
return [variableNames]
}, [[], null])
)
let analyseVariable = situation =>
R.pipe(
R.toPairs,
R.reduce(([variableNames, result], [key, value]) => {
if (key === 'concerne'){
let [variableName, evaluation] = recognizeExpression(value)
if (typeof situation[variableName] !== 'undefined'){
if (!eval(evaluation)){
return R.reduced([[]])
} else {
return [variableNames, null]
}
}
return [[...variableNames, variableName]]
}
if (key === 'non applicable si') {
let conditions = value['l\'une de ces conditions']
let subVariableNames = R.reduce((variableNames, expression) => {
let [variableName, evaluation] = recognizeExpression(expression)
// console.log('evaluation', variableName, evaluation)
if (typeof situation[variableName] !== 'undefined'){
if (eval(evaluation)){
return R.reduced([])
} else {
return variableNames
}
}
return [...variableNames, variableName]
}, [])(conditions)
console.log('subVariableNames', subVariableNames)
return [variableNames.concat(subVariableNames)]
}
if (key === 'formule'){
if (value['linéaire']){
let {assiette, taux} = value['linéaire']
// A propos de l'assiette
let assietteVariableName = removeDiacritics(assiette),
assietteValue = situation[assietteVariableName],
unknownAssiette = typeof assietteValue !== 'number'
if (unknownAssiette){
return [[...variableNames, assietteVariableName]]
} else {
if (variableNames.length > 0) {
return [variableNames]
}
}
// Arrivés là, cette formule devrait être calculable !
// A propos du taux
if (typeof taux !== 'string' && typeof taux !== 'number'){
throw 'Oups, pas de taux compliqués s\'il-vous-plaît'
}
let tauxValue = taux.indexOf('%') > -1 ?
+taux.replace('%', '')/100 :
+taux
return R.reduced([null, assietteValue * tauxValue])
}
}
return [variableNames]
}, [[], null])
extractRuleTypeAndName, // -> {type, name, rule}
data => R.assoc(
'derived',
deriveRule(situation)(data.rule)
)(data)
)
let selectedRules = rules.filter(r => extractRuleTypeAndName(r)[1] == 'CIF CDD')
// L'objectif de la simulation : quelles règles voulons nous calculer ?
let selectedRules = rules.filter(r => extractRuleTypeAndName(r).name == 'CIF CDD')
export let analyseSituation = (situation = initialSituation) =>
export let analyseSituation = situation =>
R.pipe(
R.map(analyseVariable(situation)),
R.flatten()
R.map(analyseVariable(situation))
)(selectedRules)
console.log(rules)
export let variableType = name => {
let rule = findRuleByName(name)
console.log('Getting variable type for ', name)
if (name == null) return null
let found = findRuleByName(name)
// tellement peu de variables pour l'instant
// que c'est très simpliste
if (!rule) return 'boolean'
if (rule.formule['somme']) return 'numeric'
if (!found) return 'boolean'
let {rule, type} = found
if (typeof rule.formule['somme'] !== 'undefined') return 'numeric'
}
// console.log('RES', JSON.stringify(res))
// let types = {