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
|
docker-compose down -v --remove-orphans
|
||||||
|
|
||||||
db-shell:
|
db-shell:
|
||||||
docker-compose exec postgres psql -Udaddy
|
docker-compose exec postgres psql -Uguesstimate
|
||||||
|
|
||||||
migrate: build-server
|
migrate: build-server
|
||||||
( set -o allexport && source .env && set +o allexport && bin/server -workdir "./cmd/server" -config ../../data/config.yml -migrate $(MIGRATE) )
|
( 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";
|
import { WithLoader } from "../WithLoader";
|
||||||
|
|
||||||
export interface Item {
|
export interface Item {
|
||||||
id: string
|
id: string|number
|
||||||
[propName: string]: any;
|
[propName: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -21,7 +21,7 @@ export interface ItemPanelProps {
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
items: Item[]
|
items: Item[]
|
||||||
tabs?: TabDefinition[],
|
tabs?: TabDefinition[],
|
||||||
itemKey: (item: Item, index: number) => string
|
itemKey: (item: Item, index: number) => string|number
|
||||||
itemLabel: (item: Item, index: number) => string
|
itemLabel: (item: Item, index: number) => string
|
||||||
itemUrl: (item: Item, index: number) => string
|
itemUrl: (item: Item, index: number) => string
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import React, { FunctionComponent } from "react";
|
import React, { FunctionComponent } from "react";
|
||||||
import { ItemPanel } from "./ItemPanel";
|
import { ItemPanel } from "./ItemPanel";
|
||||||
|
import { useProjects } from "../../gql/queries/project";
|
||||||
|
|
||||||
export interface ProjectModelPanelProps {
|
export interface ProjectModelPanelProps {
|
||||||
|
|
||||||
|
@ -13,7 +14,7 @@ export const ProjectModelPanel: FunctionComponent<ProjectModelPanelProps> = () =
|
||||||
newItemUrl="/models/new"
|
newItemUrl="/models/new"
|
||||||
items={[]}
|
items={[]}
|
||||||
itemKey={(item) => { return item.id }}
|
itemKey={(item) => { return item.id }}
|
||||||
itemLabel={(item) => { return item.id }}
|
itemLabel={(item) => { return `${item.id}` }}
|
||||||
itemUrl={(item) => { return `/models/${item.id}` }}
|
itemUrl={(item) => { return `/models/${item.id}` }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,19 +1,25 @@
|
||||||
import React, { FunctionComponent } from "react";
|
import React, { FunctionComponent } from "react";
|
||||||
import { ItemPanel } from "./ItemPanel";
|
import { ItemPanel } from "./ItemPanel";
|
||||||
|
import { useProjects } from "../../gql/queries/project";
|
||||||
|
|
||||||
export interface ProjectPanelProps {
|
export interface ProjectPanelProps {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProjectPanel: FunctionComponent<ProjectPanelProps> = () => {
|
export const ProjectPanel: FunctionComponent<ProjectPanelProps> = () => {
|
||||||
|
const { projects } = useProjects({
|
||||||
|
fetchPolicy: 'cache-and-network',
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ItemPanel
|
<ItemPanel
|
||||||
title="Mes projets"
|
title="Mes projets"
|
||||||
className="is-primary"
|
className="is-primary"
|
||||||
newItemUrl="/projects/new"
|
newItemUrl="/projects/new"
|
||||||
items={[]}
|
items={projects}
|
||||||
|
itemIconClassName="fa fa-file"
|
||||||
itemKey={(item) => { return item.id }}
|
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}` }}
|
itemUrl={(item) => { return `/projects/${item.id}` }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -2,7 +2,7 @@ import React, { FunctionComponent, Fragment } from "react";
|
||||||
import { Project } from "../../types/project";
|
import { Project } from "../../types/project";
|
||||||
import TaskTable from "./TasksTable";
|
import TaskTable from "./TasksTable";
|
||||||
import { TimePreview } from "./TimePreview";
|
import { TimePreview } from "./TimePreview";
|
||||||
import FinancialPreview from "./FinancielPreview";
|
import FinancialPreview from "./FinancialPreview";
|
||||||
import { addTask, updateTaskEstimation, removeTask, updateTaskLabel, ProjectReducerActions } from "../../hooks/useProjectReducer";
|
import { addTask, updateTaskEstimation, removeTask, updateTaskLabel, ProjectReducerActions } from "../../hooks/useProjectReducer";
|
||||||
import { Task, TaskID, EstimationConfidence } from "../../types/task";
|
import { Task, TaskID, EstimationConfidence } from "../../types/task";
|
||||||
import RepartitionPreview from "./RepartitionPreview";
|
import RepartitionPreview from "./RepartitionPreview";
|
||||||
|
@ -18,16 +18,16 @@ const EstimationTab: FunctionComponent<EstimationTabProps> = ({ project, dispatc
|
||||||
dispatch(addTask(task));
|
dispatch(addTask(task));
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTaskRemove = (taskId: TaskID) => {
|
const onTaskRemove = (id: number) => {
|
||||||
dispatch(removeTask(taskId));
|
dispatch(removeTask(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
const onTaskLabelUpdate = (taskId: TaskID, label: string) => {
|
const onTaskLabelUpdate = (id: number, label: string) => {
|
||||||
dispatch(updateTaskLabel(taskId, label));
|
dispatch(updateTaskLabel(id, label));
|
||||||
}
|
}
|
||||||
|
|
||||||
const onEstimationChange = (taskId: TaskID, confidence: EstimationConfidence, value: number) => {
|
const onEstimationChange = (id: number, confidence: EstimationConfidence, value: number) => {
|
||||||
dispatch(updateTaskEstimation(taskId, confidence, value));
|
dispatch(updateTaskEstimation(id, confidence, value));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -64,7 +64,7 @@ export const CostDetails:FunctionComponent<CostDetailsProps> = ({ project, cost,
|
||||||
<tbody>
|
<tbody>
|
||||||
{
|
{
|
||||||
Object.keys(cost.details).map(taskCategoryId => {
|
Object.keys(cost.details).map(taskCategoryId => {
|
||||||
const taskCategory = project.params.taskCategories[taskCategoryId];
|
const taskCategory = project.taskCategories[parseInt(taskCategoryId)];
|
||||||
const details = cost.details[taskCategoryId];
|
const details = cost.details[taskCategoryId];
|
||||||
return (
|
return (
|
||||||
<tr key={`task-category-cost-${taskCategory.id}`}>
|
<tr key={`task-category-cost-${taskCategory.id}`}>
|
|
@ -1,14 +1,15 @@
|
||||||
import React, { FunctionComponent, useEffect } from "react";
|
import React, { FunctionComponent, useEffect } from "react";
|
||||||
import style from "./style.module.css";
|
import style from "./style.module.css";
|
||||||
import { newProject, Project } from "../../types/project";
|
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 EditableText from "../EditableText/EditableText";
|
||||||
import Tabs from "../../components/Tabs/Tabs";
|
import Tabs from "../../components/Tabs/Tabs";
|
||||||
import EstimationTab from "./EstimationTab";
|
import EstimationTab from "./EstimationTab";
|
||||||
import ParamsTab from "./ParamsTab";
|
import ParamsTab from "./ParamsTab";
|
||||||
import ExportTab from "./ExportTab";
|
import ExportTab from "./ExportTab";
|
||||||
import { useParams } from "react-router";
|
import { useParams, useHistory } from "react-router";
|
||||||
import { Page } from "../Page";
|
import { Page } from "../Page";
|
||||||
|
import { useProjects } from "../../gql/queries/project";
|
||||||
|
|
||||||
export interface ProjectProps {
|
export interface ProjectProps {
|
||||||
projectId: string
|
projectId: string
|
||||||
|
@ -16,10 +17,24 @@ export interface ProjectProps {
|
||||||
|
|
||||||
export const ProjectPage: FunctionComponent<ProjectProps> = () => {
|
export const ProjectPage: FunctionComponent<ProjectProps> = () => {
|
||||||
const { id } = useParams();
|
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) => {
|
useEffect(() => {
|
||||||
dispatch(updateProjectLabel(projectLabel));
|
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 (
|
return (
|
||||||
|
@ -30,8 +45,8 @@ export const ProjectPage: FunctionComponent<ProjectProps> = () => {
|
||||||
<EditableText
|
<EditableText
|
||||||
editIconClass="is-size-4"
|
editIconClass="is-size-4"
|
||||||
render={(value) => (<h2 className="is-size-3">{value}</h2>)}
|
render={(value) => (<h2 className="is-size-3">{value}</h2>)}
|
||||||
onChange={onProjectLabelChange}
|
onChange={onProjectTitleChange}
|
||||||
value={project.label ? project.label : "Projet sans nom"}
|
value={project.title ? project.title : "Projet sans nom"}
|
||||||
/>
|
/>
|
||||||
<div className={`box mt-3 ${style.tabContainer}`}>
|
<div className={`box mt-3 ${style.tabContainer}`}>
|
||||||
<Tabs items={[
|
<Tabs items={[
|
||||||
|
|
|
@ -21,7 +21,7 @@ const RepartitionPreview: FunctionComponent<RepartitionPreviewProps> = ({ projec
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{
|
{
|
||||||
Object.values(project.params.taskCategories).map(tc => {
|
Object.values(project.taskCategories).map(tc => {
|
||||||
let percent = (repartition[tc.id] * 100).toFixed(0);
|
let percent = (repartition[tc.id] * 100).toFixed(0);
|
||||||
return (
|
return (
|
||||||
<tr key={`task-category-${tc.id}`}>
|
<tr key={`task-category-${tc.id}`}>
|
||||||
|
|
|
@ -55,7 +55,7 @@ const TaskCategoriesTable: FunctionComponent<TaskCategoriesTableProps> = ({ proj
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{
|
{
|
||||||
Object.values(project.params.taskCategories).map(tc => {
|
Object.values(project.taskCategories).map(tc => {
|
||||||
return (
|
return (
|
||||||
<tr key={`task-category-${tc.id}`}>
|
<tr key={`task-category-${tc.id}`}>
|
||||||
<td>
|
<td>
|
||||||
|
|
|
@ -9,23 +9,26 @@ import ProjectTimeUnit from "../ProjectTimeUnit";
|
||||||
export interface TaskTableProps {
|
export interface TaskTableProps {
|
||||||
project: Project
|
project: Project
|
||||||
onTaskAdd: (task: Task) => void
|
onTaskAdd: (task: Task) => void
|
||||||
onTaskRemove: (taskId: TaskID) => void
|
onTaskRemove: (taskId: number) => void
|
||||||
onEstimationChange: (taskId: TaskID, confidence: EstimationConfidence, value: number) => void
|
onEstimationChange: (taskId: number, confidence: EstimationConfidence, value: number) => void
|
||||||
onTaskLabelUpdate: (taskId: TaskID, label: string) => void
|
onTaskLabelUpdate: (taskId: number, label: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EstimationTotals = { [confidence in EstimationConfidence]: number }
|
export type EstimationTotals = { [confidence in EstimationConfidence]: number }
|
||||||
|
|
||||||
const TaskTable: FunctionComponent<TaskTableProps> = ({ project, onTaskAdd, onEstimationChange, onTaskRemove, onTaskLabelUpdate }) => {
|
const TaskTable: FunctionComponent<TaskTableProps> = ({ project, onTaskAdd, onEstimationChange, onTaskRemove, onTaskLabelUpdate }) => {
|
||||||
|
const [ task, setTask ] = useState(newTask("", null));
|
||||||
const defaultTaskCategory = Object.keys(project.params.taskCategories)[0];
|
|
||||||
const [ task, setTask ] = useState(newTask("", defaultTaskCategory));
|
|
||||||
const [ totals, setTotals ] = useState({
|
const [ totals, setTotals ] = useState({
|
||||||
[EstimationConfidence.Optimistic]: 0,
|
[EstimationConfidence.Optimistic]: 0,
|
||||||
[EstimationConfidence.Likely]: 0,
|
[EstimationConfidence.Likely]: 0,
|
||||||
[EstimationConfidence.Pessimistic]: 0,
|
[EstimationConfidence.Pessimistic]: 0,
|
||||||
} as EstimationTotals);
|
} as EstimationTotals);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (project.taskCategories.length === 0) return;
|
||||||
|
setTask({...task, category: project.taskCategories[0]});
|
||||||
|
}, [project.taskCategories]);
|
||||||
|
|
||||||
const isPrint = usePrintMediaQuery();
|
const isPrint = usePrintMediaQuery();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -33,7 +36,7 @@ const TaskTable: FunctionComponent<TaskTableProps> = ({ project, onTaskAdd, onEs
|
||||||
let likely = 0;
|
let likely = 0;
|
||||||
let pessimistic = 0;
|
let pessimistic = 0;
|
||||||
|
|
||||||
Object.values(project.tasks).forEach(t => {
|
project.tasks.forEach(t => {
|
||||||
optimistic += t.estimations.optimistic;
|
optimistic += t.estimations.optimistic;
|
||||||
likely += t.estimations.likely;
|
likely += t.estimations.likely;
|
||||||
pessimistic += t.estimations.pessimistic;
|
pessimistic += t.estimations.pessimistic;
|
||||||
|
@ -49,26 +52,28 @@ const TaskTable: FunctionComponent<TaskTableProps> = ({ project, onTaskAdd, onEs
|
||||||
|
|
||||||
const onNewTaskCategoryChange = (evt: ChangeEvent) => {
|
const onNewTaskCategoryChange = (evt: ChangeEvent) => {
|
||||||
const value = (evt.currentTarget as HTMLInputElement).value;
|
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);
|
onTaskLabelUpdate(taskId, value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAddTaskClick = (evt: MouseEvent) => {
|
const onAddTaskClick = (evt: MouseEvent) => {
|
||||||
onTaskAdd(task);
|
onTaskAdd(task);
|
||||||
setTask(newTask("", defaultTaskCategory));
|
setTask(newTask("", project.taskCategories[0]));
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTaskRemoveClick = (taskId: TaskID, evt: MouseEvent) => {
|
const onTaskRemoveClick = (taskId: number, evt: MouseEvent) => {
|
||||||
onTaskRemove(taskId);
|
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 textValue = (evt.currentTarget as HTMLInputElement).value;
|
||||||
const value = parseFloat(textValue);
|
const value = parseFloat(textValue);
|
||||||
onEstimationChange(taskID, confidence, value);
|
onEstimationChange(taskId, confidence, value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onOptimisticChange = withEstimationChange.bind(null, EstimationConfidence.Optimistic);
|
const onOptimisticChange = withEstimationChange.bind(null, EstimationConfidence.Optimistic);
|
||||||
|
@ -93,11 +98,11 @@ const TaskTable: FunctionComponent<TaskTableProps> = ({ project, onTaskAdd, onEs
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{
|
{
|
||||||
Object.values(project.tasks).map(t => {
|
project.tasks.map((t,i) => {
|
||||||
const category = project.params.taskCategories[t.category];
|
const category = project.taskCategories.find(tc => tc.id === t.category.id);
|
||||||
const categoryLabel = category ? category.label : '???';
|
const categoryLabel = category ? category.label : '???';
|
||||||
return (
|
return (
|
||||||
<tr key={`taks-${t.id}`}>
|
<tr key={`tasks-${t.id}-${i}`}>
|
||||||
<td className={`is-narrow noPrint`}>
|
<td className={`is-narrow noPrint`}>
|
||||||
<button
|
<button
|
||||||
onClick={onTaskRemoveClick.bind(null, t.id)}
|
onClick={onTaskRemoveClick.bind(null, t.id)}
|
||||||
|
@ -163,9 +168,9 @@ const TaskTable: FunctionComponent<TaskTableProps> = ({ project, onTaskAdd, onEs
|
||||||
</p>
|
</p>
|
||||||
<p className="control">
|
<p className="control">
|
||||||
<span className="select">
|
<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 (
|
return (
|
||||||
<option key={`task-category-${tc.id}`} value={tc.id}>{tc.label}</option>
|
<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";
|
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 query = useQuery(q, options);
|
||||||
const [ data, setData ] = useState<T>(defaultValue);
|
const [ data, setData ] = useState<T>(defaultValue);
|
||||||
useEffect(() => {
|
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 { Project } from "../types/project";
|
||||||
import { Task, TaskID, EstimationConfidence, TaskCategoryID, TaskCategory } from "../types/task";
|
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 {
|
export interface Action {
|
||||||
type: string
|
type: string
|
||||||
|
@ -8,34 +10,41 @@ export interface Action {
|
||||||
|
|
||||||
export type ProjectReducerActions =
|
export type ProjectReducerActions =
|
||||||
AddTask |
|
AddTask |
|
||||||
|
TaskSaved |
|
||||||
RemoveTask |
|
RemoveTask |
|
||||||
|
TaskRemoved |
|
||||||
UpdateTaskEstimation |
|
UpdateTaskEstimation |
|
||||||
UpdateProjectLabel |
|
UpdateProjectTitle |
|
||||||
UpdateTaskLabel |
|
UpdateTaskLabel |
|
||||||
UpdateParam |
|
UpdateParam |
|
||||||
UpdateTaskCategoryLabel |
|
UpdateTaskCategoryLabel |
|
||||||
UpdateTaskCategoryCost |
|
UpdateTaskCategoryCost |
|
||||||
AddTaskCategory |
|
AddTaskCategory |
|
||||||
RemoveTaskCategory
|
RemoveTaskCategory |
|
||||||
|
ResetProject
|
||||||
|
|
||||||
export function useProjectReducer(project: Project) {
|
export function useProjectReducer(project: Project) {
|
||||||
return useReducer(projectReducer, project);
|
return useReducerAndSaga<Project>(projectReducer, project, rootSaga);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function projectReducer(project: Project, action: ProjectReducerActions): Project {
|
export function projectReducer(project: Project, action: ProjectReducerActions): Project {
|
||||||
console.log(action);
|
console.log(action);
|
||||||
switch(action.type) {
|
|
||||||
case ADD_TASK:
|
|
||||||
return handleAddTask(project, action as AddTask);
|
|
||||||
|
|
||||||
case REMOVE_TASK:
|
switch(action.type) {
|
||||||
return handleRemoveTask(project, action as RemoveTask);
|
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:
|
case UPDATE_TASK_ESTIMATION:
|
||||||
return handleUpdateTaskEstimation(project, action as UpdateTaskEstimation);
|
return handleUpdateTaskEstimation(project, action as UpdateTaskEstimation);
|
||||||
|
|
||||||
case UPDATE_PROJECT_LABEL:
|
case UPDATE_PROJECT_TITLE:
|
||||||
return handleUpdateProjectLabel(project, action as UpdateProjectLabel);
|
return handleUpdateProjectTitle(project, action as UpdateProjectTitle);
|
||||||
|
|
||||||
case UPDATE_TASK_LABEL:
|
case UPDATE_TASK_LABEL:
|
||||||
return handleUpdateTaskLabel(project, action as UpdateTaskLabel);
|
return handleUpdateTaskLabel(project, action as UpdateTaskLabel);
|
||||||
|
@ -55,6 +64,9 @@ export function projectReducer(project: Project, action: ProjectReducerActions):
|
||||||
case UPDATE_TASK_CATEGORY_COST:
|
case UPDATE_TASK_CATEGORY_COST:
|
||||||
return handleUpdateTaskCategoryCost(project, action as UpdateTaskCategoryCost);
|
return handleUpdateTaskCategoryCost(project, action as UpdateTaskCategoryCost);
|
||||||
|
|
||||||
|
case RESET_PROJECT:
|
||||||
|
return handleResetProject(project, action as ResetProject);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return project;
|
return project;
|
||||||
|
@ -70,30 +82,74 @@ export function addTask(task: Task): AddTask {
|
||||||
return { type: ADD_TASK, task };
|
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 };
|
const task = { ...action.task };
|
||||||
return {
|
return {
|
||||||
...project,
|
...project,
|
||||||
tasks: {
|
tasks: [
|
||||||
...project.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 {
|
export interface RemoveTask extends Action {
|
||||||
id: TaskID
|
id: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const REMOVE_TASK = "REMOVE_TASK";
|
export const REMOVE_TASK = "REMOVE_TASK";
|
||||||
|
|
||||||
export function removeTask(id: TaskID): RemoveTask {
|
export function removeTask(id: number): RemoveTask {
|
||||||
return { type: REMOVE_TASK, id };
|
return { type: REMOVE_TASK, id };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleRemoveTask(project: Project, action: RemoveTask): Project {
|
export interface TaskRemoved extends Action {
|
||||||
const tasks = { ...project.tasks };
|
id: number
|
||||||
delete tasks[action.id];
|
}
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
...project,
|
...project,
|
||||||
tasks
|
tasks
|
||||||
|
@ -101,20 +157,24 @@ export function handleRemoveTask(project: Project, action: RemoveTask): Project
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateTaskEstimation extends Action {
|
export interface UpdateTaskEstimation extends Action {
|
||||||
id: TaskID
|
id: number
|
||||||
confidence: string
|
confidence: string
|
||||||
value: number
|
value: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UPDATE_TASK_ESTIMATION = "UPDATE_TASK_ESTIMATION";
|
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 };
|
return { type: UPDATE_TASK_ESTIMATION, id, confidence, value };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleUpdateTaskEstimation(project: Project, action: UpdateTaskEstimation): Project {
|
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 = {
|
const estimations = {
|
||||||
...project.tasks[action.id].estimations,
|
...project.tasks[taskIndex].estimations,
|
||||||
[action.confidence]: action.value
|
[action.confidence]: action.value
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -126,57 +186,53 @@ export function handleUpdateTaskEstimation(project: Project, action: UpdateTaskE
|
||||||
estimations.pessimistic = estimations.likely;
|
estimations.pessimistic = estimations.likely;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
project.tasks[taskIndex] = { ...project.tasks[taskIndex], estimations };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...project,
|
...project,
|
||||||
tasks: {
|
tasks
|
||||||
...project.tasks,
|
|
||||||
[action.id]: {
|
|
||||||
...project.tasks[action.id],
|
|
||||||
estimations: estimations,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface UpdateProjectLabel extends Action {
|
export interface UpdateProjectTitle extends Action {
|
||||||
label: string
|
title: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UPDATE_PROJECT_LABEL = "UPDATE_PROJECT_LABEL";
|
export const UPDATE_PROJECT_TITLE = "UPDATE_PROJECT_TITLE";
|
||||||
|
|
||||||
export function updateProjectLabel(label: string): UpdateProjectLabel {
|
export function updateProjectTitle(title: string): UpdateProjectTitle {
|
||||||
return { type: UPDATE_PROJECT_LABEL, label };
|
return { type: UPDATE_PROJECT_TITLE, title };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleUpdateProjectLabel(project: Project, action: UpdateProjectLabel): Project {
|
export function handleUpdateProjectTitle(project: Project, action: UpdateProjectTitle): Project {
|
||||||
return {
|
return {
|
||||||
...project,
|
...project,
|
||||||
label: action.label
|
title: action.title
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateTaskLabel extends Action {
|
export interface UpdateTaskLabel extends Action {
|
||||||
id: TaskID
|
id: number
|
||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UPDATE_TASK_LABEL = "UPDATE_TASK_LABEL";
|
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 };
|
return { type: UPDATE_TASK_LABEL, id, label };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleUpdateTaskLabel(project: Project, action: UpdateTaskLabel): Project {
|
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 {
|
return {
|
||||||
...project,
|
...project,
|
||||||
tasks: {
|
tasks
|
||||||
...project.tasks,
|
|
||||||
[action.id]: {
|
|
||||||
...project.tasks[action.id],
|
|
||||||
label: action.label,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -215,15 +271,12 @@ export function updateTaskCategoryLabel(categoryId: TaskCategoryID, label: strin
|
||||||
export function handleUpdateTaskCategoryLabel(project: Project, action: UpdateTaskCategoryLabel): Project {
|
export function handleUpdateTaskCategoryLabel(project: Project, action: UpdateTaskCategoryLabel): Project {
|
||||||
return {
|
return {
|
||||||
...project,
|
...project,
|
||||||
params: {
|
taskCategories: {
|
||||||
...project.params,
|
...project.taskCategories,
|
||||||
taskCategories: {
|
[action.categoryId]: {
|
||||||
...project.params.taskCategories,
|
...project.taskCategories[action.categoryId],
|
||||||
[action.categoryId]: {
|
label: action.label
|
||||||
...project.params.taskCategories[action.categoryId],
|
},
|
||||||
label: action.label
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -242,15 +295,12 @@ export function updateTaskCategoryCost(categoryId: TaskCategoryID, costPerTimeUn
|
||||||
export function handleUpdateTaskCategoryCost(project: Project, action: UpdateTaskCategoryCost): Project {
|
export function handleUpdateTaskCategoryCost(project: Project, action: UpdateTaskCategoryCost): Project {
|
||||||
return {
|
return {
|
||||||
...project,
|
...project,
|
||||||
params: {
|
taskCategories: {
|
||||||
...project.params,
|
...project.taskCategories,
|
||||||
taskCategories: {
|
[action.categoryId]: {
|
||||||
...project.params.taskCategories,
|
...project.taskCategories[action.categoryId],
|
||||||
[action.categoryId]: {
|
costPerTimeUnit: action.costPerTimeUnit
|
||||||
...project.params.taskCategories[action.categoryId],
|
},
|
||||||
costPerTimeUnit: action.costPerTimeUnit
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -269,12 +319,9 @@ export function handleAddTaskCategory(project: Project, action: AddTaskCategory)
|
||||||
const taskCategory = { ...action.taskCategory };
|
const taskCategory = { ...action.taskCategory };
|
||||||
return {
|
return {
|
||||||
...project,
|
...project,
|
||||||
params: {
|
taskCategories: {
|
||||||
...project.params,
|
...project.taskCategories,
|
||||||
taskCategories: {
|
[taskCategory.id]: taskCategory,
|
||||||
...project.params.taskCategories,
|
|
||||||
[taskCategory.id]: taskCategory,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -290,13 +337,40 @@ export function removeTaskCategory(taskCategoryId: TaskCategoryID): RemoveTaskCa
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleRemoveTaskCategory(project: Project, action: RemoveTaskCategory): Project {
|
export function handleRemoveTaskCategory(project: Project, action: RemoveTaskCategory): Project {
|
||||||
const taskCategories = { ...project.params.taskCategories };
|
const taskCategories = { ...project.taskCategories };
|
||||||
delete taskCategories[action.taskCategoryId];
|
delete taskCategories[action.taskCategoryId];
|
||||||
return {
|
return {
|
||||||
...project,
|
...project,
|
||||||
params: {
|
taskCategories: {
|
||||||
...project.params,
|
...project.taskCategories,
|
||||||
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 {
|
export interface Params {
|
||||||
taskCategories: TaskCategoriesIndex
|
|
||||||
timeUnit: TimeUnit
|
timeUnit: TimeUnit
|
||||||
currency: string
|
currency: string
|
||||||
roundUpEstimations: boolean
|
roundUpEstimations: boolean
|
||||||
|
@ -19,53 +18,38 @@ export interface Params {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaults = {
|
export const defaults = {
|
||||||
taskCategories: {
|
params: {
|
||||||
"RQ15CD3iX1Ey2f9kat7tfLGZmUx9GGc15nS6A7fYtZv76SnS4": {
|
|
||||||
id: "RQ15CD3iX1Ey2f9kat7tfLGZmUx9GGc15nS6A7fYtZv76SnS4",
|
|
||||||
label: "Développement",
|
|
||||||
costPerTimeUnit: 500,
|
|
||||||
},
|
|
||||||
"QRdGS5Pr5si9SSjU84WAq19cjxQ3rUL71jKh8oHSMZSY4bBH9": {
|
|
||||||
id: "QRdGS5Pr5si9SSjU84WAq19cjxQ3rUL71jKh8oHSMZSY4bBH9",
|
|
||||||
label: "Conduite de projet",
|
|
||||||
costPerTimeUnit: 500,
|
|
||||||
},
|
|
||||||
"RPcqFMLdQrgBSomv7Sao7EQSb7on6rtjfDQK5JZNhNSg9DwEo": {
|
|
||||||
id: "RPcqFMLdQrgBSomv7Sao7EQSb7on6rtjfDQK5JZNhNSg9DwEo",
|
|
||||||
label: "Recette",
|
|
||||||
costPerTimeUnit: 500,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
timeUnit: {
|
timeUnit: {
|
||||||
label: "jour/homme",
|
label: "jour/homme",
|
||||||
acronym: "j/h",
|
acronym: "j/h",
|
||||||
},
|
},
|
||||||
roundUpEstimations: true,
|
roundUpEstimations: true,
|
||||||
currency: "€ H.T.",
|
currency: "€ H.T.",
|
||||||
costPerTimeUnit: 500,
|
costPerTimeUnit: 500,
|
||||||
hideFinancialPreviewOnPrint: false,
|
hideFinancialPreviewOnPrint: false,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTimeUnit(project: Project): TimeUnit {
|
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 {
|
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 {
|
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 {
|
export function getTaskCategories(project: Project): TaskCategory[] {
|
||||||
return project.params.taskCategories ? project.params.taskCategories : defaults.taskCategories;
|
return project.taskCategories ? project.taskCategories : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTaskCategoryCost(taskCategory: TaskCategory): number {
|
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 {
|
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 { 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';
|
import { Estimation } from '../hooks/useProjectEstimations';
|
||||||
|
|
||||||
export type ProjectID = string;
|
export type ProjectID = number;
|
||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
id: ProjectID
|
id: ProjectID
|
||||||
label: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
tasks: Tasks
|
taskCategories: TaskCategory[]
|
||||||
|
tasks: Task[]
|
||||||
params: Params
|
params: Params
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Tasks {
|
export function newProject(id: number|undefined = undefined): Project {
|
||||||
[id: string]: Task
|
|
||||||
}
|
|
||||||
|
|
||||||
export function newProject(): Project {
|
|
||||||
return {
|
return {
|
||||||
id: "",
|
id: id,
|
||||||
label: "",
|
title: "",
|
||||||
description: "",
|
description: "",
|
||||||
tasks: {},
|
tasks: [],
|
||||||
|
taskCategories: [],
|
||||||
params: {
|
params: {
|
||||||
...defaults
|
...defaults.params
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -36,7 +34,7 @@ export function getProjectWeightedMean(p : Project): number {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTaskCategoryWeightedMean(taskCategoryId: TaskCategoryID, 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);
|
sum += getTaskWeightedMean(t);
|
||||||
return sum;
|
return sum;
|
||||||
}, 0);
|
}, 0);
|
||||||
|
@ -58,7 +56,7 @@ export function getTaskCategoriesMeanRepartition(project: Project): MeanRepartit
|
||||||
|
|
||||||
const repartition: MeanRepartition = {};
|
const repartition: MeanRepartition = {};
|
||||||
|
|
||||||
Object.values(project.params.taskCategories).forEach(tc => {
|
Object.values(project.taskCategories).forEach(tc => {
|
||||||
repartition[tc.id] = getTaskCategoryWeightedMean(tc.id, project) / projectMean;
|
repartition[tc.id] = getTaskCategoryWeightedMean(tc.id, project) / projectMean;
|
||||||
if (Number.isNaN(repartition[tc.id])) repartition[tc.id] = 0;
|
if (Number.isNaN(repartition[tc.id])) repartition[tc.id] = 0;
|
||||||
});
|
});
|
||||||
|
@ -74,34 +72,54 @@ export interface MinMaxCost {
|
||||||
export interface Cost {
|
export interface Cost {
|
||||||
totalCost: number
|
totalCost: number
|
||||||
totalTime: 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 {
|
export function getMinMaxCosts(project: Project, estimation: Estimation): MinMaxCost {
|
||||||
const max: Cost = {totalCost: 0, totalTime: 0, details: {}};
|
const max: Cost = {totalCost: 0, totalTime: 0, details: []};
|
||||||
const min: Cost = {totalCost: 0, totalTime: 0, details: {}};
|
const min: Cost = {totalCost: 0, totalTime: 0, details: []};
|
||||||
|
|
||||||
const repartition = getTaskCategoriesMeanRepartition(project);
|
const repartition = getTaskCategoriesMeanRepartition(project);
|
||||||
|
|
||||||
Object.values(project.params.taskCategories).forEach(tc => {
|
Object.values(project.taskCategories).forEach(tc => {
|
||||||
const cost = getTaskCategoryCost(tc);
|
const cost = getTaskCategoryCost(tc);
|
||||||
|
|
||||||
const maxTime = Math.round((estimation.e + estimation.sd) * repartition[tc.id]);
|
const maxTime = Math.round((estimation.e + estimation.sd) * repartition[tc.id]);
|
||||||
max.details[tc.id] = {
|
let maxDetails = getDetailsForTaskCategory(max.details, tc.id);
|
||||||
time: maxTime,
|
if (!maxDetails) {
|
||||||
cost: Math.ceil(maxTime) * cost,
|
maxDetails = { taskCategoryId: tc.id, time: 0, cost: 0 };
|
||||||
};
|
max.details.push(maxDetails);
|
||||||
max.totalTime += max.details[tc.id].time;
|
}
|
||||||
max.totalCost += max.details[tc.id].cost;
|
|
||||||
|
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]);
|
const minTime = Math.round((estimation.e - estimation.sd) * repartition[tc.id]);
|
||||||
min.details[tc.id] = {
|
let minDetails = getDetailsForTaskCategory(min.details, tc.id);
|
||||||
time: minTime,
|
if (!minDetails) {
|
||||||
cost: Math.ceil(minTime) * cost,
|
minDetails = { taskCategoryId: tc.id, time: 0, cost: 0 };
|
||||||
};
|
min.details.push(minDetails);
|
||||||
min.totalTime += min.details[tc.id].time;
|
}
|
||||||
min.totalCost += min.details[tc.id].cost;
|
minDetails.time = minTime;
|
||||||
|
minDetails.cost = Math.ceil(minTime) * cost;
|
||||||
|
min.totalTime += minDetails.time;
|
||||||
|
min.totalCost += minDetails.cost;
|
||||||
});
|
});
|
||||||
|
|
||||||
return { max, min };
|
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 { defaults } from "./params";
|
||||||
|
import { uuid } from "../util/uuid";
|
||||||
|
|
||||||
export type TaskID = string
|
export type TaskID = number
|
||||||
|
|
||||||
export enum EstimationConfidence {
|
export enum EstimationConfidence {
|
||||||
Optimistic = "optimistic",
|
Optimistic = "optimistic",
|
||||||
|
@ -11,21 +12,21 @@ export enum EstimationConfidence {
|
||||||
export interface Task {
|
export interface Task {
|
||||||
id: TaskID
|
id: TaskID
|
||||||
label: string
|
label: string
|
||||||
category: TaskCategoryID
|
category: TaskCategory
|
||||||
estimations: { [confidence in EstimationConfidence]: number }
|
estimations: { [confidence in EstimationConfidence]: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TaskCategoryID = string
|
export type TaskCategoryID = number
|
||||||
|
|
||||||
export interface TaskCategory {
|
export interface TaskCategory {
|
||||||
id: TaskCategoryID
|
id: number
|
||||||
label: string
|
label: string
|
||||||
costPerTimeUnit: number
|
costPerTimeUnit: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function newTask(label: string, category: TaskCategoryID): Task {
|
export function newTask(label: string, category: TaskCategory): Task {
|
||||||
return {
|
return {
|
||||||
id: '',
|
id: null,
|
||||||
label,
|
label,
|
||||||
category,
|
category,
|
||||||
estimations: {
|
estimations: {
|
||||||
|
@ -38,8 +39,8 @@ export function newTask(label: string, category: TaskCategoryID): Task {
|
||||||
|
|
||||||
export function createTaskCategory(): TaskCategory {
|
export function createTaskCategory(): TaskCategory {
|
||||||
return {
|
return {
|
||||||
id: '',
|
id: null,
|
||||||
costPerTimeUnit: defaults.costPerTimeUnit,
|
costPerTimeUnit: defaults.params.costPerTimeUnit,
|
||||||
label: ""
|
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
|
// nolint: gochecknoglobals
|
||||||
var initialModels = []interface{}{
|
var initialModels = []interface{}{
|
||||||
&model.User{},
|
&model.User{},
|
||||||
|
&model.Project{},
|
||||||
|
&model.Access{},
|
||||||
|
&model.TaskCategory{},
|
||||||
|
&model.Task{},
|
||||||
}
|
}
|
||||||
|
|
||||||
func m000initialSchema() orm.Migration {
|
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
|
return nil
|
||||||
},
|
},
|
||||||
func(ctx context.Context, tx *gorm.DB) error {
|
func(ctx context.Context, tx *gorm.DB) error {
|
||||||
for _, m := range initialModels {
|
for i := len(initialModels) - 1; i >= 0; i-- {
|
||||||
if err := tx.DropTableIfExists(m).Error; err != nil {
|
if err := tx.DropTableIfExists(initialModels[i]).Error; err != nil {
|
||||||
return errors.WithStack(err)
|
return errors.WithStack(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := tx.DropTableIfExists("sessions").Error; err != nil {
|
||||||
|
return errors.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
@ -90,7 +90,7 @@ func NewDefault() *Config {
|
||||||
Address: ":8081",
|
Address: ":8081",
|
||||||
CookieAuthenticationKey: "",
|
CookieAuthenticationKey: "",
|
||||||
CookieEncryptionKey: "",
|
CookieEncryptionKey: "",
|
||||||
CookieMaxAge: int((time.Hour * 1).Seconds()), // 1 hour
|
CookieMaxAge: int((time.Hour * 24).Seconds()), // 24 hour
|
||||||
TemplateDir: "template",
|
TemplateDir: "template",
|
||||||
PublicDir: "public",
|
PublicDir: "public",
|
||||||
FrontendURL: "http://localhost:8080",
|
FrontendURL: "http://localhost:8080",
|
||||||
|
|
|
@ -45,10 +45,7 @@ autobind:
|
||||||
models:
|
models:
|
||||||
ID:
|
ID:
|
||||||
model:
|
model:
|
||||||
- github.com/99designs/gqlgen/graphql.ID
|
|
||||||
- github.com/99designs/gqlgen/graphql.Int
|
|
||||||
- github.com/99designs/gqlgen/graphql.Int64
|
- github.com/99designs/gqlgen/graphql.Int64
|
||||||
- github.com/99designs/gqlgen/graphql.Int32
|
|
||||||
Int:
|
Int:
|
||||||
model:
|
model:
|
||||||
- github.com/99designs/gqlgen/graphql.Int
|
- 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
|
name: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input CreateProjectChanges {
|
||||||
|
title: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
input ProjectTaskChanges {
|
||||||
|
label: String
|
||||||
|
categoryId: ID
|
||||||
|
estimations: ProjectTaskEstimationsChanges
|
||||||
|
}
|
||||||
|
|
||||||
|
input ProjectTaskEstimationsChanges {
|
||||||
|
optimistic: Float
|
||||||
|
likely: Float
|
||||||
|
pessimistic: Float
|
||||||
|
}
|
||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
updateUser(id: ID!, changes: UserChanges!): User!
|
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"
|
"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)
|
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.
|
// Mutation returns generated.MutationResolver implementation.
|
||||||
func (r *Resolver) Mutation() generated.MutationResolver { return &mutationResolver{r} }
|
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!
|
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 {
|
type Query {
|
||||||
currentUser: User
|
currentUser: User
|
||||||
|
projects(filter: ProjectsFilter): [Project]
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,19 @@ func (r *queryResolver) CurrentUser(ctx context.Context) (*model1.User, error) {
|
||||||
return handleCurrentUser(ctx)
|
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.
|
// Query returns generated.QueryResolver implementation.
|
||||||
func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} }
|
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 queryResolver struct{ *Resolver }
|
||||||
|
type taskResolver struct{ *Resolver }
|
||||||
|
|
|
@ -19,7 +19,7 @@ func handleCurrentUser(ctx context.Context) (*model.User, error) {
|
||||||
return user, nil
|
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)
|
user, db, err := getSessionUser(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.WithStack(err)
|
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 {
|
type User struct {
|
||||||
ID string `json:"id"`
|
Base
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
ConnectedAt time.Time `json:"connectedAt"`
|
ConnectedAt time.Time `json:"connectedAt"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserChanges struct {
|
type UserChanges struct {
|
||||||
|
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
set -e
|
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
|
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
|
||||||
CREATE USER hydra WITH ENCRYPTED PASSWORD 'hydra';
|
CREATE USER hydra WITH ENCRYPTED PASSWORD 'hydra';
|
||||||
CREATE DATABASE hydra;
|
CREATE DATABASE hydra;
|
||||||
|
|
|
@ -11,10 +11,14 @@ modd.conf {
|
||||||
prep: make build
|
prep: make build
|
||||||
prep: [ -e data/config.yml ] || ( mkdir -p data && bin/server -dump-config > data/config.yml )
|
prep: [ -e data/config.yml ] || ( mkdir -p data && bin/server -dump-config > data/config.yml )
|
||||||
prep: [ -e .env ] || ( cp .env.dist .env )
|
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 )
|
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 {
|
**/*.go {
|
||||||
prep: make test
|
prep: make test
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue