UNPKG

tln-pm

Version:
620 lines (605 loc) 19.6 kB
import { use, useState, useEffect } from 'react'; import Box from '@mui/material/Box'; import Container from '@mui/material/Container'; import { useTheme } from '@mui/material/styles'; import Highcharts from 'highcharts' //import Gantt from "highcharts/highcharts-gantt"; import Gantt from 'highcharts/modules/gantt'; import HighchartsReact from 'highcharts-react-official'; import Breadcrumbs from '@mui/material/Breadcrumbs'; import HomeIcon from '@mui/icons-material/Home'; import MoreHorizIcon from '@mui/icons-material/MoreHoriz'; import Menu from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; import Switch from '@mui/material/Switch'; import FormControl from '@mui/material/FormControl'; import FormGroup from '@mui/material/FormGroup'; import FormControlLabel from '@mui/material/FormControlLabel'; import FormLabel from '@mui/material/FormLabel'; import Select from '@mui/material/Select'; import Drawer from '@mui/material/Drawer'; import Button from '@mui/material/Button'; import List from '@mui/material/List'; import Divider from '@mui/material/Divider'; import ListItem from '@mui/material/ListItem'; import ListItemButton from '@mui/material/ListItemButton'; import ListItemIcon from '@mui/material/ListItemIcon'; import ListItemText from '@mui/material/ListItemText'; import IconButton from '@mui/material/IconButton'; import TuneIcon from '@mui/icons-material/Tune'; import InputLabel from '@mui/material/InputLabel'; import Checkbox from '@mui/material/Checkbox'; import Avatar from '@mui/material/Avatar'; import RefreshIcon from '@mui/icons-material/Refresh'; import { Typography } from '@mui/material'; import Context from './../../shared/Context'; import { errorMsgFetchingData } from './../../shared/Errors'; import { API_BASE_URL } from './../../shared/Consts'; function Wbs() { const theme = useTheme(); const [refreshKey, setRefreshKey] = useState(0); // const { setContext } = use(Context); const team = use(Context).context.team; const components = use(Context).context.components; const selectedMembers = use(Context).context.selectedMembers; const timeline = use(Context).context.timeline; const deadline = use(Context).context.deadline; const interval = use(Context).context.interval; const statuses = use(Context).context.statuses; const priorities = use(Context).context.priorities; //console.log('!Wbs', team, components, selectedMembers, timeline, deadline, interval, statuses, priorities); // console.log('!Deadline', deadline); // console.log('!Timeline', timeline); // console.log('!Priorities', priorities); // // Components const handleComponentsChange = (newComponents) => { let value = []; if (newComponents) { if (typeof newComponents === 'string') { value = [...components, newComponents]; } else { value = newComponents; } } setContext((prev) => ({...prev, components: value})); }; // // Subcomponents const [subComponents, setSubComponents] = useState([]); const [anchorEl, setAnchorEl] = useState(null); const subComponentOpen = Boolean(anchorEl); const handleClick = (event) => { setAnchorEl(event.currentTarget); }; const handleClose = () => { setAnchorEl(null); }; // // Timeline const plotLines = []; const [deadlines] = useState(timeline.map((d) => { if (d.active) { plotLines.push({ id: d.uid, value: d.date, color: d.current ? 'red' : 'blue', width: 5 }); } return ({ id: d.uid, name: d.uid + (d.active ? ` (in ${d.durationToReleaseHR})` : '') }); }).flat(1)); const handleDeadlineChange = (event) => { setContext((prev) => ({...prev, deadline: event.target.value})); }; // // Statuses const handleStatusesChange = (key, value) => { const changes = {...statuses}; changes[key] = value; setContext((prev) => ({...prev, statuses: changes})); }; // // Priorities const handlePrioritiesChange = (key, value) => { const changes = {...priorities}; changes[key] = value; setContext((prev) => ({...prev, priorities: changes})); }; // // Members const [members] = useState(team.map((m) => { return { id: m.id, name: m.name, avatar: m.name.split(' ').map(n => n.substring(0, 1).toUpperCase()) } })); const handleToggle = (value) => () => { const currentIndex = selectedMembers.indexOf(value); const newChecked = [...selectedMembers]; if (currentIndex === -1) { newChecked.push(value); } else { newChecked.splice(currentIndex, 1); } setContext((prev) => ({...prev, selectedMembers: newChecked})); }; // // Drawer const [openDrawer, setOpenDrawer] = useState(false); const toggleDrawer = (newOpen) => () => { setOpenDrawer(newOpen); }; const DrawerList = ( <Box sx={{ p: 2, width: 336}} role="presentation"> <Box sx={{ pb: 2, display: 'flex', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}> <Typography variant="h7" component="div" sx={{ flexGrow1: 1 }}> Details </Typography> <IconButton aria-label="close" onClick={() => setRefreshKey((prev) => prev + 1)} > <RefreshIcon /> </IconButton> </Box> {/*<IconButton aria-label="close" onClick={toggleDrawer(false)} sx={(theme) => ({ p: 0, position: 'absolute', right: 0, top: 0, color: theme.palette.grey[500], })} > <CloseIcon /> </IconButton>*/} <FormControl fullWidth size="small"> <InputLabel id="deadline-select-label">deadline</InputLabel> <Select labelId="deadline-select-label" id="deadline-select" value={deadline} label="deadline" onChange={handleDeadlineChange} > {deadlines.map((d, index) => ( <MenuItem key={index} size="small" value={d.id}><small>{d.name}</small></MenuItem> ))} </Select> </FormControl> <Box sx={{ py: 2 }}> <Divider /> </Box> <FormControl sx={{ width: '100%' }} component="fieldset" > <FormLabel component="legend">Status</FormLabel> <FormGroup sx={{pt: 1, pl: 1}} aria-label="position" row> {Object.keys(statuses).map((key, index) => ( <FormControlLabel key={index} value="end" control={<Switch checked={statuses[key]} size="small" sx={{ "&.MuiSwitch-root .MuiSwitch-switchBase": { color: "darkgray" }, "&.MuiSwitch-root .MuiSwitch-track": { backgroundColor: theme.tasks[key].backgroundColor, }, "&.MuiSwitch-root .Mui-checked": { color: theme.tasks[key].backgroundColor } }} onChange={(event) => handleStatusesChange(key, event.target.checked)} />} label={<Box component="div" fontSize={14}>{key}</Box>} labelPlacement="end" /> ))} </FormGroup> </FormControl> <Box sx={{ py: 2 }}> <Divider /> </Box> <FormControl sx={{ width: '100%' }} component="fieldset" > <FormLabel component="legend">Priority</FormLabel> <Box sx={{ display: "flex", justifyContent: "space-between", width: "100%" }}> {[{id: 'critical', label: 'Critical'}, {id: 'high', label: 'High'}, {id: 'low', label: 'Low'}].map((p) => ( <FormControlLabel key={p.id} sx={{ "& .MuiTypography-root": { fontSize: "14px" }}} control={ <Checkbox size="small" checked={priorities[p.id]} onChange={(event) => handlePrioritiesChange(p.id, event.target.checked)} inputProps={{ 'aria-label': 'controlled' }} />} label={p.label} /> ))} </Box> </FormControl> <Box sx={{ py: 2 }}> <Divider /> </Box> <FormControl sx={{ width: '100%' }} component="fieldset" > <FormLabel component="legend">Team</FormLabel> <List sx={{ width: '100%', maxWidth: 360, bgcolor: 'background.paper' }}> {members.map((m) => { const labelId = `checkbox-list-label-${m.id}`; return ( <ListItem key={m.id} secondaryAction={ <Avatar sx={{ width: 32, height: 32 }}>{m.avatar}</Avatar> } disablePadding > <ListItemButton role={undefined} onClick={handleToggle(m.id)} sx={{pl: 0}} dense> <ListItemIcon> <Checkbox size="small" edge="start" checked={selectedMembers.includes(m.id)} tabIndex={-1} disableRipple inputProps={{ 'aria-labelledby': labelId }} /> </ListItemIcon> <ListItemText id={labelId} primary={`${m.name} (${m.id})`} /> </ListItemButton> </ListItem> ); })} </List> </FormControl> </Box> ); // // Tasks const day = 24 * 36e5, //today = Math.floor(Date.now() / day) * day; today = Date.now(); const transformTasks = (data) => { const series = []; const processComponent = (parentId, team, timeline, component) => { const id = [parentId, component.id].join('.'); const item = { name: component.name, id, parent: parentId !== '' ? parentId : undefined, start: today + component.start, end: today + component.end, color: theme.components.backgroundColor // data: 'data', // owner: 'Linda' }; if (component.percentage) { item.completed = { amount: component.percentage }; } series.push(item); // const processTasks = (parentId, tasks) => { tasks.forEach((t, index) => { // console.log('task:', t); const ts = t.status == '>' ? 'dev' : t.status == '+' ? 'done' : t.status == '!' ? 'blocked' : 'todo'; const color = t.tasks.length ? theme.tasks.story.backgroundColor : theme.tasks[ts].backgroundColor; const id = [parentId, t.id ? t.id : index].join('.'); const task = { name: t.title, id, parent: parentId, start: today + t.start, end: today + t.end, owner: t.assignees.join(', '), color }; if (t.percentage) { item.completed = { amount: t.percentage }; } series.push(task); // processTasks(id, t.tasks); }); }; processTasks(id, component.tasks); component.components.forEach((c) => { processComponent(id, team, timeline, c); }); }; // processComponent('', {}, {}, data); //console.log(JSON.stringify(series)); return series; }; const [chartOptions, setChartOptions] = useState({ credits: { enabled: false }, chart: { type: 'gantt', plotBackgroundColor: 'rgba(128,128,128,0.02)', plotBorderColor: 'rgba(128,128,128,0.1)', plotBorderWidth: 1 }, plotOptions: { series: { borderRadius: '50%', connectors: { dashStyle: 'ShortDot', lineWidth: 2, radius: 5, startMarker: { enabled: false } }, groupPadding: 0, dataLabels: [ { enabled: true, align: 'left', format: '', padding: 10, style: { color: 'white', fontWeight: 'normal', textOutline: 'none' } }, { enabled: true, align: 'center', format: '{#if point.completed}{(multiply ' + 'point.completed.amount 100):.0f}%{/if}', padding: 10, style: { fontWeight: 'normal', textOutline: 'none', opacity: 0.6 } } ] } }, series: [ { name: 'WBS', data: [] }, ], // series: testData, tooltip: { pointFormat: '<span style="font-weight: bold">{point.name}</span><br>' + '{point.start:%e %b}' + '{#unless point.milestone} → {point.end:%e %b}{/unless}' + '<br>' + '{#if point.completed}' + 'Completed: {multiply point.completed.amount 100}%<br>' + '{/if}' + 'Assignee: {#if point.owner}{point.owner}{else}-{/if}' }, /* title: { text: '', }, */ xAxis: [{ currentDateIndicator: { color: '#2caffe', dashStyle: 'ShortDot', width: 2, label: { format: '' } }, dateTimeLabelFormats: { day: '%e<br><span style="opacity: 0.5; font-size: 0.7em">%a</span>' }, grid: { borderWidth: 0 }, gridLineWidth: 1, min: interval.start, max: interval.end, custom: { today, weekendPlotBands: true }, plotLines: plotLines.map((l) => ({ label: { rotation: 0, text: l.id, x: -32, y: -4 }, value: l.value, color: l.color, width: l.width, zIndex: 10 })) }], yAxis: { grid: { borderWidth: 0 }, gridLineWidth: 0, labels: { symbol: { width: 8, height: 6, x: -4, y: -2 } }, staticScale: 30 }, accessibility: { keyboardNavigation: { seriesNavigation: { mode: 'serialize' } }, point: { descriptionFormatter: function(point) { const completedValue = point.completed ? point.completed.amount || point.completed : null, completed = completedValue ? ' Task ' + Math.round(completedValue * 1000) / 10 + '% completed.' : '', dependency = point.dependency && point.series.chart.get(point.dependency).name, dependsOn = dependency ? ' Depends on ' + dependency + '.' : ''; return Highcharts.format( point.milestone ? '{point.yCategory}. Milestone at {point.x:%Y-%m-%d}. ' + 'Owner: {point.owner}.{dependsOn}' : '{point.yCategory}.{completed} Start ' + '{point.x:%Y-%m-%d}, end {point.x2:%Y-%m-%d}. Owner: ' + '{point.owner}.{dependsOn}', { point, completed, dependsOn } ); } } }, lang: { accessibility: { axis: { xAxisDescriptionPlural: 'The chart has a two-part X axis ' + 'showing time in both week numbers and days.' } } } }); useEffect(() => { const getTasks = async () => { try { const query = []; if (statuses) { query.push(`status=${Object.keys(statuses).filter((k) => statuses[k]).join(',')}`); } // Assignees if (selectedMembers.length) { query.push(`assignees=${selectedMembers.join(',')}`); } // Tags const tags = []; if (deadline) { timeline.forEach((d) => { if (d.uid === deadline) { tags.push(d.id); } }); } Object.keys(priorities).forEach((k) => { if (priorities[k]) { tags.push(k); } }); // console.log('tags:', tags, deadline, priorities); if (tags.length) { query.push(`tags=${tags.join(',')}`); } // Search // if (search.length) { // query.push(`search=${search.join(',')}`); // } const p = (['tasks'].concat(components)).join('/'); const q = query.join('&'); const url = `${API_BASE_URL}/${p}?${q}`; // console.log('url:', url); const response = await fetch(url); if (!response.ok) { throw `Error fetching WBD (${url}): ${response.status}`; } const data = await response.json(); if (data.success) { const tasks = transformTasks(data.data); setChartOptions( prev => ({...prev, series: [ { name: 'WBS', data: tasks }, ]})); setSubComponents(data.data.components.map((c) => c.id)); // console.log('tasks:', tasks); } } catch (error) { // console.log('error:', error); throw new Error(`${error.message} : ${errorMsgFetchingData}`); } }; getTasks(); }, [refreshKey, components]); // // console.log('!Wbs'); return ( <Container maxWidth="xl" sx={{pt: 1}}> <Box sx={{display: 'flex', flexDirection: 'row', justifyContent: 'space-between', alignContent: 'center'}}> {/* Breadcrumbs menu */} <Menu id="basic-menu" anchorEl={anchorEl} open={subComponentOpen} onClose={handleClose} MenuListProps={{ 'aria-labelledby': 'basic-button', }} > {subComponents.map((c, index) => ( <MenuItem key={index} onClick={(event) => { setAnchorEl(null); handleComponentsChange(c); }}>{c}</MenuItem> ))} </Menu> <Breadcrumbs aria-label="breadcrumb"> <IconButton size="small" onClick={() => handleComponentsChange()}> <HomeIcon fontSize="inherit"/> </IconButton> {components.map((c, index) => ( <Button key={index} size="small" sx={{textTransform : "none"}} onClick={() => handleComponentsChange(components.slice(0, index+1))}>{c}</Button> ))} {subComponents.length && <IconButton size="small" id="basic-button" aria-controls={subComponentOpen ? 'basic-menu' : undefined} aria-haspopup="true" aria-expanded={subComponentOpen ? 'true' : undefined} onClick={handleClick} > <MoreHorizIcon fontSize="inherit"/> </IconButton>} </Breadcrumbs> {/* Statuses */} <FormControl component="fieldset" > <FormGroup aria-label="position" row> {/* Filtering details Drawer */} <FormControl variant="standard" sx={{ pl: 4}}> <IconButton size="small" onClick={toggleDrawer(true)}> <TuneIcon fontSize="inherit"/> </IconButton> </FormControl> </FormGroup> </FormControl> <Drawer open={openDrawer} anchor='right' onClose={toggleDrawer(false)}> {DrawerList} </Drawer> </Box> <HighchartsReact key={refreshKey} containerProps={{ style: { height: "100%" } }} constructorType={'ganttChart'} highcharts={Gantt} options={chartOptions} /> </Container> ); } export default Wbs;