diff --git a/client/src/adapters/project.ts b/client/src/adapters/project.ts index d7da7f2fc7897f2373499c460b921c70f64d8823..9856ae1059d0419dbc94ea43a08969997ab50f47 100644 --- a/client/src/adapters/project.ts +++ b/client/src/adapters/project.ts @@ -83,7 +83,12 @@ export function getProjectActivity(uuid: string, from: Date = new Date(0), to: D export function getProjectCompletion(uuid: string, from: Date = new Date(0), to: Date = new Date()): Promise<Completion> { return executeApiGet( `project/${uuid}/completion?since=${from.getTime()}&to=${to.getTime()}`, - ({ completion }) => completion, "Failed to get project completion" + ({ completion }) => ({...completion, sum: ( + completion.open + + completion.closed + + completion.suspended + + completion.overdue + )}), "Failed to get project completion" ); } @@ -92,7 +97,7 @@ interface NewProjectData { name: string; text: string; color: string; - deadline?: Date; + deadline?: string; } export function createProject(project: NewProjectData): Promise<string> { diff --git a/client/src/adapters/task.ts b/client/src/adapters/task.ts index aaf8649c044fb6ae3e6fff2e17c1acd70258c861..44f2f24bd048a053313b4b1bb8cbe0ecbf3c9afd 100644 --- a/client/src/adapters/task.ts +++ b/client/src/adapters/task.ts @@ -15,6 +15,12 @@ export enum Status { SUSPENDED = 'suspended' } +export const StatusColors = new Map<string, string>([ + ['open', 'lightblue'], + ['closed', 'purple'], + ['suspended', 'red'] +]); + export enum Priority { LOW = 'low', MEDIUM = 'medium', @@ -114,8 +120,8 @@ interface UpdateTaskBody { name?: string; text?: string; icon?: string; - priority?: string; - status?: string; + priority?: Priority; + status?: Status; remove_dependencies?: Array<string>; remove_requirements?: Array<string>; remove_assigned?: Array<string>; diff --git a/client/src/adapters/team.ts b/client/src/adapters/team.ts index d9b4c609eb1ecaea5e7c9f8ed8c59dd7c2e2ed47..19c9b97fd16b4453ead69e7d44e00a7608145d69 100644 --- a/client/src/adapters/team.ts +++ b/client/src/adapters/team.ts @@ -61,7 +61,12 @@ export function getTeamActivity(uuid: string, from: Date = new Date(0), to: Date export function getTeamCompletion(uuid: string, from: Date = new Date(0), to: Date = new Date()): Promise<Completion> { return executeApiGet( `team/${uuid}/completion?since=${from.getTime()}&to=${to.getTime()}`, - ({ completion }) => completion, "Failed to get team completion" + ({ completion }) => ({...completion, sum: ( + completion.open + + completion.closed + + completion.suspended + + completion.overdue + )}), "Failed to get team completion" ); } diff --git a/client/src/adapters/user.ts b/client/src/adapters/user.ts index aad7287ced6d5446de58cb603d50e9faf534c8f3..4e23c2352e949a3d3749a487db2286e55f3b7eb7 100644 --- a/client/src/adapters/user.ts +++ b/client/src/adapters/user.ts @@ -73,6 +73,12 @@ export function updateUser(user: { realname?: string, email?: string }) { return executeApiPut(`user`, user, () => {}, "Failed to update user"); } +export function updateUserImage(image: File) { + const data = new FormData(); + data.append("image", image); + return executeApiPut(`user/image`, data, () => {}, "Failed to update user"); +} + export function getUserImageUri(uuid: string): string { return `${apiRoot}/user/${uuid}/image`; } diff --git a/client/src/adapters/util.ts b/client/src/adapters/util.ts index db9ea4afb1f9c3b7e39a3847782c6c7bc08cf4ea..dcaa7e1650ef645717ef6b5ed0dd98f79baf8d5f 100644 --- a/client/src/adapters/util.ts +++ b/client/src/adapters/util.ts @@ -13,6 +13,7 @@ export interface Completion { closed: number, suspended: number, overdue: number, + sum?: number } async function executeApiRequest<T>(path: string, method: string, body: any, onSuccess: (data: any) => T, errorMessage: string): Promise<T> { @@ -21,7 +22,11 @@ async function executeApiRequest<T>(path: string, method: string, body: any, onS method: method, headers: { ...getAuthHeader(), - ...(body ? { 'Content-Type': 'application/json' } : { }), + ...(body ? ( + body instanceof FormData + ? { 'Content-Type': 'multipart/form-data' } + : { 'Content-Type': 'application/json' }) + : { }), }, body: body ? JSON.stringify(body) : undefined, }); diff --git a/client/src/components/forms/ProjectForm/index.tsx b/client/src/components/forms/ProjectForm/index.tsx index bdb0789de0bf61ad12ed93cb9fbb8e38bde6ac1c..1ada5d19ddccf3572bc8ef32d49aa50dedd9ef97 100644 --- a/client/src/components/forms/ProjectForm/index.tsx +++ b/client/src/components/forms/ProjectForm/index.tsx @@ -9,7 +9,7 @@ import CheckboxGroup from 'components/ui/CheckboxGroup'; interface Props { project?: Project - onSubmit: (teams: string[], name: string, text: string, color: string, status?: Status, deadline?: Date) => void; + onSubmit: (teams: string[], name: string, text: string, color: string, status?: Status, deadline?: string) => void; } function validateName(name: string): string | null { @@ -44,22 +44,42 @@ function validateTeams(teams: string[]): string | null { } } +//TODO add to ryoko-moment something like that + +export function getDateString(date?: Date) { + if (date) { + let month = date.getMonth() + 1; + return date.getFullYear() + '-' + + (month / 10 < 1 ? '0' + month : month) + '-' + + (date.getDate() / 10 < 1 ? '0' + date.getDate() : date.getDate()); + } else { + return undefined; + } +} + export default function ProjectForm({ project, onSubmit }: Props) { const [name, setName] = useState(project?.name); const [text, setText] = useState(project?.text); const [status, setStatus] = useState(project?.status); const [color, setColor] = useState(project?.color); - const [deadline, setDeadline] = useState(project?.deadline); + const [deadline, setDeadline] = useState(getDateString(project?.deadline)); const [error, setError] = useState(''); const [teams, setTeams] = useState(project?.teams ?? []); const [allTeams, setAllTeams] = useState<Team[]>([]); useEffect(() => { + //TODO refactor teams.forEach((userTeam) => { getTeam(userTeam).then((team) => { - setAllTeams(state => [...state, team]); + setAllTeams(state => { + if (!state.find((t) => t.id === team.id)) { + return [...state, team]; + } + + return [...state]; + }); }); }); getTeams().then((allTeamsItems) => { @@ -68,12 +88,12 @@ export default function ProjectForm({ project, onSubmit }: Props) { if (!state.find((team) => team.id === allTeamsItem.id)) { return [...state, allTeamsItem]; } + return [...state]; }); }) }); - }, []) - + }, [teams]) const colors = Object.values(ProjectColors); const allStatus = Object.values(Status); @@ -86,6 +106,7 @@ export default function ProjectForm({ project, onSubmit }: Props) { validateColor(color ?? '') === null && validateTeams(teams) === null ) { + onSubmit?.(teams, name ?? '', text ?? '', color ?? '', status ?? Status.OPEN, deadline); } else { setError('Please fill in the mandatory fields.'); @@ -128,7 +149,7 @@ export default function ProjectForm({ project, onSubmit }: Props) { <TextInput label="Deadline" name="text" - defaultText={project?.deadline?.toString()} + defaultText={deadline} onChange={setDeadline} type="date" /> @@ -139,14 +160,15 @@ export default function ProjectForm({ project, onSubmit }: Props) { { status && - <select onChange={(e) => { - let currentStatus = Object.values(Status).find(s => s === e.target.value) ?? Status.OPEN; + <select defaultValue={project?.status} onChange={(e) => { + let currentStatus = Object.values(Status).find(s => s === e.target.value) ?? undefined; setStatus(currentStatus); } }> + <option value="">Please choose a status</option> { allStatus.map((s) => ( - <option selected={status === s} value={s} key={s}>{s}</option> + <option value={s} key={s}>{s}</option> )) } </select> diff --git a/client/src/components/forms/RoleForm/RoleChangeForm.tsx b/client/src/components/forms/RoleForm/RoleChangeForm.tsx index c0c4146964790a8d8d515353a9a4763e185cffb9..0a2fb297625e68d436cafbcfca1f0847007d3f73 100644 --- a/client/src/components/forms/RoleForm/RoleChangeForm.tsx +++ b/client/src/components/forms/RoleForm/RoleChangeForm.tsx @@ -2,6 +2,7 @@ import { deleteTeamRole, Team, TeamMember, TeamRole, updateTeamMember } from 'ad import { FormEvent, useCallback, useState } from 'react'; import Button from 'components/ui/Button'; import { useHistory } from 'react-router'; +import Callout from 'components/ui/Callout'; interface Props { roles: TeamRole[]; @@ -14,6 +15,7 @@ interface Props { export default function RoleForm({ roles, setEdit, member, team, setResult, setAllRoles }: Props) { const [currentRole, setRole] = useState(member?.role.id); + const [error, setError] = useState(''); const history = useHistory(); const onSubmit = useCallback(async (e: FormEvent) => { e.preventDefault(); @@ -29,16 +31,24 @@ export default function RoleForm({ roles, setEdit, member, team, setResult, setA }, [currentRole, member, team, setResult, history]); const onDelete = useCallback(async (id: string) => { - await deleteTeamRole(team.id, id); - setAllRoles((state: any) => state.filter((role: any) => role.id !== id)); + try { + await deleteTeamRole(team.id, id); + setAllRoles((state: any) => state.filter((role: any) => role.id !== id)); + } catch { + setError('There are still users assigned to this role.') + } + }, [team, setAllRoles]); return ( <form className="role-change-form" onSubmit={onSubmit}> <h2>Set the role</h2> + { + error && <Callout message={error} /> + } { roles.map((role) => ( - <div className="role-item" key={role.id}> + <label className="role-item" key={role.id} htmlFor={role.id}>{role.name} <input type="radio" name={role.id} @@ -46,15 +56,25 @@ export default function RoleForm({ roles, setEdit, member, team, setResult, setA onChange={() => setRole(role.id)} checked={currentRole === role.id} /> - <label htmlFor={role.id}>{role.name}</label> - <div onClick={() => setEdit(role)}>edit</div> - <div onClick={() => onDelete(role.id)}>delete</div> - </div> + <div className="radio-btn"></div> + <div className="actions"> + <div className="action" onClick={() => setEdit(role)}> + <span className="material-icons"> + edit + </span> + </div> + <div className="action delete" onClick={() => onDelete(role.id)}> + <span className="material-icons"> + clear + </span> + </div> + </div> + </label> ))} <div className="add-btn role-item" onClick={() => setEdit({})}> + </div> - <Button type="submit"> + <Button type="submit" className="expanded"> Save </Button> </form > diff --git a/client/src/components/forms/RoleForm/RoleEditForm.tsx b/client/src/components/forms/RoleForm/RoleEditForm.tsx index eed2ff5a6b68078a66d4f0ef37166f48fb23634a..6230f1328ea9e8ed23cbb4fc790bd676458b20a8 100644 --- a/client/src/components/forms/RoleForm/RoleEditForm.tsx +++ b/client/src/components/forms/RoleForm/RoleEditForm.tsx @@ -1,5 +1,5 @@ -import { createTeamRole, Team, TeamRole } from 'adapters/team'; +import { createTeamRole, Team, TeamRole, updateTeamRole } from 'adapters/team'; import TextInput from 'components/ui/TextInput'; import Button from 'components/ui/Button'; import { FormEvent, useCallback, useState } from 'react'; @@ -25,11 +25,22 @@ export default function RoleEditForm({ role, team, setEdit, setAllRoles }: Props e.preventDefault(); if (validateName(name) === null) { if (!role?.id) { - const role = await createTeamRole(team.id, name); - setAllRoles((state: any) => [...state, role]); + const newRole = await createTeamRole(team.id, name); + setAllRoles((state: any) => [...state, newRole]); setEdit(null); } else { - //todo edit team role + if(updateTeamRole(team.id, role.id, name)) { + setAllRoles((state: any) => { + state = state.filter((r: any) => r.id !== role.id); + return [ + ...state, + { + ...role, + name: name + } + ] + }); + } setEdit(null); } } @@ -45,9 +56,12 @@ export default function RoleEditForm({ role, team, setEdit, setAllRoles }: Props onChange={setName} validation={validateName} /> - <Button > + <Button className="expanded"> {!role?.id ? 'Create' : 'Update'} </Button> + <Button className="expanded dark" onClick={() => setEdit(null)}> + Back + </Button> </form> ) } diff --git a/client/src/components/forms/RoleForm/role-form.scss b/client/src/components/forms/RoleForm/role-form.scss index 58f44848e8b8bc594d2cd46fa6b9a7c857670563..439d5daae15956f8b1666553cb668706c04270bd 100644 --- a/client/src/components/forms/RoleForm/role-form.scss +++ b/client/src/components/forms/RoleForm/role-form.scss @@ -1,14 +1,113 @@ -@use 'styles/settings.scss' as s; +@use 'styles/settings.scss'as s; .role-change-form { + display: flex; + flex-direction: column; + .role-item { - padding: 25px; background: s.$light; border-radius: 25px; font-size: 18px; - margin: 10px 0; - label { - margin-left: 10px; + width: 100%; + margin: 8px 0; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + display: block; + position: relative; + padding: 25px 50px; + width: 400px; + + input { + padding: 25px; + display: block; + display: none; + + &:checked { + & ~ .radio-btn { + &:before { + opacity: 1; + } + } + } + } + + .actions { + display: flex; + position: absolute; + top: 0; + right: 0; + transform: translate(18px, -18px); } + + .action { + width: 36px; + height: 36px; + border-radius: 50%; + background: s.$primary; + color: s.$white; + display: flex; + justify-content: center; + align-items: center; + margin: 0 2px; + + span { + font-size: 18px; + } + + &.delete { + background-color: s.$red; + } + } + + .radio-btn { + height: 20px; + width: 20px; + background: s.$primary; + border-radius: 50%; + position: absolute; + left: 20px; + top: 50%; + transform: translateY(-50%); + + &:before { + content: ' '; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 10px; + height: 10px; + background: s.$white; + border-radius: 50%; + opacity: 0; + transition: 300ms ease; + } + } + } + + .add-btn { + height: 77px; + display: flex; + justify-content: center; + align-items: center; + font-size: 28px; + } + button { + margin-bottom: 0; + } +} + +.role-edit-form { + h2 { + margin-bottom: 40px; + } + .button { + margin-bottom: 0; + margin-top: 10px; + } + .input-element { + margin-bottom: 20px; } } \ No newline at end of file diff --git a/client/src/components/forms/TaskForm/AssigneesForm.tsx b/client/src/components/forms/TaskForm/AssigneesForm.tsx index 4fd0238ecc50d93a0ffba885668a3e3ae7d672d4..013e66134391cadfe772653647cda3a99425b8a2 100644 --- a/client/src/components/forms/TaskForm/AssigneesForm.tsx +++ b/client/src/components/forms/TaskForm/AssigneesForm.tsx @@ -11,7 +11,7 @@ interface Props { } -export default function AssgineesForm({ assignees, setAssignees, members }: Props) { +export default function AssigneesForm({ assignees, setAssignees, members }: Props) { const [possibleMembers, setPossibleMembers] = useState<possibleMember[]>([]); const [addNew, setAddNew] = useState(false); diff --git a/client/src/components/forms/TaskForm/index.tsx b/client/src/components/forms/TaskForm/index.tsx index 3955e3a36eed9336ea0393768ffa4912ca0a6a4d..e8fcaf3ab27cf78b303959cb59fc7b1d402603ee 100644 --- a/client/src/components/forms/TaskForm/index.tsx +++ b/client/src/components/forms/TaskForm/index.tsx @@ -1,4 +1,4 @@ -import { Priority, Task, TaskAssignment, TaskRequirement } from 'adapters/task'; +import { Priority, Status, Task, TaskAssignment, TaskRequirement } from 'adapters/task'; import { FormEvent, useCallback, useEffect, useState } from 'react'; import './task-form.scss'; import Callout from 'components/ui/Callout'; @@ -13,7 +13,7 @@ import Button from 'components/ui/Button'; interface Props { task?: Task; - onSubmit: (name: string, text: string, icon: string, priority: Priority, dependencies: string[], requirements: TaskRequirement[], assignees: TaskAssignment[]) => void; + onSubmit: (name: string, text: string, icon: string, priority: Priority, dependencies: string[], requirements: TaskRequirement[], assignees: TaskAssignment[], status?: Status) => void; project: Project; } @@ -63,12 +63,15 @@ export default function TaskForm({ task, onSubmit, project }: Props) { const [text, setText] = useState(task?.text); const [icon, setIcon] = useState(task?.icon); const [priority, setPriority] = useState(task?.priority); + const [status, setStatus] = useState(task?.status); const [error, setError] = useState(''); - const [tasks, setTasks] = useState(task?.dependencies); + const [tasks, setTasks] = useState(task?.dependencies ?? []); + const [requirements, setRequirements] = useState(task?.requirements ?? []); const [assignees, setAssignees] = useState(task?.assigned ?? []); const allPriorities = Object.values(Priority); + const allStatus = Object.values(Status); const [allTasks, setAllTasks] = useState<Task[]>([]); const [allRoles, setAllRoles] = useState<possibleRole[]>([]); const [allMembers, setAllMembers] = useState<possibleMember[]>([]); @@ -80,39 +83,39 @@ export default function TaskForm({ task, onSubmit, project }: Props) { project.teams.forEach((teamId) => { getTeam(teamId).then(team => { getTeamRoles(teamId).then((roles) => { - setAllRoles(roles.map(role => { + setAllRoles(state => [...state, ...roles.map(role => { return { id: role.id, label: team.name + ': ' + role.name } - })); + })]); }) getTeamMembers(teamId).then((members) => { - setAllMembers(members.map(member => { + setAllMembers(state => [...state, ...members.map(member => { return { id: member.id, label: team.name + ': ' + (member.realname ?? member.username) } - })); + })]); }) }) }) - }, []); + }, [task, project]); const handleSubmit = useCallback(async (e: FormEvent) => { e.preventDefault(); - + if (validateName(name ?? '') === null && validateText(text ?? '') === null && validateIcon(icon ?? '') === null && validatePriority(priority ?? '') === null ) { - onSubmit?.(name ?? '', text ?? '', icon ?? '', priority ?? Priority.LOW, tasks ?? [], requirements, assignees); + onSubmit?.(name ?? '', text ?? '', icon ?? '', priority ?? Priority.LOW, tasks ?? [], requirements, assignees, status); } else { setError('Please fill in the mandatory fields.'); } - }, [onSubmit, setError, name, text, priority, icon, tasks, assignees, requirements]); + }, [onSubmit, setError, name, text, priority, icon, tasks, assignees, requirements, status]); return ( <form className="task-form" onSubmit={handleSubmit}> @@ -133,10 +136,11 @@ export default function TaskForm({ task, onSubmit, project }: Props) { type="textarea" /> - <select onChange={(e) => { - let currentPriority = Object.values(Priority).find(s => s === e.target.value) ?? Priority.LOW; + <select defaultValue={priority} onChange={(e) => { + let currentPriority = Object.values(Priority).find(s => s === e.target.value) ?? undefined; setPriority(currentPriority); }}> + <option value={''}>Please choose a priority</option> { allPriorities.map((prio) => ( <option value={prio} key={prio}>{prio}</option> @@ -144,7 +148,24 @@ export default function TaskForm({ task, onSubmit, project }: Props) { } </select> - <Picker onEmojiClick={(e, emoji) => setIcon(emoji.originalUnified)} /> + { + status && ( + <select defaultValue={status} onChange={(e) => { + let currentStatus = Object.values(Status).find(s => s === e.target.value) ?? undefined; + setStatus(currentStatus); + }}> + <option value={''}>Please choose a status</option> + { + allStatus.map((status) => ( + <option value={status} key={status}>{status}</option> + )) + } + </select> + ) + } + Chosen emoji: {icon}<br /> + <Picker onEmojiClick={(e, emoji) => setIcon(emoji.emoji)} /> + <h2>Dependencies</h2> { allTasks.length > 0 ? ( @@ -153,7 +174,7 @@ export default function TaskForm({ task, onSubmit, project }: Props) { } { allRoles.length > 0 && ( - <RequirementsForm setRequirements={setRequirements} roles={allRoles} requirements={requirements ?? []} /> + <RequirementsForm setRequirements={setRequirements} roles={allRoles} requirements={requirements} /> ) } { diff --git a/client/src/components/forms/UserForm/index.tsx b/client/src/components/forms/UserForm/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..898c17898a20b641b0d2ec01451ac47d87f8151e --- /dev/null +++ b/client/src/components/forms/UserForm/index.tsx @@ -0,0 +1,61 @@ +import { User } from 'adapters/user'; +import { FormEvent, useCallback, useState } from 'react'; +import './user-form.scss'; +import TextInput from 'components/ui/TextInput'; +import Button from 'components/ui/Button'; + +interface Props { + onSubmit?: (name?: string, email?: string,) => void; + user: User +} + +function validateEmail(email?: string): string | null { + if (email && email.length > 0) { + if (email.match('^[^\\s]+@[^\\s]+$')) { + return null; + } else { + return 'Please enter a valid email or let this field blank.' + } + } else { + return null; + } +} + +export default function UserForm({ user, onSubmit }: Props) { + const [name, setName] = useState(user.realname); + const [email, setEmail] = useState(user.email); + const handleSubmit = useCallback(async (e: FormEvent) => { + e.preventDefault(); + if (validateEmail(email) === null) { + onSubmit?.(name, email); + } + }, [onSubmit, name, email]); + return ( + <form onSubmit={handleSubmit} className="user-form"> + <div className="fields"> + <TextInput + label="Real Name" + name="name" + onChange={setName} + defaultText={name} + /> + <TextInput + label="Email address" + name="name" + validation={validateEmail} + onChange={setEmail} + defaultText={email} + /> + <div className="avatar-upload"> + <div className="label">Avatar</div> + <label htmlFor="avatar" className="avatar-field"> + <input type="file" id="avatar" name="avatar" /> + </label> + </div> + </div> + <Button type="submit" className="expanded"> + Save + </Button> + </form > + ) +} \ No newline at end of file diff --git a/client/src/components/forms/UserForm/user-form.scss b/client/src/components/forms/UserForm/user-form.scss new file mode 100644 index 0000000000000000000000000000000000000000..9e478c136507f6e527dc36398a3409c7da65c678 --- /dev/null +++ b/client/src/components/forms/UserForm/user-form.scss @@ -0,0 +1,47 @@ +@use 'styles/settings.scss'as s; +@use 'styles/mixins.scss'as mx; + +.user-form { + .fields { + display: flex; + flex-wrap: wrap; + margin: -10px; + + @include mx.breakpoint(medium) { + margin: -10px; + } + + &>* { + @include mx.breakpoint(medium) { + padding: 10px; + } + } + } + + .input-element { + width: 50%; + margin-bottom: 20px; + } + + .avatar-upload { + width: 100%; + position: relative; + .label { + position: absolute; + top: -2px; + left: 30px; + font-weight: s.$weight-bold; + } + .avatar-field { + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; + border-radius: 25px; + width: 100%; + height: 80px; + margin-bottom: 20px; + background: s.$light; + } + } +} \ No newline at end of file diff --git a/client/src/components/graphs/BarChart/bar-chart.scss b/client/src/components/graphs/BarChart/bar-chart.scss index 67be2e837d66cdba7be7bbf5b8a6bb6fd9498b82..65a120c9cc213336e9940dedf5b0f4477877f17b 100644 --- a/client/src/components/graphs/BarChart/bar-chart.scss +++ b/client/src/components/graphs/BarChart/bar-chart.scss @@ -6,6 +6,14 @@ padding: 50px; background: s.$white; border-radius: 10px; + + .error-screen { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + } .bar-chart { display: flex; diff --git a/client/src/components/graphs/BarChart/index.tsx b/client/src/components/graphs/BarChart/index.tsx index 183440331f3a8336c3d05f656df34fbbafa77d0d..ae479fa1403e863061e692c492e7efc6dffc63de 100644 --- a/client/src/components/graphs/BarChart/index.tsx +++ b/client/src/components/graphs/BarChart/index.tsx @@ -1,6 +1,6 @@ import './bar-chart.scss'; -interface ChartItem { +export interface ChartItem { label: string; value: number; } @@ -13,20 +13,26 @@ export default function BarChart({ data }: Props) { let maxValue = data.map(e => e.value).sort((a, b) => b - a)[0]; return ( <div className="bar-chart-container"> - <div className="bar-chart"> - { - data.map((item) => ( - <div key={item.label} className="bar" style={{ - height: (item.value / maxValue) * 100 + '%', - width: 'calc(' + 100 / data.length + '% - 10px)' - }}> - <div className="label"> - {item.label} - </div> - </div> - )) - } - </div> + { + data.length > 0 ? ( + <div className="bar-chart"> + { + data.map((item) => ( + <div key={item.label} className="bar" style={{ + height: (item.value / maxValue) * 100 + '%', + width: 'calc(' + 100 / data.length + '% - 10px)' + }}> + <div className="label"> + {item.label} + </div> + </div> + )) + } + </div> + ) : ( + <div className="error-screen">No activity recently</div> + ) + } </div> ); diff --git a/client/src/components/helpers/ProtectedRoute.tsx b/client/src/components/helpers/ProtectedRoute.tsx index dac6c771e03153dd309a82bd0732a70a4732dfec..2f24bc39f6512a73021db0e785c9be07533fea95 100644 --- a/client/src/components/helpers/ProtectedRoute.tsx +++ b/client/src/components/helpers/ProtectedRoute.tsx @@ -1,29 +1,34 @@ import { Route, RouteProps, useHistory } from 'react-router-dom'; import { isLoggedIn } from 'adapters/auth'; -import { useEffect } from 'react'; -import { getTeams } from 'adapters/team'; +import { useEffect, useState } from 'react'; +import { getTeams, Team } from 'adapters/team'; +import LoadingScreen from 'components/ui/LoadingScreen'; export default function ProtectedRoute(props: RouteProps) { const history = useHistory(); + const [team, setTeam] = useState<Team[]>(); - useEffect(() => { - if (!isLoggedIn()) { - history.push('/login'); - } - }) - + if (!isLoggedIn()) { + history.push('/login'); + } useEffect(() => { getTeams().then((teams) => { - if(teams.length <= 0) { - history.push('/introduction'); - } + setTeam(teams); }).catch(() => { - history.push('/introduction'); + }); - }); + }, []) + + if (team && isLoggedIn()) { + if (team.length === 0) { + history.push('/introduction'); + } else { + return <Route {...props} /> + } + } return ( - <Route {...props} /> + <LoadingScreen /> ); } diff --git a/client/src/components/layout/CommentList/index.tsx b/client/src/components/layout/CommentList/index.tsx index 8d81fad58b317e337da60f9e71e7b8b77a8fb571..6d92c08841255e73046f7d77948b124d55732189 100644 --- a/client/src/components/layout/CommentList/index.tsx +++ b/client/src/components/layout/CommentList/index.tsx @@ -1,36 +1,57 @@ import Comment, { CommentProps } from 'components/ui/Comment'; -import { User } from 'adapters/user'; -import avatar from 'images/roland-bernard.jpg'; +import { getCurrentUser, User } from 'adapters/user'; +import Avatar from 'components/ui/Avatar'; import './comment-list.scss'; +import { FormEvent, useCallback, useEffect, useState } from 'react'; +import { createComment } from 'adapters/comment'; +import { useHistory } from 'react-router'; interface Props { - user: User; comments: CommentProps[] + taskId: string; } -export default function CommentList({ comments, user }: Props) { +export default function CommentList({ comments, taskId }: Props) { + + const [user, setUser] = useState<User>(); + const [comment, setComment] = useState<string>(''); + const history = useHistory(); + useEffect(() => { + getCurrentUser().then((user) => setUser(user)); + }, []); + + const handleSubmit = useCallback((e: FormEvent) => { + e.preventDefault(); + if(comment.length > 0) { + if(createComment({task: taskId, text: comment})) { + history.go(0); + } + } + }, [comment, taskId, history]) + return ( <div className="comment-list"> - <div className="add-comment comment-container"> - <div className="head"> - <div className="avatar"> - <img src={avatar} alt={user.realname} /> - </div> - <div className="user-info"> - <div className="name"> - {user.realname} + { + user && ( + <div className="add-comment comment-container"> + <div className="head"> + <Avatar user={user} /> + <div className="user-info"> + <div className="name"> + {user.realname ?? user.username} + </div> + </div> </div> + <form onSubmit={handleSubmit}> + <textarea placeholder="Write a comment..." onChange={(e) => setComment(e.target.value)}></textarea> + <button type="submit">Send</button> + </form> </div> - </div> - <form action=""> - <textarea placeholder="Write a comment..."></textarea> - <button type="submit">Send</button> - </form> - - </div> + ) + } {comments.map(comment => ( - <Comment key={comment.comment} {...comment} /> + <Comment key={comment.comment.id} {...comment} /> ))} </div> diff --git a/client/src/components/layout/CompletionGrid/index.tsx b/client/src/components/layout/CompletionGrid/index.tsx index 784f65dabb2d553dadcdca20a6f9e03cb07f9be5..470f5ce3db3f3630a880667fc6d59c1d1bec0dce 100644 --- a/client/src/components/layout/CompletionGrid/index.tsx +++ b/client/src/components/layout/CompletionGrid/index.tsx @@ -9,8 +9,8 @@ export default function CompletionGrid({ items }: Props) { return ( <div className="completion-grid"> {items.map(item => ( - <div className="box-container"> - <Completion key={item.label} {...item} /> + <div key={item.label} className="box-container"> + <Completion {...item} /> </div> ))} </div> diff --git a/client/src/components/layout/MemberList/index.tsx b/client/src/components/layout/MemberList/index.tsx index c2c98b692f30b4d26da70aa8ae2ed9663f85c780..31669e699b6afe709d24c94b8827cabf940498eb 100644 --- a/client/src/components/layout/MemberList/index.tsx +++ b/client/src/components/layout/MemberList/index.tsx @@ -25,9 +25,12 @@ export default function MemberList({ members, addContent }: Props) { + </div> } - {members.map((member) => ( + + {members.length > 0 ? members.map((member) => ( <TeamMember key={member.user.id} {...member} /> - ))} + )) : ( + <div>No user found.</div> + )} </div> </> ); diff --git a/client/src/components/layout/TaskList/index.tsx b/client/src/components/layout/TaskList/index.tsx index 50b11d01c6634d4823f11d7c2a77d8d3446c07f8..4ec864815d052063e00803bcba5e615256852180 100644 --- a/client/src/components/layout/TaskList/index.tsx +++ b/client/src/components/layout/TaskList/index.tsx @@ -1,10 +1,9 @@ import './task-list.scss'; -import { Task as ITask } from 'adapters/task'; -import Task from 'components/ui/Task'; +import Task, { TaskProps } from 'components/ui/Task'; import { Link } from 'react-router-dom'; interface Props { - tasks: ITask[] + tasks: TaskProps[] addButton?: boolean } @@ -20,7 +19,7 @@ export default function TaskList({ tasks, addButton }: Props) { } { tasks.map(task => ( - <Task key={task.id} task={task} /> + <Task key={task.task.id} {...task} /> )) } </div> diff --git a/client/src/components/navigation/Dropdown/dropdown.scss b/client/src/components/navigation/Dropdown/dropdown.scss index ca163994068c74fde37c5cfdc9b2d027e1369f74..6e47e7cee3464e7d89db5fd0bd3d7506a103a5c4 100644 --- a/client/src/components/navigation/Dropdown/dropdown.scss +++ b/client/src/components/navigation/Dropdown/dropdown.scss @@ -44,6 +44,10 @@ box-shadow: 0 0 5px rgba(s.$black, 0.05); visibility: hidden; opacity: 0; + &.right { + right: 0; + left: auto; + } .dropdown-item { diff --git a/client/src/components/navigation/Dropdown/index.tsx b/client/src/components/navigation/Dropdown/index.tsx index 30438a53353d6e0ea25cb5ecb4fe51524840c760..5d5640b89b27da320ed1fc679d414beb73961466 100644 --- a/client/src/components/navigation/Dropdown/index.tsx +++ b/client/src/components/navigation/Dropdown/index.tsx @@ -6,15 +6,16 @@ import './dropdown.scss'; export interface DropDownItem { label: string; route?: string; - popupContent?: ReactNode + popupContent?: ReactNode; } interface Props { children: ReactNode; items: DropDownItem[] + position?: 'left'|'right'; } -export default function Dropdown({ children, items }: Props) { +export default function Dropdown({ children, items, position }: Props) { const [isOpen, setOpen] = useState(false); const [openPopup, setOpenPopup] = useState<string | null>(null); return ( @@ -24,7 +25,7 @@ export default function Dropdown({ children, items }: Props) { {children} </div> {items.length > 0 && ( - <div className="dropdown"> + <div className={'dropdown ' + (position ?? '')}> { items.map((item) => (item.route && ( diff --git a/client/src/components/navigation/Sidebar/index.tsx b/client/src/components/navigation/Sidebar/index.tsx index 2d26b4727be9196bbab0b9522a5eda6fedf76695..a0ec8d50e4666532974e62505987e03ea25c3632 100644 --- a/client/src/components/navigation/Sidebar/index.tsx +++ b/client/src/components/navigation/Sidebar/index.tsx @@ -1,11 +1,12 @@ import Navigation from 'components/navigation/Navigation'; import Avatar from 'components/ui/Avatar'; -import LineGraph from 'components/graphs/LineGraph'; import { NavLink, useHistory } from 'react-router-dom'; -import { clearToken } from 'adapters/auth'; +import { clearToken, isLoggedIn } from 'adapters/auth'; import './sidebar.scss'; import { useEffect, useState } from 'react'; -import { getCurrentUser, User } from 'adapters/user'; +import { getCurrentUser, getUserActivity, User } from 'adapters/user'; +import BarChart, { ChartItem } from 'components/graphs/BarChart'; +import LoadingScreen from 'components/ui/LoadingScreen'; interface Props { mobileShown: boolean; @@ -14,11 +15,20 @@ interface Props { export default function Sidebar({ mobileShown, setMobileShown }: Props) { const [user, setUser] = useState<User>(); + const [activity, setActivity] = useState<ChartItem[]>(); useEffect(() => { - getCurrentUser().then((user) => { - setUser(user); - }).catch(() => { }); + if (isLoggedIn()) { + getCurrentUser().then((user) => { + setUser(user); + getUserActivity().then((a) => { + setActivity(a.map(item => ({ + label: item.day, + value: item.time + }))); + }); + }).catch(() => { }); + } }, []) const history = useHistory(); @@ -32,7 +42,7 @@ export default function Sidebar({ mobileShown, setMobileShown }: Props) { <aside className={'site-aside' + (mobileShown ? ' shown' : '')}> <div className="top"> <div className="profile"> - <Avatar user={user}/> + <Avatar user={user} /> <span className="name">{user?.realname ?? user?.username}</span> {user?.realname && <span className="username">{user?.username}</span>} </div> @@ -58,10 +68,14 @@ export default function Sidebar({ mobileShown, setMobileShown }: Props) { </button> </nav> </div> - <div className="stats"> - <LineGraph /> - <div className="comment">You are doing well!</div> - </div> + { + activity ? ( + <div className="stats"> + <BarChart data={activity} /> + <div className="comment">You are doing well!</div> + </div> + ) : <LoadingScreen /> + } </aside> ); } \ No newline at end of file diff --git a/client/src/components/navigation/Sidebar/sidebar.scss b/client/src/components/navigation/Sidebar/sidebar.scss index f3d753d867c7825478eee4d8e80d71343a27eba7..6ea9a97193d2bc6e1cb6e06c18b344d6b09a15c6 100644 --- a/client/src/components/navigation/Sidebar/sidebar.scss +++ b/client/src/components/navigation/Sidebar/sidebar.scss @@ -136,9 +136,8 @@ font-size: fn.toRem(14); font-weight: s.$weight-semi-bold; - .line-graph { - width: 100%; - margin-bottom: 10px; + .bar-chart-container { + background: transparent; } } } \ No newline at end of file diff --git a/client/src/components/ui/CheckboxGroup/index.tsx b/client/src/components/ui/CheckboxGroup/index.tsx index d04537166a7d9083f00ff4b758ec114bb305001b..47a9cf90c295fcb0552a87bf5fa58987daa6b12a 100644 --- a/client/src/components/ui/CheckboxGroup/index.tsx +++ b/client/src/components/ui/CheckboxGroup/index.tsx @@ -15,7 +15,7 @@ export default function CheckboxGroup({ choices, chosen, setChosen }: Props) { <div className="team-item" key={choice.id}> <input type="checkbox" id={choice.id} checked={chosen.indexOf(choice.id) >= 0} - onClick={(e) => { + onChange={(e) => { if (chosen.find(id => choice.id === id)) { setChosen((state: any) => state.filter((id: any) => id !== choice.id)); } else { diff --git a/client/src/components/ui/Comment/index.tsx b/client/src/components/ui/Comment/index.tsx index e0bfe55144f0aa3fd747bbd436b6a040195f2379..38f6ddbbe339b87b1ff61838178f64333774de53 100644 --- a/client/src/components/ui/Comment/index.tsx +++ b/client/src/components/ui/Comment/index.tsx @@ -1,31 +1,41 @@ import './comment.scss'; -import { User } from 'adapters/user'; -import avatar from 'images/roland-bernard.jpg'; +import { getUser, User } from 'adapters/user'; +import { Comment as IComment } from 'adapters/comment'; +import { useEffect, useState } from 'react'; +import Avatar from 'components/ui/Avatar'; + export interface CommentProps { - comment: string; - user: User; + comment: IComment; } -export default function Comment({ comment, user }: CommentProps) { - return ( - <div className="comment-container"> - <div className="head"> - <div className="avatar"> - <img src={avatar} alt={user.realname} /> - </div> - <div className="user-info"> - <div className="name"> - {user.realname} +export default function Comment({ comment }: CommentProps) { + + const [user, setUser] = useState<User>(); + useEffect(() => { + getUser(comment.user).then((user) => setUser(user)); + }, [comment]); + + if (user) { + return ( + <div className="comment-container"> + <div className="head"> + <Avatar user={user} /> + <div className="user-info"> + <div className="name"> + {user.realname ?? user.username} + </div> + <div className="time"> + 10 years ago </div> - <div className="time"> - 10 years ago </div> </div> + <div className="comment"> + {comment.text} + </div> </div> - <div className="comment"> - {comment} - </div> - </div> - ) + ) + } else { + return <>Loading</> + } } \ No newline at end of file diff --git a/client/src/components/ui/Completion/index.tsx b/client/src/components/ui/Completion/index.tsx index 08ad531b6e8c6a4eda55a6593eb18e2f868fc0f4..ebaf7b7a2b4bf21f9826d95e8dcb5f9121eed966 100644 --- a/client/src/components/ui/Completion/index.tsx +++ b/client/src/components/ui/Completion/index.tsx @@ -4,14 +4,15 @@ import CircularProgress from 'components/graphs/CircularProgress'; export interface CompletionProps { label: string; percent: number; + color: string; } -export default function Completion({ label, percent }: CompletionProps) { +export default function Completion({ label, percent, color }: CompletionProps) { return ( <div className="completion"> <div className="inner"> - <CircularProgress percent={percent} /> + <CircularProgress percent={Math.floor(percent)} color={color} /> <div className="label">{label}</div> </div> </div> diff --git a/client/src/components/ui/LoadingScreen/index.tsx b/client/src/components/ui/LoadingScreen/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f795dd67a0ce79c5037a7d97ba132e7073fafab8 --- /dev/null +++ b/client/src/components/ui/LoadingScreen/index.tsx @@ -0,0 +1,23 @@ +import './loading-screen.scss'; +export default function LoadingScreen() { + return ( + <div className="loading-screen"> + <svg version="1.1" id="loader-1" xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" x="0px" y="0px" + width="40px" height="40px" viewBox="0 0 40 40" enableBackground="new 0 0 40 40" xmlSpace="preserve"> + <path opacity="0.2" fill="gray" d="M20.201,5.169c-8.254,0-14.946,6.692-14.946,14.946c0,8.255,6.692,14.946,14.946,14.946 + s14.946-6.691,14.946-14.946C35.146,11.861,28.455,5.169,20.201,5.169z M20.201,31.749c-6.425,0-11.634-5.208-11.634-11.634 + c0-6.425,5.209-11.634,11.634-11.634c6.425,0,11.633,5.209,11.633,11.634C31.834,26.541,26.626,31.749,20.201,31.749z"/> + <path fill="gray" d="M26.013,10.047l1.654-2.866c-2.198-1.272-4.743-2.012-7.466-2.012h0v3.312h0 + C22.32,8.481,24.301,9.057,26.013,10.047z"> + <animateTransform attributeType="xml" + attributeName="transform" + type="rotate" + from="0 20 20" + to="360 20 20" + dur="0.5s" + repeatCount="indefinite" /> + </path> + </svg> + </div> + ) +} \ No newline at end of file diff --git a/client/src/components/ui/LoadingScreen/loading-screen.scss b/client/src/components/ui/LoadingScreen/loading-screen.scss new file mode 100644 index 0000000000000000000000000000000000000000..9967de5dc0dd774cabc6d7e96b83232425899caf --- /dev/null +++ b/client/src/components/ui/LoadingScreen/loading-screen.scss @@ -0,0 +1,8 @@ +.loading-screen { + width: 100%; + height: 100%; + min-height: 100px; + display: flex; + justify-content: center; + align-items: center; +} \ No newline at end of file diff --git a/client/src/components/ui/Popup/popup.scss b/client/src/components/ui/Popup/popup.scss index 9071c64fcdfa2caccbdc3fb14196cb66282b470f..3966dda9c2cd8a5acc2b38b4a16b79a0bd70920e 100644 --- a/client/src/components/ui/Popup/popup.scss +++ b/client/src/components/ui/Popup/popup.scss @@ -12,9 +12,10 @@ align-items: center; .popup { + animation: moveup 300ms ease; max-height: 100vh; overflow: auto; - padding: 50px; + padding: 75px 100px; max-width: 960px; background: s.$white; border-radius: 25px; @@ -26,6 +27,29 @@ position: absolute; width: 100%; height: 100%; - background: rgba(s.$black, 0.75); + animation: appear 300ms ease; + background: rgba(s.$black, 0.5); + } +} + +@keyframes moveup { + from { + transform: translateY(50px); + opacity: 0.5; + } + + to { + transform: translateY(0); + opacity: 1; + } +} + +@keyframes appear { + from { + opacity: 0; + } + + to { + opacity: 1; } } \ No newline at end of file diff --git a/client/src/components/ui/Project/index.tsx b/client/src/components/ui/Project/index.tsx index 7f785049166b5fa25176c45d56545c30d10a97da..d0ab1a673dd6a48ef0fb495ff9171a9455476c48 100644 --- a/client/src/components/ui/Project/index.tsx +++ b/client/src/components/ui/Project/index.tsx @@ -2,10 +2,12 @@ import CircularProgress from 'components/graphs/CircularProgress'; import AssigneeList from 'components/ui/AssigneeList'; import { AssignedUser } from 'adapters/user'; -import { getProjectAssignees, Project as IProject } from 'adapters/project'; +import { getProjectAssignees, getProjectCompletion, Project as IProject } from 'adapters/project'; import './project.scss'; import { Link } from 'react-router-dom'; import { useEffect, useState } from 'react'; +import { Completion } from 'adapters/util'; +import LoadingScreen from '../LoadingScreen'; export interface ProjectProps { project: IProject @@ -13,18 +15,26 @@ export interface ProjectProps { export default function Project({ project }: ProjectProps) { const [assignees, setAssignees] = useState<AssignedUser[]>([]); + const [completion, setCOmpletion] = useState<Completion>(); useEffect(() => { getProjectAssignees(project.id).then((assignee) => setAssignees(assignee)) - }, []); - + getProjectCompletion(project.id).then((completion) => setCOmpletion(completion)); + }, [project]); + return ( <Link to={'/projects/' + project.id} className="project"> <div className="content"> - <CircularProgress percent={75} color={project.color} /> + { + completion ? ( + <CircularProgress percent={completion.closed / (completion.sum ?? 1) * 100 } color={project.color} /> + ) : ( + <LoadingScreen /> + ) + } <div className="title">{project.name}</div> <div className="details"> {project.deadline && ( - <div className="range">{project.deadline}</div> + <div className="range">{project.deadline.getDate()}</div> )} <AssigneeList assignees={assignees} max={3} /> </div> diff --git a/client/src/components/ui/Task/index.tsx b/client/src/components/ui/Task/index.tsx index 7a9f9f97cb41cb1570d813ab4b78b0487f472da8..8a2725f52994c447724b3915b92945f05f6a1271 100644 --- a/client/src/components/ui/Task/index.tsx +++ b/client/src/components/ui/Task/index.tsx @@ -5,35 +5,31 @@ import { Task as ITask } from 'adapters/task'; import { useEffect, useState } from 'react'; import { getUser, User } from 'adapters/user'; -interface Props { - task: ITask, +export interface TaskProps { + task: ITask; + color?: string; + subtitle?: string; } -function formattedTime(date: Date) { - return date.getHours() + ':' + (date.getMinutes() < 10 ? '0' + date.getMinutes() : date.getMinutes()); -} - -export default function Task({ task }: Props) { +export default function Task({ task, color, subtitle }: TaskProps) { const [assignees, setAssignees] = useState<User[]>([]); - const start = new Date(200); - const end = new Date(300); useEffect(() => { task.assigned.forEach((assign) => { getUser(assign.user).then((user) => setAssignees(state => [...state, user])).catch(() => {}) }) - }, []); + }, [task]); return ( <Link to={'/tasks/' + task.id} className="task"> - <div className="project-indicator"></div> + <div className={'indicator' + (color ? ' bg-gradient-' + color : '')}></div> <div className="main-info"> <div className="icon-container"> - {String.fromCharCode(parseInt(task.icon, 16))} + {task.icon} </div> <div className="text-container"> <h4>{task.name}</h4> - <div className="time">{formattedTime(start)} - {formattedTime(end)}</div> + <div className="time">{subtitle}</div> </div> </div> <div className="description-container"> diff --git a/client/src/components/ui/Task/task.scss b/client/src/components/ui/Task/task.scss index 9f8f9be80fa50a7ba46808d9ea08a197ead18ba7..5aaca98d4f686495f6673377f51df27bbfab12a0 100644 --- a/client/src/components/ui/Task/task.scss +++ b/client/src/components/ui/Task/task.scss @@ -21,15 +21,18 @@ } } - .project-indicator { + .indicator { position: absolute; left: 0; top: 50%; transform: translate(-50%, -50%); - background: s.$primary; width: 6px; height: 50%; border-radius: 3px; + + &:not([class*=bg-gradient]) { + background: s.$primary; + } } diff --git a/client/src/components/ui/TeamMember/index.tsx b/client/src/components/ui/TeamMember/index.tsx index 01103c90851f74f1a796bd3b59c8a0d236f859c1..6fd12f0d32f5016f88ec0374fa57ffae6b09c521 100644 --- a/client/src/components/ui/TeamMember/index.tsx +++ b/client/src/components/ui/TeamMember/index.tsx @@ -14,12 +14,12 @@ export default function TeamMember({ user, info, settings }: TeamMemberProps) { <div className="team-member-item"> <Avatar user={user} /> <div className="details"> - <div className="name">{user.username}</div> + <div className="name">{user.realname ?? user.username}</div> <div className="info">{info}</div> </div> { settings && - <Dropdown items={settings}> + <Dropdown items={settings} position="right"> <div className="settings"> <span className="material-icons icon"> expand_more diff --git a/client/src/components/ui/TextInput/index.tsx b/client/src/components/ui/TextInput/index.tsx index 15c5269b0dff087794dc3e422b2deedf676c950d..0b2da1eabb62998d97d8f16f647e9408b970ee75 100644 --- a/client/src/components/ui/TextInput/index.tsx +++ b/client/src/components/ui/TextInput/index.tsx @@ -1,6 +1,5 @@ -import { ChangeEvent, Dispatch, FocusEvent, useCallback, useState } from "react"; - +import { ChangeEvent, FocusEvent, useCallback, useState } from "react"; import './text-input.scss'; interface Props { @@ -17,12 +16,8 @@ export default function TextInput({ label, name, type, onChange, validation, com const [error, setError] = useState(''); const handleChange = useCallback((e: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => { - if (type === 'date') { - onChange(new Date(e.target.value)); - } else { - onChange(e.target.value); - } - }, [onChange, type]); + onChange(e.target.value); + }, [onChange]); const handleBlur = useCallback(async (e: FocusEvent<HTMLTextAreaElement | HTMLInputElement>) => { let error = await validation?.(e.target.value, compareValue ?? ''); diff --git a/client/src/components/ui/TextInput/text-input.scss b/client/src/components/ui/TextInput/text-input.scss index 84b175c7684c6e1c54d4fa43d23cca5442d78e19..1c080ee2068aa0a324403ee44695ef2c4e4d2b8a 100644 --- a/client/src/components/ui/TextInput/text-input.scss +++ b/client/src/components/ui/TextInput/text-input.scss @@ -13,7 +13,6 @@ .input-field { position: relative; - padding: 0 5px; width: 100%; &.mandatory { label { diff --git a/client/src/pages/AppWrapper.tsx b/client/src/pages/AppWrapper.tsx index a2224a5e03edc9d54d6d03db84b1f8dc18fc322e..a5dec26191f098d62f61e9d122b720fb099f5851 100644 --- a/client/src/pages/AppWrapper.tsx +++ b/client/src/pages/AppWrapper.tsx @@ -11,6 +11,7 @@ const TaskStart = lazy(() => import('pages/Tasks/TaskStart')); const ProjectDetail = lazy(() => import('pages/Projects/ProjectDetail')); const ProjectCreate = lazy(() => import('pages/Projects/ProjectCreate')); const TaskCreate = lazy(() => import('pages/Tasks/TaskCreate')); +const TaskEdit = lazy(() => import('pages/Tasks/TaskEdit')); const ProjectEdit = lazy(() => import('pages/Projects/ProjectEdit')); const Projects = lazy(() => import('pages/Projects')); const Stats = lazy(() => import('pages/Stats')); @@ -24,8 +25,9 @@ export default function AppWrapper() { <Header> <Suspense fallback={false}> <Switch> - <ProtectedRoute path="/tasks/:uuid/start" component={TaskStart} /> - <ProtectedRoute path="/tasks/:uuid" component={TaskDetail} /> + <ProtectedRoute path="/tasks/:taskId/start" component={TaskStart} /> + <ProtectedRoute path="/tasks/:taskId/edit" component={TaskEdit} /> + <ProtectedRoute path="/tasks/:taskId" component={TaskDetail} /> <ProtectedRoute path="/tasks" exact component={Tasks} /> <ProtectedRoute path="/projects/create" component={ProjectCreate} /> <ProtectedRoute path="/projects/:projectId/tasks/create" component={TaskCreate} /> diff --git a/client/src/pages/Introduction/index.tsx b/client/src/pages/Introduction/index.tsx index bb61116a6e95a8b5e44ffa809fe8757351c9d2a0..b6b19ce0a2ee534be808229b95d4649f7cec4b87 100644 --- a/client/src/pages/Introduction/index.tsx +++ b/client/src/pages/Introduction/index.tsx @@ -34,7 +34,7 @@ export default function Introduction() { <div className="content-container"> <h1 className="underlined">Welcome to ryoko</h1> - {!showForm && ( + {!showForm ? ( <div className="introduction-container"> <div className="lead-text"> You are one step away from getting started with ryoko. @@ -49,15 +49,14 @@ export default function Introduction() { Create a new Team </Button> </div> - </div>) - } - { + </div> + ) : showForm && (<> <p className="lead-text"> Create a new team with just one click by giving it a name! </p> - <TeamForm onSubmit={handleCreateTeam} onBack={() => setShowForm(false)} />) + <TeamForm onSubmit={handleCreateTeam} onBack={() => setShowForm(false)} /> </>) } </div> diff --git a/client/src/pages/Projects/ProjectCreate/index.tsx b/client/src/pages/Projects/ProjectCreate/index.tsx index 4074a872650650bd8e8a7105c7da6bc788dbe3a3..8009c4facc979a12031ce6ed41d9fa8a08b6c510 100644 --- a/client/src/pages/Projects/ProjectCreate/index.tsx +++ b/client/src/pages/Projects/ProjectCreate/index.tsx @@ -7,7 +7,7 @@ import Callout from 'components/ui/Callout'; export default function ProjectCreate() { const history = useHistory(); const [error, setError] = useState(''); - const handleSubmit = useCallback(async (teams: string[], name: string, text: string, color: string, status?: Status, deadline?: Date) => { + const handleSubmit = useCallback(async (teams: string[], name: string, text: string, color: string, status?: Status, deadline?: string) => { try { if (await createProject({ teams, name, text, color, deadline })) { history.push('/projects'); @@ -17,7 +17,6 @@ export default function ProjectCreate() { } catch (e) { } }, [history]); - return ( <div className="project-create-page"> <div className="content-container"> diff --git a/client/src/pages/Projects/ProjectDetail/ProjectDetails/index.tsx b/client/src/pages/Projects/ProjectDetail/ProjectDetails/index.tsx index 403d6bfc9099a22b454f4d52d82a56053cdb3dd9..ca48216016379c3ede87f504c729181943fccb4e 100644 --- a/client/src/pages/Projects/ProjectDetail/ProjectDetails/index.tsx +++ b/client/src/pages/Projects/ProjectDetail/ProjectDetails/index.tsx @@ -1,10 +1,11 @@ import './project-details.scss'; import DetailGrid from 'components/layout/DetailGrid'; -import BarChart from 'components/graphs/BarChart'; +import BarChart, { ChartItem } from 'components/graphs/BarChart'; import ButtonLink from 'components/navigation/ButtonLink'; -import { Project } from 'adapters/project'; +import { getProjectActivity, Project } from 'adapters/project'; import { useEffect, useState } from 'react'; import { getTeam } from 'adapters/team'; +import LoadingScreen from 'components/ui/LoadingScreen'; interface Props { project: Project @@ -12,12 +13,21 @@ interface Props { export default function ProjectDetails({ project }: Props) { const [teams, setTeams] = useState<string[]>([]); + const [activity, setActivity] = useState<ChartItem[]>([]); useEffect(() => { project.teams.forEach(teamId => { getTeam(teamId).then((team) => setTeams(prev => [...prev, team.name])); }); - }, []); + getProjectActivity(project.id).then((a) => { + setActivity(a.map(item => { + return { + label: item.day, + value: item.time + } + })); + }) + }, [project]); let details = [{ icon: 'group', @@ -33,21 +43,14 @@ export default function ProjectDetails({ project }: Props) { }); } - const data = [ - { - label: 'Mon', - value: 50 - }, - { - label: 'Tue', - value: 20 - } - ] - return ( <section className="project-details"> <DetailGrid details={details} /> - <BarChart data={data} /> + { + activity ? + <BarChart data={activity} /> : + <LoadingScreen /> + } <ButtonLink routing href={`/projects/${project.id}/edit`} className="expanded"> Edit </ButtonLink> diff --git a/client/src/pages/Projects/ProjectDetail/ProjectTasks/index.tsx b/client/src/pages/Projects/ProjectDetail/ProjectTasks/index.tsx index 8bb07c4c792ddc8db88d0c2bf8e332ac9fcc4024..bf34d3744b0b8c1412101683d91aa083ea2e53e1 100644 --- a/client/src/pages/Projects/ProjectDetail/ProjectTasks/index.tsx +++ b/client/src/pages/Projects/ProjectDetail/ProjectTasks/index.tsx @@ -1,27 +1,60 @@ import './project-tasks.scss'; import TaskList from 'components/layout/TaskList'; -import { Priority, Status, Task } from 'adapters/task'; +import { Status, StatusColors } from 'adapters/task'; import { useEffect, useState } from 'react'; import Filter from 'components/helpers/Filter'; import { getProjectTasks, Project } from 'adapters/project'; +import { TaskProps } from 'components/ui/Task'; +import LoadingScreen from 'components/ui/LoadingScreen'; interface Props { project: Project } export default function ProjectTasks({ project }: Props) { - const [filter, setFilter] = useState({ term: '', items: [''] }); - const [tasks, setTasks] = useState<Task[]>([]); + const [filter, setFilter] = useState({ term: '', tags: [] }); + const [allTasks, setAllTasks] = useState<TaskProps[]>([]); + const [shownTasks, setShownTasks] = useState<TaskProps[]>(); + useEffect(() => { + getProjectTasks(project.id).then((tasks) => { + setAllTasks(tasks.map((task) => { + return { + task: task, + color: StatusColors.get(task.status), + subtitle: task.status + } + })) + }); + }, [project]); + + const allStatus = Object.values(Status).map((status) => { + return { + label: status.toString(), + color: StatusColors.get(status.toString()) + } + }); useEffect(() => { - getProjectTasks(project.id) - .then((tasks) => setTasks(tasks)); - }, []); + setShownTasks(allTasks.filter(task => { + if (!filter.tags.length) { + return task.task.name.indexOf(filter.term) >= 0; + } else { + return task.task.name.indexOf(filter.term) >= 0 && filter.tags.find(tag => tag === task.task.status); + } + } + )); + }, [filter, allTasks]) + + return ( <div className="project-tasks"> - {/*<Filter setFilter={setFilter} />*/} - <TaskList tasks={tasks} addButton /> + <Filter setFilter={setFilter} tags={allStatus} filter={filter} /> + { + shownTasks ? + <TaskList tasks={shownTasks} addButton /> : + <LoadingScreen /> + } </div> ) } \ No newline at end of file diff --git a/client/src/pages/Projects/ProjectDetail/index.tsx b/client/src/pages/Projects/ProjectDetail/index.tsx index f2624b631e20b108429cde1f7e915da526e3e14b..6744620fe9bcb598210ad5332a30fb19600788ca 100644 --- a/client/src/pages/Projects/ProjectDetail/index.tsx +++ b/client/src/pages/Projects/ProjectDetail/index.tsx @@ -6,6 +6,7 @@ import ProjectDetails from './ProjectDetails'; import ProjectTasks from './ProjectTasks'; import { useEffect, useState } from 'react'; import { getProject, Project, StatusColors } from 'adapters/project'; +import LoadingScreen from 'components/ui/LoadingScreen'; export interface Params { projectId: string; @@ -16,6 +17,7 @@ export default function ProjectDetail() { const [project, setProject] = useState<Project>(); const [tabs, setTabs] = useState<Tab[]>([]); const history = useHistory(); + useEffect(() => { getProject(projectId).then((project) => { setProject(project); @@ -35,23 +37,29 @@ export default function ProjectDetail() { }).catch(() => { history.push('/projects'); }); - }, []) + }, [history, projectId]) + if (project) { + return ( + <div className={"project-detail-page theme-" + project.color}> + <div className="content-container"> + <Tag label={project.status} color={StatusColors.get(project.status)} /> + <h1>{project.name}</h1> + <div className="description-container"> + <p> + {project.text} + </p> + </div> + { + tabs ? + <Tabs tabs={tabs} /> : + <LoadingScreen /> + } - return ( - <div className={"project-detail-page theme-" + project?.color}> - <div className="content-container"> - <Tag label={project?.status ?? ''} color={StatusColors.get(project?.status ?? '')} /> - <h1>{project?.name}</h1> - <div className="description-container"> - <p> - {project?.text} - </p> </div> - {<Tabs tabs={tabs} />} - </div> - </div> - ) + ) + } + return <LoadingScreen /> } \ No newline at end of file diff --git a/client/src/pages/Projects/ProjectEdit/index.tsx b/client/src/pages/Projects/ProjectEdit/index.tsx index 2b020f91caa334edb0215abd421b89da9972405f..f2a6b7494fa960135650e6eab0ba62d8a86d0952 100644 --- a/client/src/pages/Projects/ProjectEdit/index.tsx +++ b/client/src/pages/Projects/ProjectEdit/index.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react'; import { useHistory, useParams } from 'react-router'; import ProjectForm from 'components/forms/ProjectForm'; import Callout from 'components/ui/Callout'; +import LoadingScreen from 'components/ui/LoadingScreen'; interface Params { projectId: string; @@ -20,9 +21,9 @@ export default function ProjectEdit() { }).catch(() => { history.goBack(); }); - }, []); - - const handleSubmit = useCallback(async (teams: string[], name: string, text: string, color: string, status?: Status, deadline?: Date) => { + }, [history, projectId]); + + const handleSubmit = useCallback(async (teams: string[], name: string, text: string, color: string, status?: Status, deadline?: string) => { try { if (project) { @@ -37,19 +38,18 @@ export default function ProjectEdit() { setError('There was an error with updating your project. Please try again!'); } }, [history, project]); - + if (project) { + return ( + <div className="project-create-page"> + <div className="content-container"> + <h1>Edit the project {project.name}</h1> + {error && <Callout message={error} />} + <ProjectForm onSubmit={handleSubmit} project={project} /> + </div> + </div> + ) + } return ( - <div className="project-create-page"> - { - project ? - <div className="content-container"> - <h1>Edit the project {project?.name}</h1> - {error && <Callout message={error} />} - <ProjectForm onSubmit={handleSubmit} project={project} /> - </div> : - <h2>Loading...</h2> - } - </div> + <LoadingScreen /> ) - } \ No newline at end of file diff --git a/client/src/pages/Projects/index.tsx b/client/src/pages/Projects/index.tsx index c3ffcbf4b16c3396779ad0025f653fde6e007d1a..ddc1f842d9a428ba1cf26db1ebb2ddba76bc7de6 100644 --- a/client/src/pages/Projects/index.tsx +++ b/client/src/pages/Projects/index.tsx @@ -4,6 +4,7 @@ import ProjectGrid from 'components/layout/ProjectGrid'; import Filter from 'components/helpers/Filter'; import { useEffect, useState } from 'react'; import { getProjects, Project, Status, StatusColors } from 'adapters/project'; +import LoadingScreen from 'components/ui/LoadingScreen'; export default function Projects() { const [filter, setFilter] = useState({ term: '', tags: [] }); @@ -43,7 +44,13 @@ export default function Projects() { <ProjectsSlider projects={[]} /> <h2>All Projects</h2> <Filter setFilter={setFilter} filter={filter} tags={allStatus} /> - <ProjectGrid projects={shownProjects} /> + { + shownProjects ? ( + <ProjectGrid projects={shownProjects} /> + ) : ( + <LoadingScreen /> + ) + } </div> </div> ); diff --git a/client/src/pages/Settings/index.tsx b/client/src/pages/Settings/index.tsx index a95b543cf558c1440fdb78b84a8dffc1363fbb98..3097213b51fc04e84f5a6efcaceb4f7a46da39dc 100644 --- a/client/src/pages/Settings/index.tsx +++ b/client/src/pages/Settings/index.tsx @@ -1,11 +1,38 @@ import './settings.scss'; +import { useCallback, useEffect, useState } from 'react'; +import { getCurrentUser, updateUser, User } from 'adapters/user'; +import LoadingScreen from 'components/ui/LoadingScreen'; +import UserForm from 'components/forms/UserForm'; +import { useHistory } from 'react-router'; export default function Settings() { - return ( - <div className="settings-page"> - <div className="content-container"> - <h1 className="underlined">Settings</h1> + const [user, setUser] = useState<User>(); + const history = useHistory(); + + useEffect(() => { + getCurrentUser().then((user) => setUser(user)) + }, []); + + + const handleSubmit = useCallback(async (name?: string, email?: string) => { + try { + if (user && updateUser({realname: name, email })) { + history.push('/tasks'); + } + } catch (e) { + } + }, [history, user]); + + if (user) { + return ( + <div className="settings-page"> + <div className="content-container"> + <h1 className="underlined">Settings</h1> + <UserForm user={user} onSubmit={handleSubmit} /> + </div> </div> - </div> - ) + ) + + } + return <LoadingScreen /> } \ No newline at end of file diff --git a/client/src/pages/Tasks/TaskCreate/index.tsx b/client/src/pages/Tasks/TaskCreate/index.tsx index 1f6fd0a13751216d7ca6966f80b86445b95656f3..63867664a76e30b996b88e70e72553a1766fe4a3 100644 --- a/client/src/pages/Tasks/TaskCreate/index.tsx +++ b/client/src/pages/Tasks/TaskCreate/index.tsx @@ -17,7 +17,7 @@ export default function TaskCreate() { useEffect(() => { getProject(projectId).then((project) => setProject(project)); - }, []); + }, [projectId]); const handleSubmit = useCallback(async (name: string, text: string, icon: string, priority: Priority, dependencies: string[], requirements: TaskRequirement[], assignees: TaskAssignment[]) => { try { diff --git a/client/src/pages/Tasks/TaskDetail/TaskAssignees/index.tsx b/client/src/pages/Tasks/TaskDetail/TaskAssignees/index.tsx index 8885d45e5fbcd7d9b3b4b3f95e187a5ac78e045a..8b38b8dc0144101cbb1af8b2b3486c62a2fbe13c 100644 --- a/client/src/pages/Tasks/TaskDetail/TaskAssignees/index.tsx +++ b/client/src/pages/Tasks/TaskDetail/TaskAssignees/index.tsx @@ -1,14 +1,32 @@ -import { TeamMember } from 'adapters/team'; +import { getTaskAssignees } from 'adapters/task'; import MemberList from 'components/layout/MemberList'; +import LoadingScreen from 'components/ui/LoadingScreen'; +import { TeamMemberProps } from 'components/ui/TeamMember'; +import { useEffect, useState } from 'react'; interface Props { - assignees: TeamMember[] + taskId: string } -export default function TaskAssignees({assignees}: Props) { +export default function TaskAssignees({ taskId }: Props) { + const [assignees, setAssignees] = useState<TeamMemberProps[]>(); + useEffect(() => { + getTaskAssignees(taskId).then(assignees => + setAssignees(assignees.map(assignee => ({ + user: assignee, + info: assignee.time.toString() + } + )))) + }, [taskId]) + + return ( - <section className="teams-assignees-section"> - {/*<MemberList members={assignees} />*/} + <section className="task-assignees-section"> + { + assignees ? + <MemberList members={assignees} /> + : <LoadingScreen /> + } </section> ); } \ No newline at end of file diff --git a/client/src/pages/Tasks/TaskDetail/TaskComments/index.tsx b/client/src/pages/Tasks/TaskDetail/TaskComments/index.tsx index 8bd13310e5b5cff75e220963fa04bd4828bd5a9d..cf291cdcb3abeb117241602ff01562c9eee9ac5f 100644 --- a/client/src/pages/Tasks/TaskDetail/TaskComments/index.tsx +++ b/client/src/pages/Tasks/TaskDetail/TaskComments/index.tsx @@ -1,14 +1,28 @@ -import { useParams } from "react-router-dom"; -import { Params } from '../../TaskDetail'; +import { getTaskComments } from 'adapters/task'; import CommentList from 'components/layout/CommentList'; +import { CommentProps } from 'components/ui/Comment'; +import LoadingScreen from 'components/ui/LoadingScreen'; +import { useEffect, useState } from 'react'; -export default function TaskComments() { - const { uuid } = useParams<Params>(); - console.log(uuid); +interface Props { + taskId: string; +} +export default function TaskComments({ taskId }: Props) { + + const [comments, setComments] = useState<CommentProps[]>(); + useEffect(() => { + getTaskComments(taskId).then((comments) => { + setComments(comments.map((comment) => { return { comment } })); + }) + }, [taskId]) return ( <div className="task-comment-list"> - <CommentList user={{id: 'testid', realname: 'Current user', username: 'testname'}} comments={[{user: {id: 'test', username: 'test', realname: 'testname'}, comment: 'Comment'}]}/> + { + comments ? + <CommentList comments={comments} taskId={taskId} /> + : <LoadingScreen /> + } </div> ); } \ No newline at end of file diff --git a/client/src/pages/Tasks/TaskDetail/index.tsx b/client/src/pages/Tasks/TaskDetail/index.tsx index e41ffe1034e3d4a777cd4e915b0984a5e470285e..d8277c63ab3882ff8140ef24d333fafd872f4303 100644 --- a/client/src/pages/Tasks/TaskDetail/index.tsx +++ b/client/src/pages/Tasks/TaskDetail/index.tsx @@ -3,57 +3,82 @@ import Tag from 'components/ui/Tag'; import DetailGrid from 'components/layout/DetailGrid'; import ButtonLink from 'components/navigation/ButtonLink'; import Tabs from 'components/navigation/Tabs'; -import { useParams } from 'react-router'; +import { useHistory, useParams } from 'react-router'; import TaskAssignees from './TaskAssignees'; import TaskComments from './TaskComments'; +import { useEffect, useState } from 'react'; +import { getTask, StatusColors, Task } from 'adapters/task'; +import { getProject, Project } from 'adapters/project'; +import { getTeam } from 'adapters/team'; +import LoadingScreen from 'components/ui/LoadingScreen'; export interface Params { - uuid: string; + taskId: string; } export default function TaskDetail() { - const { uuid } = useParams<Params>(); + const { taskId } = useParams<Params>(); + const history = useHistory(); + const [task, setTask] = useState<Task>(); + const [project, setProject] = useState<Project>(); + const [teamNames, setTeamNames] = useState<string[]>([]); - const tabs = [ - { - label: 'Assignees', - path: '/tasks/' + uuid, - routePath: '/tasks/:uuid', - component: TaskAssignees - }, - { - label: 'Comments', - path: '/tasks/' + uuid + '/comments', - routePath: '/tasks/:uuid/comments', - component: TaskComments - } - ]; + useEffect(() => { + getTask(taskId).then((task) => { - return ( - <div className="tasks-detail-page"> - <div className="content-container"> - <Tag label="Done" /> - <h1>Creating API Routes</h1> - <div className="description-container"> - <p> - Create the API routes and implement them into the FrontEnd, by adding them into the controls. - </p> - </div> - <h2> - Details - </h2> - <DetailGrid details={[ - { icon: 'folder', title: 'Project', label: 'Shopping List' }, - { icon: 'group', title: 'Team', label: 'Ryoko' }]} /> + setTask(task); + getProject(task.project).then((project) => { + setProject(project); + project.teams.forEach((teamId) => + getTeam(teamId).then((team) => { + setTeamNames(state => [...state, team.name]) + } + )); + }); + }).catch(() => history.goBack()); + }, [taskId, history]); + + if (task) { + return ( + <div className={'tasks-detail-page theme-' + StatusColors.get(task.status)}> + <div className="content-container"> + <Tag label={task.status} color={StatusColors.get(task.status)} /> + <h1>{task.name}</h1> + <div className="description-container"> + <p> + {task.text} + </p> + </div> + <h2> + Details + </h2> - <ButtonLink href={'/tasks/' + uuid + '/start'} className="expanded"> - Start + <DetailGrid details={[ + { icon: 'folder', title: 'Project', label: project?.name ?? 'Loading...' }, + { icon: 'group', title: 'Teams', label: teamNames.join(', ') }]} /> + <ButtonLink href={'/tasks/' + taskId + '/start'} className="expanded"> + Start </ButtonLink> - <ButtonLink href={'/tasks/' + uuid + '/edit'} className="dark expanded"> - Edit + <ButtonLink href={'/tasks/' + taskId + '/edit'} className="dark expanded"> + Edit </ButtonLink> - {/*<Tabs tabs={tabs} /> */} + <Tabs tabs={[ + { + label: 'Assignees', + route: '/tasks/' + taskId, + component: <TaskAssignees taskId={taskId} /> + }, + { + label: 'Comments', + route: '/tasks/' + taskId + '/comments', + component: <TaskComments taskId={taskId} /> + } + ]} /> + </div> </div> - </div> - ); + ); + + } else { + return <LoadingScreen />; + } } \ No newline at end of file diff --git a/client/src/pages/Tasks/TaskEdit/index.tsx b/client/src/pages/Tasks/TaskEdit/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a7630c4c85c402093ce8c52f8975871e44b67503 --- /dev/null +++ b/client/src/pages/Tasks/TaskEdit/index.tsx @@ -0,0 +1,78 @@ +import { getProject, Project } from "adapters/project"; +import { getTask, Priority, Status, Task, TaskAssignment, TaskRequirement, updateTask } from "adapters/task"; +import TaskForm from "components/forms/TaskForm"; +import LoadingScreen from "components/ui/LoadingScreen"; +import { useCallback, useEffect, useState } from "react"; +import { useHistory, useParams } from "react-router"; + +interface Params { + taskId: string; +} + +export default function TaskEdit() { + const { taskId } = useParams<Params>(); + const [task, setTask] = useState<Task>(); + const [project, setProject] = useState<Project>(); + const history = useHistory(); + + useEffect(() => { + getTask(taskId).then((task) => { + setTask(task); + getProject(task.project).then((project) => { + setProject(project); + }) + }) + + }, [taskId]) + const handleSubmit = useCallback(async (name: string, text: string, icon: string, priority: Priority, dependencies: string[], requirements: TaskRequirement[], assignees: TaskAssignment[], status?: Status) => { + try { + let addedDependencies: string[] = dependencies; + addedDependencies.filter((dep) => task?.dependencies.indexOf(dep) === -1); + let removedDependencies: string[] = task?.dependencies ?? []; + removedDependencies.filter((dep) => dependencies.indexOf(dep) === -1); + + let addedRequirements: TaskRequirement[] = requirements; + addedRequirements.filter((req) => task?.requirements.indexOf(req) === -1); + let removedRequirementsTemp: TaskRequirement[] = task?.requirements ?? []; + removedRequirementsTemp.filter((req) => requirements.indexOf(req) === -1); + let removedRequirements: string[] = removedRequirementsTemp.map((req) => req.role); + + let addedAssignees: TaskAssignment[] = assignees; + addedAssignees.filter((assignee) => task?.assigned.indexOf(assignee) === -1); + let removedAssigneesTemp: TaskAssignment[] = task?.assigned ?? []; + removedAssigneesTemp.filter((assignee) => assignees.indexOf(assignee) === -1); + let removedAssignees: string[] = removedAssigneesTemp.map((assignee) => assignee.user); + + await updateTask(taskId, { + name, + text, + icon, + priority, + status, + remove_dependencies: removedDependencies, + add_dependencies: addedDependencies, + add_assigned: addedAssignees, + remove_assigned: removedAssignees, + add_requirements: addedRequirements, + remove_requirements: removedRequirements + }); + + history.push('/tasks/' + taskId); + } catch (e) { } + }, [history, taskId, task]); + + if (task && project) { + return ( + <div className="task-edit-page"> + <div className="content-container"> + <h1>Edit your task</h1> + <TaskForm project={project} task={task} onSubmit={handleSubmit} /> + </div> + </div> + ) + + } + return ( + <LoadingScreen /> + ) +} \ No newline at end of file diff --git a/client/src/pages/Tasks/TaskStart/index.tsx b/client/src/pages/Tasks/TaskStart/index.tsx index 1b851c07fe344feea19b8c632d00bb354f2ffd64..9489f19fa75d983e065f88f0dc63c9f27e4ce4b3 100644 --- a/client/src/pages/Tasks/TaskStart/index.tsx +++ b/client/src/pages/Tasks/TaskStart/index.tsx @@ -1,3 +1,63 @@ -export default function TaskStart() { - return (<></>); +import './task-start.scss'; +import Tag from 'components/ui/Tag'; +import DetailGrid from 'components/layout/DetailGrid'; +import ButtonLink from 'components/navigation/ButtonLink'; +import { useHistory, useParams } from 'react-router'; +import { useEffect, useState } from 'react'; +import { getTask, StatusColors, Task } from 'adapters/task'; +import { getProject, Project } from 'adapters/project'; +import { getTeam } from 'adapters/team'; +import LoadingScreen from 'components/ui/LoadingScreen'; + +export interface Params { + taskId: string; +} + +export default function TaskDetail() { + const { taskId } = useParams<Params>(); + const history = useHistory(); + const [task, setTask] = useState<Task>(); + const [project, setProject] = useState<Project>(); + const [teamNames, setTeamNames] = useState<string[]>([]); + + useEffect(() => { + getTask(taskId).then((task) => { + + setTask(task); + getProject(task.project).then((project) => { + setProject(project); + project.teams.forEach((teamId) => + getTeam(teamId).then((team) => { + setTeamNames(state => [...state, team.name]) + } + )); + }); + }).catch(() => history.goBack()); + }, [taskId, history]); + + if (task) { + return ( + <div className={'tasks-detail-page theme-' + StatusColors.get(task.status)}> + <div className="content-container"> + <Tag label={task.status} color={StatusColors.get(task.status)} /> + <h1>{task.name}</h1> + + <div className="description-container"> + <h2> + Description + </h2> + <p> + {task.text} + </p> + </div> + + <DetailGrid details={[ + { icon: 'folder', title: 'Project', label: project?.name ?? 'Loading...' }, + { icon: 'group', title: 'Teams', label: teamNames.join(', ') }]} /> + </div> + </div> + ); + + } + return <LoadingScreen />; } \ No newline at end of file diff --git a/client/src/pages/Tasks/TaskStart/task-start.scss b/client/src/pages/Tasks/TaskStart/task-start.scss new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/client/src/pages/Tasks/index.tsx b/client/src/pages/Tasks/index.tsx index d8d00cb1d1828bdd9504bfefb31d47a36003f81e..f876bf3694b3427247a3fde020f2694830246a01 100644 --- a/client/src/pages/Tasks/index.tsx +++ b/client/src/pages/Tasks/index.tsx @@ -1,41 +1,57 @@ -import Task from 'components/ui/Task'; -import { Priority, Status } from 'adapters/task'; +import Task, { TaskProps } from 'components/ui/Task'; import './tasks.scss'; +import { useEffect, useState } from 'react'; +import { getProject } from 'adapters/project'; +import { getCurrentUser, getUserTasks } from 'adapters/user'; export default function Tasks() { - const task = { - id: 'asdf', - priority: Priority.HIGH, - status: Status.CLOSED, - dependencies: ['test'], - assigned: [{ user: 'test', time: 30, finished: false }], - requirements: [{ role: 'test', time: 20 }], - created: new Date(), - edited: new Date(), + const [tasks, setTasks] = useState<TaskProps[]>([]); - project: 'asdf', + useEffect(() => { + getCurrentUser().then((user) => { + getUserTasks().then((tasks) => { + tasks.forEach(task => { + getProject(task.project).then((project) => { - name: 'Create API Routes', - icon: '🌎', - text: 'Create the API routes and implement them into the FrontEnd, by adding them into the controls.' - } + setTasks(state => [...state, { + task: task, + subtitle: task.assigned.find(assignee => assignee.user === user.id)?.time.toString() ?? '', + color: project.color + }]); + }) + }) + }) + }) + }, []); return ( <div className="tasks-page"> <main className="content-container"> <section className="intro-section"> <h1 className="underlined">Tasks</h1> - <p>Hey Daniel, you have <strong>10 tasks</strong> for today.</p> - </section> - <section className="tasks-container"> - <h2>Today</h2> - <div className="task-group"> - <h3>09:00</h3> - <div className="tasks-list"> - <Task task={task} /> - <Task task={task} /> - </div> - </div> </section> + { + tasks ? ( + <> + <p>Hey Daniel, you have <strong>{tasks.length} tasks</strong> for today.</p> + <section className="tasks-container"> + <h2>Today</h2> + <div className="task-group"> + <h3>09:00</h3> + <div className="tasks-list"> + { + tasks.map((task) => ( + <Task key={task.task.id} {...task} /> + )) + } + </div> + </div> + </section> + </> + ) : + ( + <div>No open tasks found</div> + ) + } </main> </div> ); diff --git a/client/src/pages/Teams/TeamsEdit/index.tsx b/client/src/pages/Teams/TeamsEdit/index.tsx index 7485000a6ba31cefd8f065b0fd37f825aa553b19..13d58a6dc816382ce5b2e5139095bb838fb1c1cf 100644 --- a/client/src/pages/Teams/TeamsEdit/index.tsx +++ b/client/src/pages/Teams/TeamsEdit/index.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react'; import { useHistory, useParams } from 'react-router'; import TeamForm from 'components/forms/TeamForm'; import './teams-edit.scss'; +import LoadingScreen from 'components/ui/LoadingScreen'; interface Params { teamId: string; @@ -39,10 +40,8 @@ export default function TeamsEdit() { </div> </div> ); - } else { - return ( - <h1>Loading</h1> - ) } - + return ( + <LoadingScreen /> + ) } \ No newline at end of file diff --git a/client/src/pages/Teams/TeamsMembers/index.tsx b/client/src/pages/Teams/TeamsMembers/index.tsx index 719d8073192441598f699e195a531e7334f779bd..117a4dbaf439d22c214e85f26f30f6ad81a208dd 100644 --- a/client/src/pages/Teams/TeamsMembers/index.tsx +++ b/client/src/pages/Teams/TeamsMembers/index.tsx @@ -4,6 +4,7 @@ import { getTeamRoles, Team, TeamMember, TeamRole } from 'adapters/team'; import RoleForm from 'components/forms/RoleForm'; import MemberForm from 'components/forms/MemberForm'; import { useEffect, useState } from 'react'; +import LoadingScreen from 'components/ui/LoadingScreen'; interface Props { members: TeamMember[]; @@ -11,15 +12,16 @@ interface Props { } export default function TeamsMembers({ members, team }: Props) { - const [roles, setRoles] = useState<TeamRole[]>([]); + const [roles, setRoles] = useState<TeamRole[]>(); useEffect(() => { getTeamRoles(team.id).then((roles) => { setRoles(roles); }) - }, []); - - const teamMembers = members.map(member => { - return { + }, [team]); + + let teamMembers; + if (roles) { + teamMembers = members.map(member => ({ user: member, info: member.role.name, settings: [{ @@ -30,12 +32,17 @@ export default function TeamsMembers({ members, team }: Props) { </> ) }] - } - }); + })); + } + return ( <section className="teams-members-section"> - <MemberList members={teamMembers} addContent={<MemberForm setRoles={setRoles} roles={roles} team={team} />} /> + { + (roles && teamMembers) ? + <MemberList members={teamMembers} addContent={<MemberForm setRoles={setRoles} roles={roles} team={team} />} /> + : <LoadingScreen /> + } </section> ) } \ No newline at end of file diff --git a/client/src/pages/Teams/TeamsStats/index.tsx b/client/src/pages/Teams/TeamsStats/index.tsx index be9ba4fd9d1cd2809cf4deb5dff6a58fc48dc39c..f5c999a6275d768d46e3025812d928806ddff005 100644 --- a/client/src/pages/Teams/TeamsStats/index.tsx +++ b/client/src/pages/Teams/TeamsStats/index.tsx @@ -1,23 +1,55 @@ import Dropdown from 'components/navigation/Dropdown'; -import BarChart from 'components/graphs/BarChart'; +import BarChart, { ChartItem } from 'components/graphs/BarChart'; import CompletionGrid from 'components/layout/CompletionGrid'; import './teams-stats.scss'; +import { useEffect, useState } from 'react'; +import { getTeamActivity, getTeamCompletion } from 'adapters/team'; +import LoadingScreen from 'components/ui/LoadingScreen'; +import { CompletionProps } from 'components/ui/Completion'; +import { StatusColors } from 'adapters/task'; -export default function TeamsStats() { - const completions = [ - { - label: 'Done', - percent: 20, - }, - { - label: 'Done', - percent: 20 - }, - { - label: 'Done', - percent: 20 - } - ] +interface Props { + teamId: string; +} + +export default function TeamsStats({ teamId }: Props) { + const [activity, setActivity] = useState<ChartItem[]>([]); + const [completions, setCompletions] = useState<CompletionProps[]>([]); + + + useEffect(() => { + getTeamActivity(teamId).then((a) => { + setActivity(a.map(item => ({ + label: item.day, + value: item.time + }))); + }); + getTeamCompletion(teamId).then((comp) => { + const allAmount = comp.sum ?? 1; + setCompletions([ + { + label: 'Closed', + percent: comp.closed / allAmount * 100, + color: StatusColors.get('closed') ?? '' + }, + { + label: 'Open', + percent: comp.open / allAmount * 100, + color: StatusColors.get('open') ?? '' + }, + { + label: 'Suspended', + percent: comp.suspended / allAmount * 100, + color: StatusColors.get('suspended') ?? '' + }, + { + label: 'Overdue', + percent: comp.overdue / allAmount * 100, + color: StatusColors.get('overdue') ?? '' + }, + ]); + }) + }, [teamId]) return ( <section className="teams-stats-section"> <Dropdown items={[{ label: "Last month", route: "lastmonth" }]}> @@ -27,9 +59,19 @@ export default function TeamsStats() { Last week </Dropdown> <h3>Activities</h3> - <BarChart data={[{ label: 'mon', value: 20 }, { label: 'tue', value: 10 }, { label: 'wed', value: 5 }]} /> + { + activity ? + <BarChart data={activity} /> + : <LoadingScreen /> + } <h3>Completion</h3> - <CompletionGrid items={completions} /> + { + completions ? ( + <CompletionGrid items={completions} /> + ) : ( + <LoadingScreen /> + ) + } </section> ) } \ No newline at end of file diff --git a/client/src/pages/Teams/index.tsx b/client/src/pages/Teams/index.tsx index 6cbe14e96c993c3d99ed0233a6c1e2f8a1fd61ae..088a7276d8cc924ae195be2a4a7cf95b84c8cb73 100644 --- a/client/src/pages/Teams/index.tsx +++ b/client/src/pages/Teams/index.tsx @@ -10,6 +10,7 @@ import { useCallback, useEffect, useState } from 'react'; import { getTeamMembers, getTeamProjects, getTeams, leaveTeam, Team } from 'adapters/team'; import { DetailProps } from 'components/ui/DetailBox'; import { useHistory, useParams } from 'react-router'; +import LoadingScreen from 'components/ui/LoadingScreen'; export interface Params { teamId: string; @@ -64,7 +65,7 @@ export default function Teams() { }, { route: '/teams/' + currentTeam.id + '/stats', label: 'Stats', - component: <TeamsStats /> + component: <TeamsStats teamId={currentTeam.id} /> }]); }); @@ -86,32 +87,51 @@ export default function Teams() { } }, [currentTeam, history]) - return ( - <div className="teams-page"> - <div className="content-container"> - <h1 className="underlined">Teams</h1> - { - allTeams && ( - <Dropdown items={pageLinks}> - <h2>{currentTeam?.name}</h2> - <span className="material-icons icon"> - expand_more + if (currentTeam) { + return ( + <div className="teams-page"> + <div className="content-container"> + <h1 className="underlined">Teams</h1> + { + allTeams && ( + <Dropdown items={pageLinks}> + <h2>{currentTeam?.name}</h2> + <span className="material-icons icon"> + expand_more </span> - </Dropdown> - ) - } - <DetailGrid details={details} /> - <ButtonLink href={'/teams/' + currentTeam?.id + '/edit'} className="expanded"> - Edit + </Dropdown> + ) + } + { + details ? ( + <DetailGrid details={details} /> + ) : ( + <LoadingScreen /> + ) + } + + <ButtonLink href={'/teams/' + currentTeam?.id + '/edit'} className="expanded"> + Edit </ButtonLink> - { - allTeams && allTeams.length > 1 && - <Button className="expanded dark" onClick={leaveCurrentTeam}> - Leave Team + { + allTeams && allTeams.length > 1 && + <Button className="expanded dark" onClick={leaveCurrentTeam}> + Leave Team </Button> - } - <Tabs tabs={tabs} /> + } + { + tabs ? ( + <Tabs tabs={tabs} /> + ) : ( + <LoadingScreen /> + ) + } + </div> </div> - </div> + ) + } + + return ( + <LoadingScreen /> ) } \ No newline at end of file diff --git a/client/src/styles/settings.scss b/client/src/styles/settings.scss index 60913d728fc7f63b214c3c720f3e922a65109d8f..fb92ed280892a3e098a3db06a288aaaab441f813 100644 --- a/client/src/styles/settings.scss +++ b/client/src/styles/settings.scss @@ -4,7 +4,7 @@ $primary: var(--primary); $primary-dark: var(--primary-dark); $secondary: #7DEFFF; $red: #E51C4A; -$light: #F1F1F1; +$light: rgba(0, 0, 0, 0.025); $dark: #180923; $light-gray: #F8F8F8; diff --git a/server/src/v1/project.ts b/server/src/v1/project.ts index 21af41a0ce581114c515ffa3dd595b4d7c3d1476..4605e66898012901585d2186d44ecadabc3bc122 100644 --- a/server/src/v1/project.ts +++ b/server/src/v1/project.ts @@ -120,7 +120,7 @@ project.get('/:uuid/tasks', async (req, res) => { assigned_user: 'task_assignees.user_id', assigned_time: 'task_assignees.time', assigned_finished: 'task_assignees.finished', - dependentcy: 'task_dependencies.requires_id', + dependency: 'task_dependencies.requires_id', }) .where({ 'team_members.user_id': req.body.token.id, diff --git a/server/src/v1/task.ts b/server/src/v1/task.ts index b32681a266dac45acc5a04839fe6da8b89b56aa0..6dc5bc6d703e29342af7d05f4cf9bc1b7f6b226e 100644 --- a/server/src/v1/task.ts +++ b/server/src/v1/task.ts @@ -25,7 +25,7 @@ export interface Task { icon: string; priority: string; status: string; - dependentcies: Array<string>; + dependencies: Array<string>; requirements: Array<TaskRequirement>; assigned: Array<TaskAssignment>; created: number; @@ -49,7 +49,7 @@ export function generateFromFlatResult(results: any[]): Task[] { edited: row.edited, requirements: [], assigned: [], - dependentcies: [], + dependencies: [], }; } if (row.requirement_role) { @@ -73,12 +73,12 @@ export function generateFromFlatResult(results: any[]): Task[] { }); } } - if (row.dependentcy) { - if (!grouped_tasks[row.id].dependentcies.includes(row.dependentcy)) { - grouped_tasks[row.id].dependentcies.push(row.dependentcy); + if (row.dependency) { + if (!grouped_tasks[row.id].dependencies.includes(row.dependency)) { + grouped_tasks[row.id].dependencies.push(row.dependency); } } - if (row.dependentcy_status && row.dependentcy_status !== 'closed') { + if (row.dependency_status && row.dependency_status !== 'closed') { to_remove.push(row.id); } } @@ -115,7 +115,7 @@ task.get('/', async (req, res) => { assigned_user: 'task_assignees.user_id', assigned_time: 'task_assignees.time', assigned_finished: 'task_assignees.finished', - dependentcy: 'task_dependencies.requires_id', + dependency: 'task_dependencies.requires_id', }) .where({ 'team_members.user_id': req.body.token.id, @@ -155,7 +155,7 @@ task.get('/:status(open|closed|suspended)', async (req, res) => { assigned_user: 'task_assignees.user_id', assigned_time: 'task_assignees.time', assigned_finished: 'task_assignees.finished', - dependentcy: 'task_dependencies.requires_id', + dependency: 'task_dependencies.requires_id', }) .where({ 'team_members.user_id': req.body.token.id, @@ -197,8 +197,8 @@ task.get('/possible', async (req, res) => { assigned_user: 'task_assignees.user_id', assigned_time: 'task_assignees.time', assigned_finished: 'task_assignees.finished', - dependentcy: 'task_dependencies.requires_id', - dependentcy_status: 'require.status', + dependency: 'task_dependencies.requires_id', + dependency_status: 'require.status', }) .where({ 'team_members.user_id': req.body.token.id, @@ -372,7 +372,7 @@ task.get('/:uuid', async (req, res) => { .where({ 'task_assignees.task_id': id, }); - const dependentcies = await database('task_dependencies') + const dependencies = await database('task_dependencies') .select({ id: 'task_dependencies.requires_id', }) @@ -396,7 +396,7 @@ task.get('/:uuid', async (req, res) => { time: row.time, finished: row.finished, })), - dependentcies: dependentcies.map(row => row.id), + dependencies: dependencies.map(row => row.id), requirements: requirements.map(row => ({ role: row.role, time: row.time, @@ -442,12 +442,12 @@ task.post('/', async (req, res) => { ])) { try { const project_id = req.body.project; - const dependentcy_ids = req.body.dependencies; + const dependency_ids = req.body.dependencies; const assigned = req.body.assigned; const assigned_ids = assigned.map(asg => asg.user); const requirements = req.body.requirements; const requirement_ids = requirements.map(req => req.role); - for (const team_id of [ ...dependentcy_ids, ...requirement_ids, ...assigned_ids, project_id ]) { + for (const team_id of [ ...dependency_ids, ...requirement_ids, ...assigned_ids, project_id ]) { if (!validate(team_id)) { res.status(400).json({ status: 'error', @@ -487,11 +487,11 @@ task.post('/', async (req, res) => { })) ); } - if (dependentcy_ids.length !== 0) { + if (dependency_ids.length !== 0) { await transaction('task_dependencies').insert( - dependentcy_ids.map(dependentcy_id => ({ + dependency_ids.map(dependency_id => ({ task_id: task_id, - requires_id: dependentcy_id, + requires_id: dependency_id, })) ); } @@ -536,7 +536,7 @@ interface UpdateTaskBody { icon?: string; priority?: string; status?: string; - remove_dependentcies?: Array<string>; + remove_dependencies?: Array<string>; remove_requirements?: Array<string>; remove_assigned?: Array<string>; add_dependencies?: Array<string>; @@ -549,10 +549,10 @@ task.put('/:uuid', async (req, res) => { if (isOfType<UpdateTaskBody>(req.body, [])) { try { const task_id = req.params.uuid; - const remove_dependentcy_ids = req.body.remove_dependentcies ?? []; + const remove_dependency_ids = req.body.remove_dependencies ?? []; const remove_assigned_ids = req.body.remove_assigned ?? []; const remove_requirement_ids = req.body.remove_requirements ?? []; - const add_dependentcy_ids = req.body.add_dependencies ?? []; + const add_dependency_ids = req.body.add_dependencies ?? []; const add_assigned = req.body.add_assigned ?? []; const add_assigned_ids = add_assigned.map(asg => asg.user); const add_requirements = req.body.add_requirements ?? []; @@ -560,10 +560,10 @@ task.put('/:uuid', async (req, res) => { const all_ids = [ ...remove_requirement_ids, ...remove_assigned_ids, - ...remove_dependentcy_ids, + ...remove_dependency_ids, ...add_requirement_ids, ...add_assigned_ids, - ...add_dependentcy_ids, + ...add_dependency_ids, task_id, ]; for (const team_id of all_ids) { @@ -607,13 +607,13 @@ task.put('/:uuid', async (req, res) => { }) .whereIn('role_id', remove_requirement_ids); } - if (remove_dependentcy_ids.length !== 0) { + if (remove_dependency_ids.length !== 0) { await transaction('task_dependencies') .delete() .where({ 'task_id': task_id, }) - .whereIn('requires_id', remove_dependentcy_ids); + .whereIn('requires_id', remove_dependency_ids); } if (remove_assigned_ids.length !== 0) { await transaction('task_assignees') @@ -632,11 +632,11 @@ task.put('/:uuid', async (req, res) => { })) ); } - if (add_dependentcy_ids.length !== 0) { + if (add_dependency_ids.length !== 0) { await transaction('task_dependencies').insert( - add_dependentcy_ids.map(dependentcy_id => ({ + add_dependency_ids.map(dependency_id => ({ task_id: task_id, - requires_id: dependentcy_id, + requires_id: dependency_id, })) ); } diff --git a/server/src/v1/user.ts b/server/src/v1/user.ts index e6c85ca5ab61272d5c94dfc96385dcebc0e23ba9..8e596c226e059b2568ec479ce6e6caef51d6c8d5 100644 --- a/server/src/v1/user.ts +++ b/server/src/v1/user.ts @@ -139,7 +139,7 @@ user.get('/tasks', async (req, res) => { assigned_user: 'task_assignees.user_id', assigned_time: 'task_assignees.time', assigned_finished: 'task_assignees.finished', - dependentcy: 'task_dependencies.requires_id', + dependency: 'task_dependencies.requires_id', }) .where({ 'ut.user_id': req.body.token.id,