feat(ui+backend): base of data persistence
This commit is contained in:
@ -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
|
||||
}
|
||||
|
@ -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}` }}
|
||||
/>
|
||||
);
|
||||
|
@ -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}` }}
|
||||
/>
|
||||
);
|
||||
|
@ -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 (
|
||||
|
@ -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}`}>
|
@ -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={[
|
||||
|
@ -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}`}>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
|
34
client/src/gql/fragments/project.ts
Normal file
34
client/src/gql/fragments/project.ts
Normal 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
|
||||
}
|
||||
}
|
||||
`
|
69
client/src/gql/mutations/project.tsx
Normal file
69
client/src/gql/mutations/project.tsx
Normal 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);
|
||||
}
|
@ -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(() => {
|
||||
|
49
client/src/gql/queries/project.ts
Normal file
49
client/src/gql/queries/project.ts
Normal 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 };
|
||||
}
|
95
client/src/hooks/useProjectReducer.sagas.ts
Normal file
95
client/src/hooks/useProjectReducer.sagas.ts
Normal 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));
|
||||
}
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
24
client/src/hooks/useReducerAndSaga.ts
Normal file
24
client/src/hooks/useReducerAndSaga.ts
Normal 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]
|
||||
}
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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
35
client/src/util/uuid.ts
Normal 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]]
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user