guesstimate/client/src/routes/project/tasks-table.tsx

259 lines
10 KiB
TypeScript

import React, { FunctionComponent, useState, useEffect, ChangeEvent, MouseEvent, useCallback } from "react";
import style from "./style.module.css";
import { Project } from "../../models/project";
import { newTask, Task, TaskID, EstimationConfidence } from "../../models/task";
import EditableText from "../../components/editable-text";
import { usePrintMediaQuery } from "../../hooks/use-media-query";
import ProjectTimeUnit from "../../components/project-time-unit";
import { useSort } from "../../hooks/useSort";
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
onTaskMove: (taskId: TaskID, move: number) => void
}
export type EstimationTotals = { [confidence in EstimationConfidence]: number }
const TaskTable: FunctionComponent<TaskTableProps> = ({ project, onTaskAdd, onEstimationChange, onTaskRemove, onTaskLabelUpdate, onTaskMove }) => {
const defaultTaskCategory = Object.keys(project.params.taskCategories)[0];
const [task, setTask] = useState(newTask("", defaultTaskCategory));
const [totals, setTotals] = useState({
[EstimationConfidence.Optimistic]: 0,
[EstimationConfidence.Likely]: 0,
[EstimationConfidence.Pessimistic]: 0,
} as EstimationTotals);
const [tasks, setTasks] = useState<Task[]>([])
useEffect(() => {
setTasks(Object.values(project.tasks))
}, [project.tasks])
const sortTask = useCallback((a: Task, b: Task) => {
const aIndex = (project.ordering ?? []).findIndex(taskId => a.id === taskId)
const bIndex = (project.ordering ?? []).findIndex(taskId => b.id === taskId)
return aIndex - bIndex;
}, [project.ordering])
const sorted = useSort(tasks, sortTask)
const [ activeTaskActions, setActiveTaskActions ] = useState<string|null>(null);
const isPrint = usePrintMediaQuery();
useEffect(() => {
let optimistic = 0;
let likely = 0;
let pessimistic = 0;
Object.values(project.tasks).forEach(t => {
optimistic += t.estimations.optimistic;
likely += t.estimations.likely;
pessimistic += t.estimations.pessimistic;
});
setTotals({ optimistic, likely, pessimistic });
}, [project.tasks]);
const toggleActiveTaskAction = useCallback((evt: MouseEvent<HTMLButtonElement>) => {
const taskId = evt.currentTarget.dataset.taskId;
if (!taskId) return
setActiveTaskActions(activeTaskActions === taskId ? null : taskId)
}, [activeTaskActions])
const onNewTaskLabelChange = (evt: ChangeEvent) => {
const value = (evt.currentTarget as HTMLInputElement).value;
setTask({ ...task, label: value });
};
const onNewTaskCategoryChange = (evt: ChangeEvent) => {
const value = (evt.currentTarget as HTMLInputElement).value;
setTask({ ...task, category: value });
};
const onTaskLabelChange = (taskId: TaskID, value: string) => {
onTaskLabelUpdate(taskId, value);
};
const onAddTaskClick = (evt: MouseEvent) => {
onTaskAdd(task);
setTask(newTask("", defaultTaskCategory));
};
const onTaskRemoveClick = useCallback((evt: MouseEvent<HTMLAnchorElement>) => {
const taskId = evt.currentTarget.dataset.taskId;
if (!taskId) return
onTaskRemove(taskId);
}, []);
const onTaskMoveUpClick = useCallback((evt: MouseEvent<HTMLAnchorElement>) => {
const taskId = evt.currentTarget.dataset.taskId;
if (!taskId) return
onTaskMove(taskId, -1);
}, []);
const onTaskMoveDownClick = useCallback((evt: MouseEvent<HTMLAnchorElement>) => {
const taskId = evt.currentTarget.dataset.taskId;
if (!taskId) return
onTaskMove(taskId, +1);
}, []);
const withEstimationChange = (confidence: EstimationConfidence, taskID: TaskID, evt: ChangeEvent) => {
const textValue = (evt.currentTarget as HTMLInputElement).value;
const value = parseFloat(textValue);
onEstimationChange(taskID, confidence, value);
};
const onOptimisticChange = withEstimationChange.bind(null, EstimationConfidence.Optimistic);
const onLikelyChange = withEstimationChange.bind(null, EstimationConfidence.Likely);
const onPessimisticChange = withEstimationChange.bind(null, EstimationConfidence.Pessimistic);
return (
<div className="table-container">
<table className={`table is-bordered is-striped is-hoverable is-fullwidth ${style.middleTable}`}>
<thead>
<tr>
<th className={`${style.noBorder} noPrint`} rowSpan={2}></th>
<th className={style.mainColumn} rowSpan={2}>Tâche</th>
<th rowSpan={2}>Catégorie</th>
<th colSpan={3}>Estimation (en <ProjectTimeUnit project={project} />)</th>
</tr>
<tr>
<th>Optimiste</th>
<th>Probable</th>
<th>Pessimiste</th>
</tr>
</thead>
<tbody>
{
sorted.map(t => {
const category = project.params.taskCategories[t.category];
const categoryLabel = category ? category.label : '???';
return (
<tr key={`taks-${t.id}`}>
<td className={`is-narrow noPrint`}>
<div className={`dropdown ${activeTaskActions === t.id ? 'is-active' : ''}`}>
<div className="dropdown-trigger">
<button onClick={toggleActiveTaskAction} data-task-id={t.id} className={`button is-small is-outlined ${activeTaskActions === t.id ? 'is-active' : ''}`} aria-haspopup="true" aria-controls="dropdown-menu">
<span></span>
</button>
</div>
<div className="dropdown-menu" id="dropdown-menu" role="menu">
<div className="dropdown-content">
<a className="dropdown-item" data-task-id={t.id} onClick={onTaskMoveUpClick}>
Monter
</a>
<a href="#" className="dropdown-item" data-task-id={t.id} onClick={onTaskMoveDownClick}>
Descendre
</a>
<a className="dropdown-item" onClick={onTaskRemoveClick} data-task-id={t.id}>
<span className="has-text-danger">🗑 Supprimer</span>
</a>
</div>
</div>
</div>
</td>
<td className={style.mainColumn}>
<EditableText
render={(value) => (<span>{value}</span>)}
onChange={onTaskLabelChange.bind(null, t.id)}
value={t.label} />
</td>
<td>{categoryLabel}</td>
<td>
{
isPrint ?
<span>{t.estimations.optimistic}</span> :
<input
className="input" type="number"
value={t.estimations.optimistic}
min={0}
onChange={onOptimisticChange.bind(null, t.id)} />
}
</td>
<td>
{
isPrint ?
<span>{t.estimations.likely}</span> :
<input
className={`input ${t.estimations.likely < t.estimations.optimistic ? 'is-danger' : ''}`}
type="number"
value={t.estimations.likely}
min={0}
onChange={onLikelyChange.bind(null, t.id)} />
}
</td>
<td>
{
isPrint ?
<span>{t.estimations.pessimistic}</span> :
<input
className={`input ${t.estimations.pessimistic < t.estimations.likely ? 'is-danger' : ''}`}
type="number"
value={t.estimations.pessimistic}
min={0}
onChange={onPessimisticChange.bind(null, t.id)} />
}
</td>
</tr>
)
})
}
{
Object.keys(project.tasks).length === 0 ?
<tr>
<td className={`${style.noBorder} noPrint`}></td>
<td className={style.noTasks} colSpan={5}>Aucune tâche pour l'instant.</td>
</tr> :
null
}
</tbody>
<tfoot>
<tr>
<td className={`${style.noBorder} noPrint`}></td>
<td colSpan={2} className={isPrint ? style.noBorder : ''}>
<div className="field has-addons noPrint">
<p className="control is-expanded">
<input className="input" type="text" placeholder="Nouvelle tâche"
value={task.label} onChange={onNewTaskLabelChange} />
</p>
<p className="control">
<span className="select">
<select onChange={onNewTaskCategoryChange} value={task.category}>
{
Object.values(project.params.taskCategories).map(tc => {
return (
<option key={`task-category-${tc.id}`} value={tc.id}>{tc.label}</option>
);
})
}
</select>
</span>
</p>
<p className="control">
<a className="button is-primary" onClick={onAddTaskClick}>
Ajouter
</a>
</p>
</div>
</td>
<th colSpan={3}>Total</th>
</tr>
<tr>
<td colSpan={isPrint ? 2 : 3} className={style.noBorder}></td>
<td>{totals.optimistic} <ProjectTimeUnit project={project} /></td>
<td>{totals.likely} <ProjectTimeUnit project={project} /></td>
<td>{totals.pessimistic} <ProjectTimeUnit project={project} /></td>
</tr>
</tfoot>
</table>
</div>
);
};
export default TaskTable;