feat(ui+backend): base of data persistence

This commit is contained in:
2020-09-11 09:19:18 +02:00
parent c7cea6e46b
commit 7fc1a7f3af
37 changed files with 1298 additions and 195 deletions

View File

@ -3,7 +3,7 @@ import { Link } from "react-router-dom";
import { WithLoader } from "../WithLoader";
export interface Item {
id: string
id: string|number
[propName: string]: any;
}
@ -21,7 +21,7 @@ export interface ItemPanelProps {
isLoading?: boolean
items: Item[]
tabs?: TabDefinition[],
itemKey: (item: Item, index: number) => string
itemKey: (item: Item, index: number) => string|number
itemLabel: (item: Item, index: number) => string
itemUrl: (item: Item, index: number) => string
}

View File

@ -1,5 +1,6 @@
import React, { FunctionComponent } from "react";
import { ItemPanel } from "./ItemPanel";
import { useProjects } from "../../gql/queries/project";
export interface ProjectModelPanelProps {
@ -13,7 +14,7 @@ export const ProjectModelPanel: FunctionComponent<ProjectModelPanelProps> = () =
newItemUrl="/models/new"
items={[]}
itemKey={(item) => { return item.id }}
itemLabel={(item) => { return item.id }}
itemLabel={(item) => { return `${item.id}` }}
itemUrl={(item) => { return `/models/${item.id}` }}
/>
);

View File

@ -1,19 +1,25 @@
import React, { FunctionComponent } from "react";
import { ItemPanel } from "./ItemPanel";
import { useProjects } from "../../gql/queries/project";
export interface ProjectPanelProps {
}
export const ProjectPanel: FunctionComponent<ProjectPanelProps> = () => {
const { projects } = useProjects({
fetchPolicy: 'cache-and-network',
});
return (
<ItemPanel
title="Mes projets"
className="is-primary"
newItemUrl="/projects/new"
items={[]}
items={projects}
itemIconClassName="fa fa-file"
itemKey={(item) => { return item.id }}
itemLabel={(item) => { return item.id }}
itemLabel={(item) => { return `#${item.id} - ${item.title ? item.title : 'Projet sans nom'}` }}
itemUrl={(item) => { return `/projects/${item.id}` }}
/>
);

View File

@ -2,7 +2,7 @@ import React, { FunctionComponent, Fragment } from "react";
import { Project } from "../../types/project";
import TaskTable from "./TasksTable";
import { TimePreview } from "./TimePreview";
import FinancialPreview from "./FinancielPreview";
import FinancialPreview from "./FinancialPreview";
import { addTask, updateTaskEstimation, removeTask, updateTaskLabel, ProjectReducerActions } from "../../hooks/useProjectReducer";
import { Task, TaskID, EstimationConfidence } from "../../types/task";
import RepartitionPreview from "./RepartitionPreview";
@ -18,16 +18,16 @@ const EstimationTab: FunctionComponent<EstimationTabProps> = ({ project, dispatc
dispatch(addTask(task));
};
const onTaskRemove = (taskId: TaskID) => {
dispatch(removeTask(taskId));
const onTaskRemove = (id: number) => {
dispatch(removeTask(id));
}
const onTaskLabelUpdate = (taskId: TaskID, label: string) => {
dispatch(updateTaskLabel(taskId, label));
const onTaskLabelUpdate = (id: number, label: string) => {
dispatch(updateTaskLabel(id, label));
}
const onEstimationChange = (taskId: TaskID, confidence: EstimationConfidence, value: number) => {
dispatch(updateTaskEstimation(taskId, confidence, value));
const onEstimationChange = (id: number, confidence: EstimationConfidence, value: number) => {
dispatch(updateTaskEstimation(id, confidence, value));
};
return (

View File

@ -64,7 +64,7 @@ export const CostDetails:FunctionComponent<CostDetailsProps> = ({ project, cost,
<tbody>
{
Object.keys(cost.details).map(taskCategoryId => {
const taskCategory = project.params.taskCategories[taskCategoryId];
const taskCategory = project.taskCategories[parseInt(taskCategoryId)];
const details = cost.details[taskCategoryId];
return (
<tr key={`task-category-cost-${taskCategory.id}`}>

View File

@ -1,14 +1,15 @@
import React, { FunctionComponent, useEffect } from "react";
import style from "./style.module.css";
import { newProject, Project } from "../../types/project";
import { useProjectReducer, updateProjectLabel } from "../../hooks/useProjectReducer";
import { useProjectReducer, updateProjectTitle, resetProject } from "../../hooks/useProjectReducer";
import EditableText from "../EditableText/EditableText";
import Tabs from "../../components/Tabs/Tabs";
import EstimationTab from "./EstimationTab";
import ParamsTab from "./ParamsTab";
import ExportTab from "./ExportTab";
import { useParams } from "react-router";
import { useParams, useHistory } from "react-router";
import { Page } from "../Page";
import { useProjects } from "../../gql/queries/project";
export interface ProjectProps {
projectId: string
@ -16,10 +17,24 @@ export interface ProjectProps {
export const ProjectPage: FunctionComponent<ProjectProps> = () => {
const { id } = useParams();
const [ project, dispatch ] = useProjectReducer(newProject());
const onProjectLabelChange = (projectLabel: string) => {
dispatch(updateProjectLabel(projectLabel));
const isNew = id === 'new';
const projectId = isNew ? undefined : parseInt(id);
const { projects } = useProjects({ variables: { filter: {ids: projectId !== undefined ? [projectId] : undefined} }});
const [ project, dispatch ] = useProjectReducer(newProject(projectId));
const history = useHistory();
useEffect(() => {
history.push(`/projects/${project.id}`);
}, [project.id])
useEffect(() => {
if (isNew || projects.length === 0) return;
dispatch(resetProject(projects[0]));
}, [projects.length]);
const onProjectTitleChange = (projectTitle: string) => {
if (project.title === projectTitle) return;
dispatch(updateProjectTitle(projectTitle));
};
return (
@ -30,8 +45,8 @@ export const ProjectPage: FunctionComponent<ProjectProps> = () => {
<EditableText
editIconClass="is-size-4"
render={(value) => (<h2 className="is-size-3">{value}</h2>)}
onChange={onProjectLabelChange}
value={project.label ? project.label : "Projet sans nom"}
onChange={onProjectTitleChange}
value={project.title ? project.title : "Projet sans nom"}
/>
<div className={`box mt-3 ${style.tabContainer}`}>
<Tabs items={[

View File

@ -21,7 +21,7 @@ const RepartitionPreview: FunctionComponent<RepartitionPreviewProps> = ({ projec
</thead>
<tbody>
{
Object.values(project.params.taskCategories).map(tc => {
Object.values(project.taskCategories).map(tc => {
let percent = (repartition[tc.id] * 100).toFixed(0);
return (
<tr key={`task-category-${tc.id}`}>

View File

@ -55,7 +55,7 @@ const TaskCategoriesTable: FunctionComponent<TaskCategoriesTableProps> = ({ proj
</thead>
<tbody>
{
Object.values(project.params.taskCategories).map(tc => {
Object.values(project.taskCategories).map(tc => {
return (
<tr key={`task-category-${tc.id}`}>
<td>

View File

@ -9,22 +9,25 @@ import ProjectTimeUnit from "../ProjectTimeUnit";
export interface TaskTableProps {
project: Project
onTaskAdd: (task: Task) => void
onTaskRemove: (taskId: TaskID) => void
onEstimationChange: (taskId: TaskID, confidence: EstimationConfidence, value: number) => void
onTaskLabelUpdate: (taskId: TaskID, label: string) => void
onTaskRemove: (taskId: number) => void
onEstimationChange: (taskId: number, confidence: EstimationConfidence, value: number) => void
onTaskLabelUpdate: (taskId: number, label: string) => void
}
export type EstimationTotals = { [confidence in EstimationConfidence]: number }
const TaskTable: FunctionComponent<TaskTableProps> = ({ project, onTaskAdd, onEstimationChange, onTaskRemove, onTaskLabelUpdate }) => {
const defaultTaskCategory = Object.keys(project.params.taskCategories)[0];
const [ task, setTask ] = useState(newTask("", defaultTaskCategory));
const [ task, setTask ] = useState(newTask("", null));
const [ totals, setTotals ] = useState({
[EstimationConfidence.Optimistic]: 0,
[EstimationConfidence.Likely]: 0,
[EstimationConfidence.Pessimistic]: 0,
} as EstimationTotals);
useEffect(() => {
if (project.taskCategories.length === 0) return;
setTask({...task, category: project.taskCategories[0]});
}, [project.taskCategories]);
const isPrint = usePrintMediaQuery();
@ -33,7 +36,7 @@ const TaskTable: FunctionComponent<TaskTableProps> = ({ project, onTaskAdd, onEs
let likely = 0;
let pessimistic = 0;
Object.values(project.tasks).forEach(t => {
project.tasks.forEach(t => {
optimistic += t.estimations.optimistic;
likely += t.estimations.likely;
pessimistic += t.estimations.pessimistic;
@ -49,26 +52,28 @@ const TaskTable: FunctionComponent<TaskTableProps> = ({ project, onTaskAdd, onEs
const onNewTaskCategoryChange = (evt: ChangeEvent) => {
const value = (evt.currentTarget as HTMLInputElement).value;
setTask({...task, category: value});
const taskCategoryId = parseInt(value);
const category = project.taskCategories.find(tc => tc.id === taskCategoryId);
setTask({...task, category });
};
const onTaskLabelChange = (taskId: TaskID, value: string) => {
const onTaskLabelChange = (taskId: number, value: string) => {
onTaskLabelUpdate(taskId, value);
};
const onAddTaskClick = (evt: MouseEvent) => {
onTaskAdd(task);
setTask(newTask("", defaultTaskCategory));
setTask(newTask("", project.taskCategories[0]));
};
const onTaskRemoveClick = (taskId: TaskID, evt: MouseEvent) => {
const onTaskRemoveClick = (taskId: number, evt: MouseEvent) => {
onTaskRemove(taskId);
};
const withEstimationChange = (confidence: EstimationConfidence, taskID: TaskID, evt: ChangeEvent) => {
const withEstimationChange = (confidence: EstimationConfidence, taskId: number, evt: ChangeEvent) => {
const textValue = (evt.currentTarget as HTMLInputElement).value;
const value = parseFloat(textValue);
onEstimationChange(taskID, confidence, value);
onEstimationChange(taskId, confidence, value);
};
const onOptimisticChange = withEstimationChange.bind(null, EstimationConfidence.Optimistic);
@ -93,11 +98,11 @@ const TaskTable: FunctionComponent<TaskTableProps> = ({ project, onTaskAdd, onEs
</thead>
<tbody>
{
Object.values(project.tasks).map(t => {
const category = project.params.taskCategories[t.category];
project.tasks.map((t,i) => {
const category = project.taskCategories.find(tc => tc.id === t.category.id);
const categoryLabel = category ? category.label : '???';
return (
<tr key={`taks-${t.id}`}>
<tr key={`tasks-${t.id}-${i}`}>
<td className={`is-narrow noPrint`}>
<button
onClick={onTaskRemoveClick.bind(null, t.id)}
@ -163,9 +168,9 @@ const TaskTable: FunctionComponent<TaskTableProps> = ({ project, onTaskAdd, onEs
</p>
<p className="control">
<span className="select">
<select onChange={onNewTaskCategoryChange} value={task.category}>
<select onChange={onNewTaskCategoryChange} value={task.category ? task.category.id : -1}>
{
Object.values(project.params.taskCategories).map(tc => {
Object.values(project.taskCategories).map(tc => {
return (
<option key={`task-category-${tc.id}`} value={tc.id}>{tc.label}</option>
);

View File

@ -0,0 +1,34 @@
import { gql } from '@apollo/client';
export const FRAGMENT_FULL_PROJECT = gql`
fragment FullProject on Project {
id
title
taskCategories {
id
label
costPerTimeUnit
}
tasks {
id
label
category {
id
label
}
estimations {
optimistic
likely
pessimistic
}
}
params {
timeUnit {
label
acronym
}
currency
hideFinancialPreviewOnPrint
}
}
`

View File

@ -0,0 +1,69 @@
import { gql, useMutation, PureQueryOptions } from '@apollo/client';
import { FRAGMENT_FULL_PROJECT } from '../fragments/project';
import { QUERY_PROJECTS } from '../queries/project';
export const MUTATION_CREATE_PROJECT = gql`
mutation createProject($changes: CreateProjectChanges!) {
createProject(changes: $changes) {
...FullProject
}
}
${FRAGMENT_FULL_PROJECT}
`;
export function useProjectCreateMutation() {
return useMutation(MUTATION_CREATE_PROJECT);
}
export const MUTATION_UPDATE_PROJECT_TITLE = gql`
mutation updateProjectTitle($projectId: ID!, $title: String!) {
updateProjectTitle(projectId: $projectId, title: $title) {
...FullProject
}
}
${FRAGMENT_FULL_PROJECT}
`;
export function useUpdateProjectTitleMutation() {
return useMutation(MUTATION_UPDATE_PROJECT_TITLE, {
refetchQueries: ({ variables }: PureQueryOptions) => {
return [
{ query: QUERY_PROJECTS, variables: { filters: { ids: [ variables.projectId ] } }},
];
}
});
}
export const MUTATION_ADD_PROJECT_TASK = gql`
mutation addProjectTask($projectId: ID!, $changes: ProjectTaskChanges!) {
addProjectTask(projectId: $projectId, changes: $changes) {
id
}
}
`;
export function useAddProjectTaskMutation() {
return useMutation(MUTATION_ADD_PROJECT_TASK);
}
export const MUTATION_UPDATE_PROJECT_TASK = gql`
mutation updateProjectTask($projectId: ID!, $taskId: ID!, $changes: ProjectTaskChanges!) {
updateProjectTask(projectId: $projectId, taskId: $taskId, changes: $changes) {
id
}
}
`;
export function useUpdateProjectTaskMutation() {
return useMutation(MUTATION_UPDATE_PROJECT_TASK);
}
export const MUTATION_REMOVE_PROJECT_TASK = gql`
mutation removeProjectTask($projectId: ID!, $taskId: ID!) {
removeProjectTask(projectId: $projectId, taskId: $taskId)
}
`;
export function useRemoveProjectTaskMutation() {
return useMutation(MUTATION_REMOVE_PROJECT_TASK);
}

View File

@ -1,7 +1,7 @@
import { useQuery, DocumentNode } from "@apollo/client";
import { useQuery, DocumentNode, QueryHookOptions } from "@apollo/client";
import { useState, useEffect } from "react";
export function useGraphQLData<T>(q: DocumentNode, key: string, defaultValue: T, options = {}) {
export function useGraphQLData<T, A = any, R = Record<string, any>>(q: DocumentNode, key: string, defaultValue: T, options: QueryHookOptions<A, R> = {}) {
const query = useQuery(q, options);
const [ data, setData ] = useState<T>(defaultValue);
useEffect(() => {

View File

@ -0,0 +1,49 @@
import { gql, useQuery, QueryHookOptions } from '@apollo/client';
import { User } from '../../types/user';
import { useGraphQLData } from './helper';
import { Project } from '../../types/project';
export const QUERY_PROJECTS = gql`
query projects($filter: ProjectsFilter) {
projects(filter: $filter) {
id
title
taskCategories {
id
label
costPerTimeUnit
}
tasks {
id
label
category {
id
label
}
estimations {
optimistic
likely
pessimistic
}
}
params {
timeUnit {
label
acronym
}
currency
hideFinancialPreviewOnPrint
}
}
}`;
export function useProjectsQuery() {
return useQuery(QUERY_PROJECTS);
}
export function useProjects<A = any, R = Record<string, any>>(options: QueryHookOptions<A, R> = {}) {
const { data, loading, error } = useGraphQLData<Project[]>(
QUERY_PROJECTS, 'projects', [], options
);
return { projects: data, loading, error };
}

View File

@ -0,0 +1,95 @@
import { all, select, takeLatest, put, delay } from "redux-saga/effects";
import { client } from '../gql/client';
import { MUTATION_CREATE_PROJECT, MUTATION_UPDATE_PROJECT_TITLE, MUTATION_ADD_PROJECT_TASK, MUTATION_REMOVE_PROJECT_TASK } from "../gql/mutations/project";
import { UPDATE_PROJECT_TITLE, resetProject, ADD_TASK, taskSaved, AddTask, taskAdded, taskRemoved, RemoveTask, REMOVE_TASK } from "./useProjectReducer";
import { Project } from "../types/project";
export function* rootSaga() {
yield all([
createProjectSaga(),
takeLatest(UPDATE_PROJECT_TITLE, updateProjectTitleSaga),
takeLatest(ADD_TASK, addTaskSaga),
takeLatest(REMOVE_TASK, removeTaskSaga),
]);
}
export function* updateProjectTitleSaga() {
yield delay(500);
let project = yield select();
if (project.id === undefined) {
project = yield createProjectSaga();
}
yield client.mutate({
mutation: MUTATION_UPDATE_PROJECT_TITLE,
variables: {
projectId: project.id,
title: project.title,
}
});
}
export function* createProjectSaga() {
const project: Project = yield select();
if (project.id !== undefined) return;
const { data } = yield client.mutate({
mutation: MUTATION_CREATE_PROJECT,
variables: {
changes: {
title: project.title,
}
}
});
yield put(resetProject(data.createProject));
return yield select();
}
export function* addTaskSaga({ task }: AddTask) {
let project: Project = yield select();
if (project.id === undefined) {
project = yield createProjectSaga();
}
const { data } = yield client.mutate({
mutation: MUTATION_ADD_PROJECT_TASK,
variables: {
projectId: project.id,
changes: {
label: task.label,
categoryId: task.category ? task.category.id : -1,
estimations: {
optimistic: task.estimations.optimistic,
likely: task.estimations.likely,
pessimistic: task.estimations.pessimistic,
}
}
}
});
yield put(taskAdded({ ...task, ...data.addProjectTask }));
}
export function* removeTaskSaga({ id }: RemoveTask) {
let project: Project = yield select();
if (project.id === undefined) {
project = yield createProjectSaga();
}
yield client.mutate({
mutation: MUTATION_REMOVE_PROJECT_TASK,
variables: {
projectId: project.id,
taskId: id,
}
});
yield put(taskRemoved(id));
}

View File

@ -1,6 +1,8 @@
import { Project } from "../types/project";
import { Task, TaskID, EstimationConfidence, TaskCategoryID, TaskCategory } from "../types/task";
import { useReducer } from "react";
import { useReducerAndSaga } from "./useReducerAndSaga";
import { rootSaga } from "./useProjectReducer.sagas";
import { uuid } from "../util/uuid";
export interface Action {
type: string
@ -8,34 +10,41 @@ export interface Action {
export type ProjectReducerActions =
AddTask |
TaskSaved |
RemoveTask |
TaskRemoved |
UpdateTaskEstimation |
UpdateProjectLabel |
UpdateProjectTitle |
UpdateTaskLabel |
UpdateParam |
UpdateTaskCategoryLabel |
UpdateTaskCategoryCost |
AddTaskCategory |
RemoveTaskCategory
RemoveTaskCategory |
ResetProject
export function useProjectReducer(project: Project) {
return useReducer(projectReducer, project);
return useReducerAndSaga<Project>(projectReducer, project, rootSaga);
}
export function projectReducer(project: Project, action: ProjectReducerActions): Project {
console.log(action);
switch(action.type) {
case ADD_TASK:
return handleAddTask(project, action as AddTask);
case TASK_ADDED:
return handleTaskAdded(project, action as TaskAdded);
case TASK_SAVED:
return handleTaskSaved(project, action as TaskSaved);
case REMOVE_TASK:
return handleRemoveTask(project, action as RemoveTask);
case TASK_REMOVED:
return handleTaskRemoved(project, action as RemoveTask);
case UPDATE_TASK_ESTIMATION:
return handleUpdateTaskEstimation(project, action as UpdateTaskEstimation);
case UPDATE_PROJECT_LABEL:
return handleUpdateProjectLabel(project, action as UpdateProjectLabel);
case UPDATE_PROJECT_TITLE:
return handleUpdateProjectTitle(project, action as UpdateProjectTitle);
case UPDATE_TASK_LABEL:
return handleUpdateTaskLabel(project, action as UpdateTaskLabel);
@ -54,6 +63,9 @@ export function projectReducer(project: Project, action: ProjectReducerActions):
case UPDATE_TASK_CATEGORY_COST:
return handleUpdateTaskCategoryCost(project, action as UpdateTaskCategoryCost);
case RESET_PROJECT:
return handleResetProject(project, action as ResetProject);
}
@ -70,30 +82,74 @@ export function addTask(task: Task): AddTask {
return { type: ADD_TASK, task };
}
export function handleAddTask(project: Project, action: AddTask): Project {
export interface TaskAdded extends Action {
task: Task
}
export const TASK_ADDED = "TASK_ADDED";
export function taskAdded(task: Task): TaskAdded {
return { type: TASK_ADDED, task };
}
export function handleTaskAdded(project: Project, action: TaskAdded): Project {
const task = { ...action.task };
return {
...project,
tasks: {
tasks: [
...project.tasks,
[task.id]: task,
}
task,
]
};
};
export const TASK_SAVED = "TASK_SAVED";
export interface TaskSaved extends Action {
task: Task
}
export function taskSaved(task: Task): TaskSaved {
return { type: TASK_SAVED, task };
}
export function handleTaskSaved(project: Project, action: TaskSaved): Project {
const taskIndex = project.tasks.findIndex(t => t.id === action.task.id);
if (taskIndex === -1) return project;
const tasks = [ ...project.tasks ];
tasks[taskIndex] = { ...tasks[taskIndex], ...action.task };
return {
...project,
tasks
};
}
export interface RemoveTask extends Action {
id: TaskID
id: number
}
export const REMOVE_TASK = "REMOVE_TASK";
export function removeTask(id: TaskID): RemoveTask {
export function removeTask(id: number): RemoveTask {
return { type: REMOVE_TASK, id };
}
export function handleRemoveTask(project: Project, action: RemoveTask): Project {
const tasks = { ...project.tasks };
delete tasks[action.id];
export interface TaskRemoved extends Action {
id: number
}
export const TASK_REMOVED = "TASK_REMOVED";
export function taskRemoved(id: number): TaskRemoved {
return { type: TASK_REMOVED, id };
}
export function handleTaskRemoved(project: Project, action: TaskRemoved): Project {
const tasks = [...project.tasks];
const taskIndex = project.tasks.findIndex(t => t.id === action.id);
if (taskIndex === -1) return project;
tasks.splice(taskIndex, 1);
return {
...project,
tasks
@ -101,20 +157,24 @@ export function handleRemoveTask(project: Project, action: RemoveTask): Project
}
export interface UpdateTaskEstimation extends Action {
id: TaskID
id: number
confidence: string
value: number
}
export const UPDATE_TASK_ESTIMATION = "UPDATE_TASK_ESTIMATION";
export function updateTaskEstimation(id: TaskID, confidence: EstimationConfidence, value: number): UpdateTaskEstimation {
export function updateTaskEstimation(id: number, confidence: EstimationConfidence, value: number): UpdateTaskEstimation {
return { type: UPDATE_TASK_ESTIMATION, id, confidence, value };
}
export function handleUpdateTaskEstimation(project: Project, action: UpdateTaskEstimation): Project {
const tasks = [...project.tasks];
const taskIndex = project.tasks.findIndex(t => t.id === action.id);
if (taskIndex === -1) return project;
const estimations = {
...project.tasks[action.id].estimations,
...project.tasks[taskIndex].estimations,
[action.confidence]: action.value
};
@ -125,58 +185,54 @@ export function handleUpdateTaskEstimation(project: Project, action: UpdateTaskE
if (estimations.pessimistic < estimations.likely) {
estimations.pessimistic = estimations.likely;
}
project.tasks[taskIndex] = { ...project.tasks[taskIndex], estimations };
return {
...project,
tasks: {
...project.tasks,
[action.id]: {
...project.tasks[action.id],
estimations: estimations,
}
}
tasks
};
}
export interface UpdateProjectLabel extends Action {
label: string
export interface UpdateProjectTitle extends Action {
title: string
}
export const UPDATE_PROJECT_LABEL = "UPDATE_PROJECT_LABEL";
export const UPDATE_PROJECT_TITLE = "UPDATE_PROJECT_TITLE";
export function updateProjectLabel(label: string): UpdateProjectLabel {
return { type: UPDATE_PROJECT_LABEL, label };
export function updateProjectTitle(title: string): UpdateProjectTitle {
return { type: UPDATE_PROJECT_TITLE, title };
}
export function handleUpdateProjectLabel(project: Project, action: UpdateProjectLabel): Project {
export function handleUpdateProjectTitle(project: Project, action: UpdateProjectTitle): Project {
return {
...project,
label: action.label
title: action.title
};
}
export interface UpdateTaskLabel extends Action {
id: TaskID
id: number
label: string
}
export const UPDATE_TASK_LABEL = "UPDATE_TASK_LABEL";
export function updateTaskLabel(id: TaskID, label: string): UpdateTaskLabel {
export function updateTaskLabel(id: number, label: string): UpdateTaskLabel {
return { type: UPDATE_TASK_LABEL, id, label };
}
export function handleUpdateTaskLabel(project: Project, action: UpdateTaskLabel): Project {
const tasks = [...project.tasks];
const taskIndex = project.tasks.findIndex(t => t.id === action.id);
if (taskIndex === -1) return project;
tasks[taskIndex] = { ...tasks[taskIndex], label: action.label };
return {
...project,
tasks: {
...project.tasks,
[action.id]: {
...project.tasks[action.id],
label: action.label,
}
}
tasks
};
}
@ -215,15 +271,12 @@ export function updateTaskCategoryLabel(categoryId: TaskCategoryID, label: strin
export function handleUpdateTaskCategoryLabel(project: Project, action: UpdateTaskCategoryLabel): Project {
return {
...project,
params: {
...project.params,
taskCategories: {
...project.params.taskCategories,
[action.categoryId]: {
...project.params.taskCategories[action.categoryId],
label: action.label
},
}
taskCategories: {
...project.taskCategories,
[action.categoryId]: {
...project.taskCategories[action.categoryId],
label: action.label
},
}
};
}
@ -242,15 +295,12 @@ export function updateTaskCategoryCost(categoryId: TaskCategoryID, costPerTimeUn
export function handleUpdateTaskCategoryCost(project: Project, action: UpdateTaskCategoryCost): Project {
return {
...project,
params: {
...project.params,
taskCategories: {
...project.params.taskCategories,
[action.categoryId]: {
...project.params.taskCategories[action.categoryId],
costPerTimeUnit: action.costPerTimeUnit
},
}
taskCategories: {
...project.taskCategories,
[action.categoryId]: {
...project.taskCategories[action.categoryId],
costPerTimeUnit: action.costPerTimeUnit
},
}
};
}
@ -269,12 +319,9 @@ export function handleAddTaskCategory(project: Project, action: AddTaskCategory)
const taskCategory = { ...action.taskCategory };
return {
...project,
params: {
...project.params,
taskCategories: {
...project.params.taskCategories,
[taskCategory.id]: taskCategory,
}
taskCategories: {
...project.taskCategories,
[taskCategory.id]: taskCategory,
}
};
}
@ -290,13 +337,40 @@ export function removeTaskCategory(taskCategoryId: TaskCategoryID): RemoveTaskCa
}
export function handleRemoveTaskCategory(project: Project, action: RemoveTaskCategory): Project {
const taskCategories = { ...project.params.taskCategories };
const taskCategories = { ...project.taskCategories };
delete taskCategories[action.taskCategoryId];
return {
...project,
params: {
...project.params,
taskCategories
taskCategories: {
...project.taskCategories,
...taskCategories
}
};
}
}
export interface ResetProject extends Action {
project: Project
}
export const RESET_PROJECT = "RESET_PROJECT";
export function resetProject(project: Project): ResetProject {
const newProject = JSON.parse(JSON.stringify(project));
newProject.tasks.forEach(t => {
if (!t.localId) t.localId = uuid();
});
newProject.taskCategories.forEach(tc => {
if (!tc.localId) tc.localId = uuid();
});
return { type: RESET_PROJECT, project: newProject };
}
export function handleResetProject(project: Project, action: ResetProject): Project {
return {
...project,
...action.project,
};
}

View File

@ -0,0 +1,24 @@
import { useReducer, useRef, useMemo, useCallback, useEffect } from "react"
import { runSaga, stdChannel } from 'redux-saga'
export function useReducerAndSaga<T>(reducer: React.Reducer<any, any>, initialState, rootSaga): [T, (a: any) => void] {
const [state, reactDispatch] = useReducer(reducer, initialState)
const env = useRef(state)
env.current = state
const channel = useMemo(() => stdChannel(), [])
const dispatch = useCallback((a) => {
setImmediate(channel.put, a)
reactDispatch(a)
}, [])
const getState = useCallback(() => env.current, [])
useEffect(() => {
const task = runSaga({ channel, dispatch, getState }, rootSaga)
return () => task.cancel()
}, [])
return [state, dispatch]
}

View File

@ -11,7 +11,6 @@ export interface TimeUnit {
}
export interface Params {
taskCategories: TaskCategoriesIndex
timeUnit: TimeUnit
currency: string
roundUpEstimations: boolean
@ -19,53 +18,38 @@ export interface Params {
}
export const defaults = {
taskCategories: {
"RQ15CD3iX1Ey2f9kat7tfLGZmUx9GGc15nS6A7fYtZv76SnS4": {
id: "RQ15CD3iX1Ey2f9kat7tfLGZmUx9GGc15nS6A7fYtZv76SnS4",
label: "Développement",
costPerTimeUnit: 500,
},
"QRdGS5Pr5si9SSjU84WAq19cjxQ3rUL71jKh8oHSMZSY4bBH9": {
id: "QRdGS5Pr5si9SSjU84WAq19cjxQ3rUL71jKh8oHSMZSY4bBH9",
label: "Conduite de projet",
costPerTimeUnit: 500,
},
"RPcqFMLdQrgBSomv7Sao7EQSb7on6rtjfDQK5JZNhNSg9DwEo": {
id: "RPcqFMLdQrgBSomv7Sao7EQSb7on6rtjfDQK5JZNhNSg9DwEo",
label: "Recette",
costPerTimeUnit: 500,
},
},
params: {
timeUnit: {
label: "jour/homme",
acronym: "j/h",
label: "jour/homme",
acronym: "j/h",
},
roundUpEstimations: true,
currency: "€ H.T.",
costPerTimeUnit: 500,
hideFinancialPreviewOnPrint: false,
}
}
export function getTimeUnit(project: Project): TimeUnit {
return project.params.timeUnit ? project.params.timeUnit : defaults.timeUnit;
return project.params.timeUnit ? project.params.timeUnit : defaults.params.timeUnit;
}
export function getRoundUpEstimations(project: Project): boolean {
return project.params.hasOwnProperty("roundUpEstimations") ? project.params.roundUpEstimations : defaults.roundUpEstimations;
return project.params.hasOwnProperty("roundUpEstimations") ? project.params.roundUpEstimations : defaults.params.roundUpEstimations;
}
export function getCurrency(project: Project): string {
return project.params.currency ? project.params.currency : defaults.currency;
return project.params.currency ? project.params.currency : defaults.params.currency;
}
export function getTaskCategories(project: Project): TaskCategoriesIndex {
return project.params.taskCategories ? project.params.taskCategories : defaults.taskCategories;
export function getTaskCategories(project: Project): TaskCategory[] {
return project.taskCategories ? project.taskCategories : [];
}
export function getTaskCategoryCost(taskCategory: TaskCategory): number {
return taskCategory.hasOwnProperty("costPerTimeUnit") ? taskCategory.costPerTimeUnit : defaults.costPerTimeUnit;
return taskCategory.hasOwnProperty("costPerTimeUnit") ? taskCategory.costPerTimeUnit : defaults.params.costPerTimeUnit;
}
export function getHideFinancialPreviewOnPrint(project: Project): boolean {
return project.params.hasOwnProperty("hideFinancialPreviewOnPrint") ? project.params.hideFinancialPreviewOnPrint : defaults.hideFinancialPreviewOnPrint;
return project.params.hasOwnProperty("hideFinancialPreviewOnPrint") ? project.params.hideFinancialPreviewOnPrint : defaults.params.hideFinancialPreviewOnPrint;
}

View File

@ -1,29 +1,27 @@
import { Task, TaskCategory, TaskID, getTaskWeightedMean, TaskCategoryID, getTaskStandardDeviation } from './task';
import { Params, defaults, getTaskCategoryCost } from "./params";
import { Params, defaults, getTaskCategoryCost, TaskCategoriesIndex } from "./params";
import { Estimation } from '../hooks/useProjectEstimations';
export type ProjectID = string;
export type ProjectID = number;
export interface Project {
id: ProjectID
label: string
title: string
description: string
tasks: Tasks
taskCategories: TaskCategory[]
tasks: Task[]
params: Params
}
export interface Tasks {
[id: string]: Task
}
export function newProject(): Project {
export function newProject(id: number|undefined = undefined): Project {
return {
id: "",
label: "",
id: id,
title: "",
description: "",
tasks: {},
tasks: [],
taskCategories: [],
params: {
...defaults
...defaults.params
},
};
}
@ -36,7 +34,7 @@ export function getProjectWeightedMean(p : Project): number {
}
export function getTaskCategoryWeightedMean(taskCategoryId: TaskCategoryID, p : Project): number {
return Object.values(p.tasks).filter(t => t.category === taskCategoryId).reduce((sum: number, t: Task) => {
return Object.values(p.tasks).filter(t => t.category && t.category.id === taskCategoryId).reduce((sum: number, t: Task) => {
sum += getTaskWeightedMean(t);
return sum;
}, 0);
@ -58,7 +56,7 @@ export function getTaskCategoriesMeanRepartition(project: Project): MeanRepartit
const repartition: MeanRepartition = {};
Object.values(project.params.taskCategories).forEach(tc => {
Object.values(project.taskCategories).forEach(tc => {
repartition[tc.id] = getTaskCategoryWeightedMean(tc.id, project) / projectMean;
if (Number.isNaN(repartition[tc.id])) repartition[tc.id] = 0;
});
@ -74,34 +72,54 @@ export interface MinMaxCost {
export interface Cost {
totalCost: number
totalTime: number
details: { [taskCategoryId: string]: { time: number, cost: number } }
details: CostDetails[]
}
export interface CostDetails {
time: number
cost: number
taskCategoryId: TaskCategoryID
}
export function getMinMaxCosts(project: Project, estimation: Estimation): MinMaxCost {
const max: Cost = {totalCost: 0, totalTime: 0, details: {}};
const min: Cost = {totalCost: 0, totalTime: 0, details: {}};
const max: Cost = {totalCost: 0, totalTime: 0, details: []};
const min: Cost = {totalCost: 0, totalTime: 0, details: []};
const repartition = getTaskCategoriesMeanRepartition(project);
Object.values(project.params.taskCategories).forEach(tc => {
Object.values(project.taskCategories).forEach(tc => {
const cost = getTaskCategoryCost(tc);
const maxTime = Math.round((estimation.e + estimation.sd) * repartition[tc.id]);
max.details[tc.id] = {
time: maxTime,
cost: Math.ceil(maxTime) * cost,
};
max.totalTime += max.details[tc.id].time;
max.totalCost += max.details[tc.id].cost;
let maxDetails = getDetailsForTaskCategory(max.details, tc.id);
if (!maxDetails) {
maxDetails = { taskCategoryId: tc.id, time: 0, cost: 0 };
max.details.push(maxDetails);
}
maxDetails.time = maxTime;
maxDetails.cost = Math.ceil(maxTime) * cost;
max.totalTime += maxDetails.time;
max.totalCost += maxDetails.cost;
const minTime = Math.round((estimation.e - estimation.sd) * repartition[tc.id]);
min.details[tc.id] = {
time: minTime,
cost: Math.ceil(minTime) * cost,
};
min.totalTime += min.details[tc.id].time;
min.totalCost += min.details[tc.id].cost;
let minDetails = getDetailsForTaskCategory(min.details, tc.id);
if (!minDetails) {
minDetails = { taskCategoryId: tc.id, time: 0, cost: 0 };
min.details.push(minDetails);
}
minDetails.time = minTime;
minDetails.cost = Math.ceil(minTime) * cost;
min.totalTime += minDetails.time;
min.totalCost += minDetails.cost;
});
return { max, min };
}
function getDetailsForTaskCategory(details: CostDetails[], id: TaskCategoryID): CostDetails|void {
for (let d, i = 0; (d = details[i]); ++i) {
if (d.taskCategoryId === id) return d;
}
}

View File

@ -1,6 +1,7 @@
import { defaults } from "./params";
import { uuid } from "../util/uuid";
export type TaskID = string
export type TaskID = number
export enum EstimationConfidence {
Optimistic = "optimistic",
@ -11,21 +12,21 @@ export enum EstimationConfidence {
export interface Task {
id: TaskID
label: string
category: TaskCategoryID
category: TaskCategory
estimations: { [confidence in EstimationConfidence]: number }
}
export type TaskCategoryID = string
export type TaskCategoryID = number
export interface TaskCategory {
id: TaskCategoryID
id: number
label: string
costPerTimeUnit: number
}
export function newTask(label: string, category: TaskCategoryID): Task {
export function newTask(label: string, category: TaskCategory): Task {
return {
id: '',
id: null,
label,
category,
estimations: {
@ -38,8 +39,8 @@ export function newTask(label: string, category: TaskCategoryID): Task {
export function createTaskCategory(): TaskCategory {
return {
id: '',
costPerTimeUnit: defaults.costPerTimeUnit,
id: null,
costPerTimeUnit: defaults.params.costPerTimeUnit,
label: ""
};
}

35
client/src/util/uuid.ts Normal file
View File

@ -0,0 +1,35 @@
const hex: string[] = [];
for (var i = 0; i < 256; i++) {
hex[i] = (i < 16 ? '0' : '') + (i).toString(16);
}
export function uuid(): string {
const r = crypto.getRandomValues(new Uint8Array(16));
r[6] = r[6] & 0x0f | 0x40;
r[8] = r[8] & 0x3f | 0x80;
return (
hex[r[0]] +
hex[r[1]] +
hex[r[2]] +
hex[r[3]] +
"-" +
hex[r[4]] +
hex[r[5]] +
"-" +
hex[r[6]] +
hex[r[7]] +
"-" +
hex[r[8]] +
hex[r[9]] +
"-" +
hex[r[10]] +
hex[r[11]] +
hex[r[12]] +
hex[r[13]] +
hex[r[14]] +
hex[r[15]]
);
}