feat(ui+backend): base of data persistence
This commit is contained in:
parent
c7cea6e46b
commit
7fc1a7f3af
2
Makefile
2
Makefile
|
@ -28,7 +28,7 @@ down:
|
|||
docker-compose down -v --remove-orphans
|
||||
|
||||
db-shell:
|
||||
docker-compose exec postgres psql -Udaddy
|
||||
docker-compose exec postgres psql -Uguesstimate
|
||||
|
||||
migrate: build-server
|
||||
( set -o allexport && source .env && set +o allexport && bin/server -workdir "./cmd/server" -config ../../data/config.yml -migrate $(MIGRATE) )
|
||||
|
|
|
@ -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 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();
|
||||
|
||||
const onProjectLabelChange = (projectLabel: string) => {
|
||||
dispatch(updateProjectLabel(projectLabel));
|
||||
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,23 +9,26 @@ 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();
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
`
|
|
@ -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(() => {
|
||||
|
|
|
@ -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 };
|
||||
}
|
|
@ -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 REMOVE_TASK:
|
||||
return handleRemoveTask(project, action as RemoveTask);
|
||||
switch(action.type) {
|
||||
case TASK_ADDED:
|
||||
return handleTaskAdded(project, action as TaskAdded);
|
||||
|
||||
case TASK_SAVED:
|
||||
return handleTaskSaved(project, action as TaskSaved);
|
||||
|
||||
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);
|
||||
|
@ -55,6 +64,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);
|
||||
|
||||
}
|
||||
|
||||
return project;
|
||||
|
@ -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
|
||||
};
|
||||
|
||||
|
@ -126,57 +186,53 @@ export function handleUpdateTaskEstimation(project: Project, action: UpdateTaskE
|
|||
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,16 +271,13 @@ export function updateTaskCategoryLabel(categoryId: TaskCategoryID, label: strin
|
|||
export function handleUpdateTaskCategoryLabel(project: Project, action: UpdateTaskCategoryLabel): Project {
|
||||
return {
|
||||
...project,
|
||||
params: {
|
||||
...project.params,
|
||||
taskCategories: {
|
||||
...project.params.taskCategories,
|
||||
...project.taskCategories,
|
||||
[action.categoryId]: {
|
||||
...project.params.taskCategories[action.categoryId],
|
||||
...project.taskCategories[action.categoryId],
|
||||
label: action.label
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -242,16 +295,13 @@ export function updateTaskCategoryCost(categoryId: TaskCategoryID, costPerTimeUn
|
|||
export function handleUpdateTaskCategoryCost(project: Project, action: UpdateTaskCategoryCost): Project {
|
||||
return {
|
||||
...project,
|
||||
params: {
|
||||
...project.params,
|
||||
taskCategories: {
|
||||
...project.params.taskCategories,
|
||||
...project.taskCategories,
|
||||
[action.categoryId]: {
|
||||
...project.params.taskCategories[action.categoryId],
|
||||
...project.taskCategories[action.categoryId],
|
||||
costPerTimeUnit: action.costPerTimeUnit
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -269,13 +319,10 @@ export function handleAddTaskCategory(project: Project, action: AddTaskCategory)
|
|||
const taskCategory = { ...action.taskCategory };
|
||||
return {
|
||||
...project,
|
||||
params: {
|
||||
...project.params,
|
||||
taskCategories: {
|
||||
...project.params.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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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,23 +18,7 @@ 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",
|
||||
|
@ -45,27 +28,28 @@ export const defaults = {
|
|||
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: ""
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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]]
|
||||
);
|
||||
}
|
|
@ -79,6 +79,10 @@ func applyMigration(ctx context.Context, ctn *service.Container) error {
|
|||
// nolint: gochecknoglobals
|
||||
var initialModels = []interface{}{
|
||||
&model.User{},
|
||||
&model.Project{},
|
||||
&model.Access{},
|
||||
&model.TaskCategory{},
|
||||
&model.Task{},
|
||||
}
|
||||
|
||||
func m000initialSchema() orm.Migration {
|
||||
|
@ -91,15 +95,50 @@ func m000initialSchema() orm.Migration {
|
|||
}
|
||||
}
|
||||
|
||||
// Create foreign keys indexes
|
||||
err := tx.Model(&model.Access{}).
|
||||
AddForeignKey("user_id", "users(id)", "CASCADE", "CASCADE").Error
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
err = tx.Model(&model.TaskCategory{}).
|
||||
AddForeignKey("project_id", "projects(id)", "CASCADE", "CASCADE").Error
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
err = tx.Model(&model.Access{}).
|
||||
AddForeignKey("project_id", "projects(id)", "CASCADE", "CASCADE").Error
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
err = tx.Model(&model.Access{}).
|
||||
AddForeignKey("user_id", "users(id)", "CASCADE", "CASCADE").Error
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
err = tx.Model(&model.Task{}).
|
||||
AddForeignKey("category_id", "task_categories(id)", "CASCADE", "CASCADE").Error
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
func(ctx context.Context, tx *gorm.DB) error {
|
||||
for _, m := range initialModels {
|
||||
if err := tx.DropTableIfExists(m).Error; err != nil {
|
||||
for i := len(initialModels) - 1; i >= 0; i-- {
|
||||
if err := tx.DropTableIfExists(initialModels[i]).Error; err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.DropTableIfExists("sessions").Error; err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
|
|
@ -90,7 +90,7 @@ func NewDefault() *Config {
|
|||
Address: ":8081",
|
||||
CookieAuthenticationKey: "",
|
||||
CookieEncryptionKey: "",
|
||||
CookieMaxAge: int((time.Hour * 1).Seconds()), // 1 hour
|
||||
CookieMaxAge: int((time.Hour * 24).Seconds()), // 24 hour
|
||||
TemplateDir: "template",
|
||||
PublicDir: "public",
|
||||
FrontendURL: "http://localhost:8080",
|
||||
|
|
|
@ -45,10 +45,7 @@ autobind:
|
|||
models:
|
||||
ID:
|
||||
model:
|
||||
- github.com/99designs/gqlgen/graphql.ID
|
||||
- github.com/99designs/gqlgen/graphql.Int
|
||||
- github.com/99designs/gqlgen/graphql.Int64
|
||||
- github.com/99designs/gqlgen/graphql.Int32
|
||||
Int:
|
||||
model:
|
||||
- github.com/99designs/gqlgen/graphql.Int
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
package graph
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"forge.cadoles.com/Cadoles/guesstimate/internal/model"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func handleEstimations(ctx context.Context, task *model.Task) (*model.Estimations, error) {
|
||||
estimations := &model.Estimations{}
|
||||
|
||||
if task.Estimations == nil {
|
||||
return estimations, nil
|
||||
}
|
||||
|
||||
rawOptimistic, exists := task.Estimations[model.EstimationOptimistic]
|
||||
if exists && rawOptimistic != nil {
|
||||
optimistic, err := strconv.ParseFloat(*rawOptimistic, 64)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
estimations.Optimistic = optimistic
|
||||
}
|
||||
|
||||
rawLikely, exists := task.Estimations[model.EstimationLikely]
|
||||
if exists && rawLikely != nil {
|
||||
likely, err := strconv.ParseFloat(*rawLikely, 64)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
estimations.Likely = likely
|
||||
}
|
||||
|
||||
rawPessimistic, exists := task.Estimations[model.EstimationPessimistic]
|
||||
if exists && rawPessimistic != nil {
|
||||
pessimistic, err := strconv.ParseFloat(*rawPessimistic, 64)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
estimations.Pessimistic = pessimistic
|
||||
}
|
||||
|
||||
return estimations, nil
|
||||
}
|
|
@ -2,6 +2,26 @@ input UserChanges {
|
|||
name: String
|
||||
}
|
||||
|
||||
input CreateProjectChanges {
|
||||
title: String!
|
||||
}
|
||||
|
||||
input ProjectTaskChanges {
|
||||
label: String
|
||||
categoryId: ID
|
||||
estimations: ProjectTaskEstimationsChanges
|
||||
}
|
||||
|
||||
input ProjectTaskEstimationsChanges {
|
||||
optimistic: Float
|
||||
likely: Float
|
||||
pessimistic: Float
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
updateUser(id: ID!, changes: UserChanges!): User!
|
||||
createProject(changes: CreateProjectChanges!): Project!
|
||||
updateProjectTitle(projectId: ID!, title: String!): Project!
|
||||
addProjectTask(projectId: ID!, changes: ProjectTaskChanges): Task!
|
||||
removeProjectTask(projectId: ID!, taskId: ID!): Boolean!
|
||||
}
|
|
@ -10,10 +10,26 @@ import (
|
|||
"forge.cadoles.com/Cadoles/guesstimate/internal/model"
|
||||
)
|
||||
|
||||
func (r *mutationResolver) UpdateUser(ctx context.Context, id string, changes model.UserChanges) (*model.User, error) {
|
||||
func (r *mutationResolver) UpdateUser(ctx context.Context, id int64, changes model.UserChanges) (*model.User, error) {
|
||||
return handleUpdateUser(ctx, id, changes)
|
||||
}
|
||||
|
||||
func (r *mutationResolver) CreateProject(ctx context.Context, changes model.CreateProjectChanges) (*model.Project, error) {
|
||||
return handleCreateProject(ctx, changes)
|
||||
}
|
||||
|
||||
func (r *mutationResolver) UpdateProjectTitle(ctx context.Context, projectID int64, title string) (*model.Project, error) {
|
||||
return handleUpdateProjectTitle(ctx, projectID, title)
|
||||
}
|
||||
|
||||
func (r *mutationResolver) AddProjectTask(ctx context.Context, projectID int64, changes *model.ProjectTaskChanges) (*model.Task, error) {
|
||||
return handleAddProjectTask(ctx, projectID, changes)
|
||||
}
|
||||
|
||||
func (r *mutationResolver) RemoveProjectTask(ctx context.Context, projectID int64, taskID int64) (bool, error) {
|
||||
return handleRemoveProjectTask(ctx, projectID, taskID)
|
||||
}
|
||||
|
||||
// Mutation returns generated.MutationResolver implementation.
|
||||
func (r *Resolver) Mutation() generated.MutationResolver { return &mutationResolver{r} }
|
||||
|
||||
|
|
|
@ -0,0 +1,96 @@
|
|||
package graph
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"forge.cadoles.com/Cadoles/guesstimate/internal/model"
|
||||
model1 "forge.cadoles.com/Cadoles/guesstimate/internal/model"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func handleProjects(ctx context.Context, filter *model1.ProjectsFilter) ([]*model1.Project, error) {
|
||||
user, db, err := getSessionUser(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
repo := model.NewProjectRepository(db)
|
||||
|
||||
if filter == nil {
|
||||
filter = &model1.ProjectsFilter{
|
||||
OwnerIds: make([]int64, 0, 1),
|
||||
}
|
||||
}
|
||||
|
||||
filter.OwnerIds = append(filter.OwnerIds, user.ID)
|
||||
|
||||
projects, err := repo.Search(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return projects, nil
|
||||
}
|
||||
|
||||
func handleCreateProject(ctx context.Context, input model.CreateProjectChanges) (*model.Project, error) {
|
||||
user, db, err := getSessionUser(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
repo := model.NewProjectRepository(db)
|
||||
|
||||
project, err := repo.Create(ctx, input.Title, user.ID)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return project, nil
|
||||
}
|
||||
|
||||
func handleUpdateProjectTitle(ctx context.Context, projectID int64, title string) (*model.Project, error) {
|
||||
db, err := getDB(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
repo := model.NewProjectRepository(db)
|
||||
|
||||
project, err := repo.UpdateTitle(ctx, projectID, title)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return project, nil
|
||||
}
|
||||
|
||||
func handleAddProjectTask(ctx context.Context, projectID int64, changes *model.ProjectTaskChanges) (*model.Task, error) {
|
||||
db, err := getDB(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
repo := model.NewProjectRepository(db)
|
||||
|
||||
task, err := repo.AddTask(ctx, projectID, changes)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func handleRemoveProjectTask(ctx context.Context, projectID int64, taskID int64) (bool, error) {
|
||||
db, err := getDB(ctx)
|
||||
if err != nil {
|
||||
return false, errors.WithStack(err)
|
||||
}
|
||||
|
||||
repo := model.NewProjectRepository(db)
|
||||
|
||||
if err := repo.RemoveTask(ctx, projectID, taskID); err != nil {
|
||||
return false, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
|
@ -8,6 +8,60 @@ type User {
|
|||
createdAt: Time!
|
||||
}
|
||||
|
||||
type Project {
|
||||
id: ID!
|
||||
title: String!
|
||||
tasks: [Task]!
|
||||
params: ProjectParams!
|
||||
acl: [Access]!
|
||||
taskCategories: [TaskCategory]!
|
||||
}
|
||||
|
||||
type Task {
|
||||
id: ID!
|
||||
label: String
|
||||
category: TaskCategory
|
||||
estimations: Estimations
|
||||
}
|
||||
|
||||
type TaskCategory {
|
||||
id: ID!
|
||||
label: String!
|
||||
costPerTimeUnit: Float!
|
||||
}
|
||||
|
||||
type Access {
|
||||
id: ID!
|
||||
user: User!
|
||||
level: String!
|
||||
}
|
||||
|
||||
type ProjectParams {
|
||||
timeUnit: TimeUnit
|
||||
currency: String!
|
||||
roundUpEstimations: Boolean!
|
||||
hideFinancialPreviewOnPrint: Boolean!
|
||||
}
|
||||
|
||||
type TimeUnit {
|
||||
label: String!
|
||||
acronym: String!
|
||||
}
|
||||
|
||||
type Estimations {
|
||||
optimistic: Float!
|
||||
likely: Float!
|
||||
pessimistic: Float!
|
||||
}
|
||||
|
||||
input ProjectsFilter {
|
||||
ids: [ID]
|
||||
limit: Int
|
||||
offset: Int
|
||||
search: String
|
||||
}
|
||||
|
||||
type Query {
|
||||
currentUser: User
|
||||
projects(filter: ProjectsFilter): [Project]
|
||||
}
|
||||
|
|
|
@ -14,7 +14,19 @@ func (r *queryResolver) CurrentUser(ctx context.Context) (*model1.User, error) {
|
|||
return handleCurrentUser(ctx)
|
||||
}
|
||||
|
||||
func (r *queryResolver) Projects(ctx context.Context, filter *model1.ProjectsFilter) ([]*model1.Project, error) {
|
||||
return handleProjects(ctx, filter)
|
||||
}
|
||||
|
||||
func (r *taskResolver) Estimations(ctx context.Context, obj *model1.Task) (*model1.Estimations, error) {
|
||||
return handleEstimations(ctx, obj)
|
||||
}
|
||||
|
||||
// Query returns generated.QueryResolver implementation.
|
||||
func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} }
|
||||
|
||||
// Task returns generated.TaskResolver implementation.
|
||||
func (r *Resolver) Task() generated.TaskResolver { return &taskResolver{r} }
|
||||
|
||||
type queryResolver struct{ *Resolver }
|
||||
type taskResolver struct{ *Resolver }
|
||||
|
|
|
@ -19,7 +19,7 @@ func handleCurrentUser(ctx context.Context) (*model.User, error) {
|
|||
return user, nil
|
||||
}
|
||||
|
||||
func handleUpdateUser(ctx context.Context, id string, changes model.UserChanges) (*model.User, error) {
|
||||
func handleUpdateUser(ctx context.Context, id int64, changes model.UserChanges) (*model.User, error) {
|
||||
user, db, err := getSessionUser(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Base struct {
|
||||
ID int64 `gorm:"primary_key,AUTO_INCREMENT" json:"id"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (b *Base) BeforeSave() (err error) {
|
||||
now := time.Now()
|
||||
|
||||
if b.CreatedAt.IsZero() {
|
||||
b.CreatedAt = now
|
||||
}
|
||||
|
||||
b.UpdatedAt = now
|
||||
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,123 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/jinzhu/gorm/dialects/postgres"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type Project struct {
|
||||
Base
|
||||
Title *string `json:"title"`
|
||||
Tasks []*Task `json:"tasks"`
|
||||
Params *ProjectParams `gorm:"-" json:"params"`
|
||||
ParamsJSON postgres.Jsonb `json:"-" gorm:"column:params;"`
|
||||
TaskCategories []*TaskCategory `json:"taskCategories"`
|
||||
ACL []*Access `json:"acl"`
|
||||
}
|
||||
|
||||
func (p *Project) BeforeSave() error {
|
||||
data, err := json.Marshal(p.Params)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
p.ParamsJSON = postgres.Jsonb{RawMessage: data}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Project) AfterFind() error {
|
||||
params := &ProjectParams{}
|
||||
if err := json.Unmarshal(p.ParamsJSON.RawMessage, params); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
p.Params = params
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type ProjectParams struct {
|
||||
TimeUnit *TimeUnit `json:"timeUnit"`
|
||||
Currency string `json:"currency"`
|
||||
RoundUpEstimations bool `json:"roundUpEstimations"`
|
||||
HideFinancialPreviewOnPrint bool `json:"hideFinancialPreviewOnPrint"`
|
||||
}
|
||||
|
||||
func NewDefaultProjectParams() *ProjectParams {
|
||||
return &ProjectParams{
|
||||
Currency: "€ H.T.",
|
||||
TimeUnit: &TimeUnit{
|
||||
Acronym: "J/H",
|
||||
Label: "Jour/Homme",
|
||||
},
|
||||
RoundUpEstimations: false,
|
||||
HideFinancialPreviewOnPrint: false,
|
||||
}
|
||||
}
|
||||
|
||||
func NewDefaultTaskCategories() []*TaskCategory {
|
||||
return []*TaskCategory{
|
||||
&TaskCategory{
|
||||
Label: "Développement",
|
||||
CostPerTimeUnit: 500,
|
||||
},
|
||||
&TaskCategory{
|
||||
Label: "Conduite de projet",
|
||||
CostPerTimeUnit: 500,
|
||||
},
|
||||
&TaskCategory{
|
||||
Label: "Recette",
|
||||
CostPerTimeUnit: 500,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
LevelOwner = "owner"
|
||||
LevelReadOnly = "readonly"
|
||||
LevelContributor = "contributor"
|
||||
)
|
||||
|
||||
type Access struct {
|
||||
Base
|
||||
ProjectID int64
|
||||
Project *Project `json:"-"`
|
||||
UserID int64
|
||||
User *User `json:"user"`
|
||||
Level string `json:"level"`
|
||||
}
|
||||
|
||||
const (
|
||||
EstimationPessimistic = "pessimistic"
|
||||
EstimationLikely = "likely"
|
||||
EstimationOptimistic = "optimistic"
|
||||
)
|
||||
|
||||
type Task struct {
|
||||
Base
|
||||
ProjectID int64 `json:"-"`
|
||||
Project *Project `json:"-"`
|
||||
Label *string `json:"label"`
|
||||
CategoryID int64 `json:"-"`
|
||||
Category *TaskCategory `json:"category"`
|
||||
Estimations postgres.Hstore `json:"estimations"`
|
||||
}
|
||||
|
||||
type TaskCategory struct {
|
||||
Base
|
||||
ProjectID int64 `json:"-"`
|
||||
Project *Project `json:"-"`
|
||||
Label string `json:"label"`
|
||||
CostPerTimeUnit float64 `json:"costPerTimeUnit"`
|
||||
}
|
||||
|
||||
type ProjectsFilter struct {
|
||||
Ids []*int64 `json:"ids"`
|
||||
Limit *int `json:"limit"`
|
||||
Offset *int `json:"offset"`
|
||||
Search *string `json:"search"`
|
||||
OwnerIds []int64 `json:"-"`
|
||||
}
|
|
@ -0,0 +1,261 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/jinzhu/gorm/dialects/postgres"
|
||||
|
||||
"forge.cadoles.com/Cadoles/guesstimate/internal/orm"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type ProjectRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) Create(ctx context.Context, title string, ownerID int64) (*Project, error) {
|
||||
project := &Project{
|
||||
Title: &title,
|
||||
Tasks: make([]*Task, 0),
|
||||
TaskCategories: NewDefaultTaskCategories(),
|
||||
Params: NewDefaultProjectParams(),
|
||||
}
|
||||
|
||||
err := orm.WithTx(ctx, r.db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
if err := tx.Save(project).Error; err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
err := tx.Model(project).Association("ACL").Append(&Access{
|
||||
Level: LevelOwner,
|
||||
UserID: ownerID,
|
||||
}).Error
|
||||
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
err = tx.Model(project).
|
||||
Preload("ACL").
|
||||
Preload("ACL.User").
|
||||
Preload("Tasks").
|
||||
Preload("Tasks.Category").
|
||||
Preload("TaskCategories").
|
||||
Find(project).
|
||||
Error
|
||||
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not create user")
|
||||
}
|
||||
|
||||
return project, nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) UpdateTitle(ctx context.Context, projectID int64, title string) (*Project, error) {
|
||||
project := &Project{}
|
||||
project.ID = projectID
|
||||
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(project).Update("title", title).Error; err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
err := tx.Model(project).
|
||||
Preload("ACL").
|
||||
Preload("ACL.User").
|
||||
Preload("Tasks").
|
||||
Preload("Tasks.Category").
|
||||
Preload("TaskCategories").
|
||||
First(project, "id = ?", projectID).
|
||||
Error
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return project, nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) Search(ctx context.Context, filter *ProjectsFilter) ([]*Project, error) {
|
||||
projects := make([]*Project, 0)
|
||||
|
||||
projectTableName := r.db.NewScope(&Project{}).TableName()
|
||||
|
||||
query := r.db.Table(projectTableName).
|
||||
Preload("ACL").
|
||||
Preload("ACL.User").
|
||||
Preload("Tasks").
|
||||
Preload("Tasks.Category").
|
||||
Preload("TaskCategories")
|
||||
|
||||
if filter != nil {
|
||||
if len(filter.Ids) > 0 {
|
||||
query = query.Where(fmt.Sprintf("%s.id in (?)", projectTableName), filter.Ids)
|
||||
}
|
||||
|
||||
if len(filter.OwnerIds) > 0 {
|
||||
accessTableName := r.db.NewScope(&Access{}).TableName()
|
||||
|
||||
query = query.
|
||||
Joins(
|
||||
fmt.Sprintf(
|
||||
"left join %s on %s.project_id = %s.id",
|
||||
accessTableName, accessTableName, projectTableName,
|
||||
),
|
||||
).
|
||||
Where(fmt.Sprintf("%s.level = ?", accessTableName), LevelOwner).
|
||||
Where(fmt.Sprintf("%s.user_id IN (?)", accessTableName), filter.OwnerIds)
|
||||
}
|
||||
|
||||
if filter.Limit != nil {
|
||||
query = query.Limit(*filter.Limit)
|
||||
}
|
||||
|
||||
if filter.Offset != nil {
|
||||
query = query.Offset(*filter.Offset)
|
||||
}
|
||||
}
|
||||
|
||||
if err := query.Find(&projects).Error; err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return projects, nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) AddTask(ctx context.Context, projectID int64, changes *ProjectTaskChanges) (*Task, error) {
|
||||
project := &Project{}
|
||||
project.ID = projectID
|
||||
task := &Task{}
|
||||
|
||||
if changes == nil {
|
||||
return nil, errors.Errorf("changes should not be nil")
|
||||
}
|
||||
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if changes.Label != nil {
|
||||
task.Label = changes.Label
|
||||
}
|
||||
|
||||
if changes.CategoryID != nil {
|
||||
taskCategory := &TaskCategory{}
|
||||
taskCategory.ID = *changes.CategoryID
|
||||
task.Category = taskCategory
|
||||
}
|
||||
|
||||
if changes.Estimations != nil {
|
||||
if task.Estimations == nil {
|
||||
task.Estimations = postgres.Hstore{}
|
||||
}
|
||||
|
||||
if changes.Estimations.Pessimistic != nil {
|
||||
pessimistic := strconv.FormatFloat(*changes.Estimations.Pessimistic, 'f', 12, 64)
|
||||
task.Estimations[EstimationPessimistic] = &pessimistic
|
||||
}
|
||||
|
||||
if changes.Estimations.Likely != nil {
|
||||
likely := strconv.FormatFloat(*changes.Estimations.Likely, 'f', 12, 64)
|
||||
task.Estimations[EstimationLikely] = &likely
|
||||
}
|
||||
|
||||
if changes.Estimations.Optimistic != nil {
|
||||
optimistic := strconv.FormatFloat(*changes.Estimations.Optimistic, 'f', 12, 64)
|
||||
task.Estimations[EstimationOptimistic] = &optimistic
|
||||
}
|
||||
|
||||
if changes.CategoryID != nil {
|
||||
taskCategory := &TaskCategory{}
|
||||
if err := tx.Find(taskCategory, "id = ?", *changes.CategoryID).Error; err != nil {
|
||||
return errors.Wrap(err, "could not find task category")
|
||||
}
|
||||
|
||||
task.Category = taskCategory
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Save(task).Error; err != nil {
|
||||
return errors.Wrap(err, "could not create task")
|
||||
}
|
||||
|
||||
err := tx.Model(project).Association("Tasks").Append(task).Error
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not add task")
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not add task")
|
||||
}
|
||||
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) RemoveTask(ctx context.Context, projectID int64, taskID int64) error {
|
||||
project := &Project{}
|
||||
project.ID = projectID
|
||||
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
task := &Task{}
|
||||
task.ID = taskID
|
||||
|
||||
err := tx.Model(project).Association("Tasks").Delete(task).Error
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not remove task relationship")
|
||||
}
|
||||
|
||||
err = tx.Delete(task, "id = ?", taskID).Error
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not delete task")
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not remove task")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) UpdateTaskEstimation(ctx context.Context, projectID, taskID int64, estimation string, value float64) (*Task, error) {
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
task := &Task{}
|
||||
if err := tx.First(task, "id = ?", taskID).Error; err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
strValue := strconv.FormatFloat(value, 'f', 12, 64)
|
||||
task.Estimations[estimation] = &strValue
|
||||
|
||||
if err := tx.Save(task).Error; err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not update task")
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func NewProjectRepository(db *gorm.DB) *ProjectRepository {
|
||||
return &ProjectRepository{db}
|
||||
}
|
|
@ -5,11 +5,10 @@ import (
|
|||
)
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Base
|
||||
Name *string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
ConnectedAt time.Time `json:"connectedAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type UserChanges struct {
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
set -e
|
||||
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" template1 -c 'CREATE EXTENSION hstore;'
|
||||
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
|
||||
CREATE USER hydra WITH ENCRYPTED PASSWORD 'hydra';
|
||||
CREATE DATABASE hydra;
|
||||
|
|
|
@ -11,10 +11,14 @@ modd.conf {
|
|||
prep: make build
|
||||
prep: [ -e data/config.yml ] || ( mkdir -p data && bin/server -dump-config > data/config.yml )
|
||||
prep: [ -e .env ] || ( cp .env.dist .env )
|
||||
prep: make migrate-latest
|
||||
daemon: ( set -o allexport && source .env && set +o allexport && bin/server -workdir "./cmd/server" -config ../../data/config.yml )
|
||||
}
|
||||
|
||||
cmd/server/migration.go {
|
||||
prep: make migrate-down
|
||||
prep: make migrate-latest
|
||||
}
|
||||
|
||||
**/*.go {
|
||||
prep: make test
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue