@@ -5,6 +5,7 @@ const withPWA = require("next-pwa")({ | |||
dest: "public", | |||
register: true, | |||
skipWaiting: true, | |||
disable: process.env.NODE_ENV === 'development' | |||
}); | |||
const nextConfig = { | |||
@@ -1,3 +1,5 @@ | |||
import { fetchProjectCategories } from "@/app/api/projects"; | |||
import { fetchAllTasks, fetchTaskTemplates } from "@/app/api/tasks"; | |||
import CreateProject from "@/components/CreateProject"; | |||
import { I18nProvider, getServerI18n } from "@/i18n"; | |||
import Typography from "@mui/material/Typography"; | |||
@@ -10,6 +12,11 @@ export const metadata: Metadata = { | |||
const Projects: React.FC = async () => { | |||
const { t } = await getServerI18n("projects"); | |||
// Preload necessary dependencies | |||
fetchAllTasks(); | |||
fetchTaskTemplates(); | |||
fetchProjectCategories(); | |||
return ( | |||
<> | |||
<Typography variant="h4">{t("Create Project")}</Typography> | |||
@@ -8,7 +8,7 @@ export interface CreateProjectInputs { | |||
// Project details | |||
projectCode: string; | |||
projectName: string; | |||
projectCategory: string; | |||
projectCategoryId: number; | |||
projectDescription: string; | |||
// Client details | |||
@@ -10,7 +10,13 @@ export interface ProjectResult { | |||
client: string; | |||
} | |||
export interface ProjectCategory { | |||
id: number; | |||
label: string; | |||
} | |||
export const preloadProjects = () => { | |||
fetchProjectCategories(); | |||
fetchProjects(); | |||
}; | |||
@@ -18,6 +24,15 @@ export const fetchProjects = cache(async () => { | |||
return mockProjects; | |||
}); | |||
export const fetchProjectCategories = cache(async () => { | |||
return mockProjectCategories; | |||
}); | |||
const mockProjectCategories: ProjectCategory[] = [ | |||
{ id: 1, label: "Confirmed Project" }, | |||
{ id: 2, label: "Project to be bidded" }, | |||
]; | |||
const mockProjects: ProjectResult[] = [ | |||
{ | |||
id: 1, | |||
@@ -19,6 +19,7 @@ export interface TaskTemplate { | |||
id: number; | |||
code: string; | |||
name: string; | |||
tasks: Task[]; | |||
} | |||
export const preloadTaskTemplates = () => { | |||
@@ -13,7 +13,7 @@ import ProjectClientDetails from "./ProjectClientDetails"; | |||
import TaskSetup from "./TaskSetup"; | |||
import StaffAllocation from "./StaffAllocation"; | |||
import ResourceMilestone from "./ResourceMilestone"; | |||
import { Task } from "@/app/api/tasks"; | |||
import { Task, TaskTemplate } from "@/app/api/tasks"; | |||
import { | |||
FieldErrors, | |||
FormProvider, | |||
@@ -23,9 +23,12 @@ import { | |||
} from "react-hook-form"; | |||
import { CreateProjectInputs } from "@/app/api/projects/actions"; | |||
import { Error } from "@mui/icons-material"; | |||
import { ProjectCategory } from "@/app/api/projects"; | |||
export interface Props { | |||
allTasks: Task[]; | |||
projectCategories: ProjectCategory[]; | |||
taskTemplates: TaskTemplate[]; | |||
} | |||
const hasErrorsInTab = ( | |||
@@ -40,7 +43,11 @@ const hasErrorsInTab = ( | |||
} | |||
}; | |||
const CreateProject: React.FC<Props> = ({ allTasks }) => { | |||
const CreateProject: React.FC<Props> = ({ | |||
allTasks, | |||
projectCategories, | |||
taskTemplates, | |||
}) => { | |||
const [tabIndex, setTabIndex] = useState(0); | |||
const { t } = useTranslation(); | |||
const router = useRouter(); | |||
@@ -101,8 +108,19 @@ const CreateProject: React.FC<Props> = ({ allTasks }) => { | |||
<Tab label={t("Staff Allocation")} iconPosition="end" /> | |||
<Tab label={t("Resource and Milestone")} iconPosition="end" /> | |||
</Tabs> | |||
{<ProjectClientDetails isActive={tabIndex === 0} />} | |||
{<TaskSetup allTasks={allTasks} isActive={tabIndex === 1} />} | |||
{ | |||
<ProjectClientDetails | |||
projectCategories={projectCategories} | |||
isActive={tabIndex === 0} | |||
/> | |||
} | |||
{ | |||
<TaskSetup | |||
allTasks={allTasks} | |||
taskTemplates={taskTemplates} | |||
isActive={tabIndex === 1} | |||
/> | |||
} | |||
{<StaffAllocation isActive={tabIndex === 2} />} | |||
{<ResourceMilestone allTasks={allTasks} isActive={tabIndex === 3} />} | |||
<Stack direction="row" justifyContent="flex-end" gap={1}> | |||
@@ -1,10 +1,19 @@ | |||
import { fetchAllTasks } from "@/app/api/tasks"; | |||
import { fetchAllTasks, fetchTaskTemplates } from "@/app/api/tasks"; | |||
import CreateProject from "./CreateProject"; | |||
import { fetchProjectCategories } from "@/app/api/projects"; | |||
const CreateProjectWrapper: React.FC = async () => { | |||
const tasks = await fetchAllTasks(); | |||
const taskTemplates = await fetchTaskTemplates(); | |||
const projectCategories = await fetchProjectCategories(); | |||
return <CreateProject allTasks={tasks} />; | |||
return ( | |||
<CreateProject | |||
allTasks={tasks} | |||
projectCategories={projectCategories} | |||
taskTemplates={taskTemplates} | |||
/> | |||
); | |||
}; | |||
export default CreateProjectWrapper; |
@@ -15,16 +15,24 @@ import { useTranslation } from "react-i18next"; | |||
import CardActions from "@mui/material/CardActions"; | |||
import RestartAlt from "@mui/icons-material/RestartAlt"; | |||
import Button from "@mui/material/Button"; | |||
import { useFormContext } from "react-hook-form"; | |||
import { Controller, useFormContext } from "react-hook-form"; | |||
import { CreateProjectInputs } from "@/app/api/projects/actions"; | |||
import { ProjectCategory } from "@/app/api/projects"; | |||
const ProjectClientDetails: React.FC<{ isActive: boolean }> = ({ | |||
interface Props { | |||
isActive: boolean; | |||
projectCategories: ProjectCategory[]; | |||
} | |||
const ProjectClientDetails: React.FC<Props> = ({ | |||
isActive, | |||
projectCategories, | |||
}) => { | |||
const { t } = useTranslation(); | |||
const { | |||
register, | |||
formState: { errors }, | |||
control, | |||
} = useFormContext<CreateProjectInputs>(); | |||
return ( | |||
@@ -55,14 +63,23 @@ const ProjectClientDetails: React.FC<{ isActive: boolean }> = ({ | |||
<Grid item xs={6}> | |||
<FormControl fullWidth> | |||
<InputLabel>{t("Project Category")}</InputLabel> | |||
<Select | |||
label={t("Project Category")} | |||
value={"Temporary Project"} | |||
> | |||
<MenuItem value={"Temporary Project"}> | |||
{t("Temporary Project")} | |||
</MenuItem> | |||
</Select> | |||
<Controller | |||
defaultValue={projectCategories[0].id} | |||
control={control} | |||
name="projectCategoryId" | |||
render={({ field }) => ( | |||
<Select label={t("Project Category")} {...field}> | |||
{projectCategories.map((category, index) => ( | |||
<MenuItem | |||
key={`${category.id}-${index}`} | |||
value={category.id} | |||
> | |||
{t(category.label)} | |||
</MenuItem> | |||
))} | |||
</Select> | |||
)} | |||
/> | |||
</FormControl> | |||
</Grid> | |||
<Grid item xs={6}> | |||
@@ -81,11 +81,15 @@ const ResourceMilestone: React.FC<Props> = ({ | |||
))} | |||
</Select> | |||
</FormControl> | |||
<ResourceSection | |||
tasks={currentTasks} | |||
manhourBreakdownByGrade={defaultManhourBreakdownByGrade} | |||
/> | |||
<MilestoneSection taskGroupId={currentTaskGroupId} /> | |||
{/* MUI X-Grid will throw an error if it is rendered without any dimensions; so not rendering when not active */} | |||
{isActive && ( | |||
<ResourceSection | |||
tasks={currentTasks} | |||
manhourBreakdownByGrade={defaultManhourBreakdownByGrade} | |||
/> | |||
)} | |||
{/* MUI X-Grid will throw an error if it is rendered without any dimensions; so not rendering when not active */} | |||
{isActive && <MilestoneSection taskGroupId={currentTaskGroupId} />} | |||
<CardActions sx={{ justifyContent: "flex-end" }}> | |||
<Button variant="text" startIcon={<RestartAlt />}> | |||
{t("Reset")} | |||
@@ -7,31 +7,65 @@ import Typography from "@mui/material/Typography"; | |||
import { useTranslation } from "react-i18next"; | |||
import TransferList from "../TransferList"; | |||
import Button from "@mui/material/Button"; | |||
import React, { useMemo } from "react"; | |||
import React, { useCallback, useMemo, useState } from "react"; | |||
import CardActions from "@mui/material/CardActions"; | |||
import RestartAlt from "@mui/icons-material/RestartAlt"; | |||
import FormControl from "@mui/material/FormControl"; | |||
import Select from "@mui/material/Select"; | |||
import Select, { SelectChangeEvent } from "@mui/material/Select"; | |||
import MenuItem from "@mui/material/MenuItem"; | |||
import InputLabel from "@mui/material/InputLabel"; | |||
import { Task } from "@/app/api/tasks"; | |||
import { Task, TaskTemplate } from "@/app/api/tasks"; | |||
import { useFormContext } from "react-hook-form"; | |||
import { CreateProjectInputs } from "@/app/api/projects/actions"; | |||
import isNumber from "lodash/isNumber"; | |||
interface Props { | |||
allTasks: Task[]; | |||
taskTemplates: TaskTemplate[]; | |||
isActive: boolean; | |||
} | |||
const TaskSetup: React.FC<Props> = ({ allTasks: tasks, isActive }) => { | |||
const TaskSetup: React.FC<Props> = ({ | |||
allTasks: tasks, | |||
taskTemplates, | |||
isActive, | |||
}) => { | |||
const { t } = useTranslation(); | |||
const { getValues, setValue } = useFormContext<CreateProjectInputs>(); | |||
const currentTasks = getValues("tasks"); | |||
const { setValue, watch } = useFormContext<CreateProjectInputs>(); | |||
const currentTasks = watch("tasks"); | |||
const items = useMemo( | |||
() => tasks.map((t) => ({ id: t.id, label: t.name, group: t.taskGroup })), | |||
[tasks], | |||
const onReset = useCallback(() => { | |||
setValue("tasks", {}); | |||
}, [setValue]); | |||
const [selectedTaskTemplateId, setSelectedTaskTemplateId] = useState< | |||
"All" | number | |||
>("All"); | |||
const onSelectTaskTemplate = useCallback( | |||
(e: SelectChangeEvent<number | "All">) => { | |||
if (e.target.value === "All" || isNumber(e.target.value)) { | |||
setSelectedTaskTemplateId(e.target.value); | |||
onReset(); | |||
} | |||
}, | |||
[onReset], | |||
); | |||
const items = useMemo(() => { | |||
const taskList = | |||
selectedTaskTemplateId === "All" | |||
? tasks | |||
: taskTemplates.find( | |||
(template) => template.id === selectedTaskTemplateId, | |||
)?.tasks || tasks; | |||
return taskList.map((t) => ({ | |||
id: t.id, | |||
label: t.name, | |||
group: t.taskGroup, | |||
})); | |||
}, [tasks, selectedTaskTemplateId, taskTemplates]); | |||
const selectedItems = useMemo(() => { | |||
return tasks | |||
.filter((t) => currentTasks[t.id]) | |||
@@ -53,20 +87,24 @@ const TaskSetup: React.FC<Props> = ({ allTasks: tasks, isActive }) => { | |||
<Grid item xs={6}> | |||
<FormControl fullWidth> | |||
<InputLabel>{t("Task List Source")}</InputLabel> | |||
<Select | |||
<Select<"All" | number> | |||
label={t("Task List Source")} | |||
value={"M1009 - Consultancy Project X Temp"} | |||
value={selectedTaskTemplateId} | |||
onChange={onSelectTaskTemplate} | |||
> | |||
<MenuItem value={"M1009 - Consultancy Project X Temp"}> | |||
{"M1009 - Consultancy Project X Temp"} | |||
</MenuItem> | |||
<MenuItem value={"All"}>{t("All tasks")}</MenuItem> | |||
{taskTemplates.map((template, index) => ( | |||
<MenuItem key={`${template.id}-${index}`} value={template.id}> | |||
{template.name} | |||
</MenuItem> | |||
))} | |||
</Select> | |||
</FormControl> | |||
</Grid> | |||
</Grid> | |||
<TransferList | |||
allItems={items} | |||
initiallySelectedItems={selectedItems} | |||
selectedItems={selectedItems} | |||
onChange={(selectedTasks) => { | |||
const newTasks = selectedTasks.reduce<CreateProjectInputs["tasks"]>( | |||
(acc, item) => { | |||
@@ -85,7 +123,7 @@ const TaskSetup: React.FC<Props> = ({ allTasks: tasks, isActive }) => { | |||
selectedItemsLabel={t("Project Task List")} | |||
/> | |||
<CardActions sx={{ justifyContent: "flex-end" }}> | |||
<Button variant="text" startIcon={<RestartAlt />}> | |||
<Button variant="text" startIcon={<RestartAlt />} onClick={onReset}> | |||
{t("Reset")} | |||
</Button> | |||
</CardActions> | |||
@@ -10,10 +10,10 @@ const LoginPage = () => { | |||
<Grid item sm sx={{ backgroundColor: 'neutral.900'}}> | |||
</Grid> | |||
<Grid item xs={12} sm={8} lg={5}> | |||
<Box sx={{ width: '100%', padding: 5, paddingBlockStart: 10, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', svg: { maxHeight: 120 } }}> | |||
<Logo /> | |||
</Box> | |||
<Paper square sx={{ height: "100%" }}> | |||
<Box sx={{ width: '100%', padding: 5, paddingBlockStart: 10, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', svg: { maxHeight: 120 } }}> | |||
<Logo /> | |||
</Box> | |||
<LoginForm /> | |||
</Paper> | |||
</Grid> | |||
@@ -1,23 +1,24 @@ | |||
"use client"; | |||
import { ProjectResult } from "@/app/api/projects"; | |||
import { ProjectCategory, ProjectResult } from "@/app/api/projects"; | |||
import React, { useCallback, useMemo, useState } from "react"; | |||
import SearchBox, { Criterion } from "../SearchBox"; | |||
import { useTranslation } from "react-i18next"; | |||
import SearchResults, { Column } from "../SearchResults"; | |||
import EditNote from "@mui/icons-material/EditNote"; | |||
import uniq from 'lodash/uniq'; | |||
interface Props { | |||
projects: ProjectResult[]; | |||
projectCategories: ProjectCategory[]; | |||
} | |||
type SearchQuery = Partial<Omit<ProjectResult, "id">>; | |||
type SearchParamNames = keyof SearchQuery; | |||
const ProjectSearch: React.FC<Props> = ({ projects }) => { | |||
const ProjectSearch: React.FC<Props> = ({ projects, projectCategories }) => { | |||
const { t } = useTranslation("projects"); | |||
// If project searching is done on the server-side, then no need for this. | |||
const [filteredProjects, setFilteredProjects] = useState(projects); | |||
const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | |||
@@ -28,22 +29,22 @@ const ProjectSearch: React.FC<Props> = ({ projects }) => { | |||
label: t("Client name"), | |||
paramName: "client", | |||
type: "select", | |||
options: ["Client A", "Client B", "Client C"], | |||
options: uniq(projects.map((project) => project.client)), | |||
}, | |||
{ | |||
label: t("Project category"), | |||
paramName: "category", | |||
type: "select", | |||
options: ["Confirmed Project", "Project to be bidded"], | |||
options: projectCategories.map((category) => category.label), | |||
}, | |||
{ | |||
label: t("Team"), | |||
paramName: "team", | |||
type: "select", | |||
options: ["TW", "WY"], | |||
options: uniq(projects.map((project) => project.team)), | |||
}, | |||
], | |||
[t], | |||
[t, projectCategories, projects], | |||
); | |||
const onReset = useCallback(() => { | |||
@@ -1,4 +1,4 @@ | |||
import { fetchProjects } from "@/app/api/projects"; | |||
import { fetchProjectCategories, fetchProjects } from "@/app/api/projects"; | |||
import React from "react"; | |||
import ProjectSearch from "./ProjectSearch"; | |||
import ProjectSearchLoading from "./ProjectSearchLoading"; | |||
@@ -8,9 +8,10 @@ interface SubComponents { | |||
} | |||
const ProjectSearchWrapper: React.FC & SubComponents = async () => { | |||
const projectCategories = await fetchProjectCategories(); | |||
const projects = await fetchProjects(); | |||
return <ProjectSearch projects={projects} />; | |||
return <ProjectSearch projects={projects} projectCategories={projectCategories} />; | |||
}; | |||
ProjectSearchWrapper.Loading = ProjectSearchLoading; | |||
@@ -11,11 +11,11 @@ import { | |||
ListSubheader, | |||
MenuItem, | |||
Select, | |||
SelectProps, | |||
SelectChangeEvent, | |||
Stack, | |||
Typography, | |||
} from "@mui/material"; | |||
import React, { useCallback, useEffect, useState } from "react"; | |||
import React, { useCallback } from "react"; | |||
import { LabelGroup, LabelWithId, TransferListProps } from "./TransferList"; | |||
import { useTranslation } from "react-i18next"; | |||
import uniqBy from "lodash/uniqBy"; | |||
@@ -23,7 +23,7 @@ import groupBy from "lodash/groupBy"; | |||
export const MultiSelectList: React.FC<TransferListProps> = ({ | |||
allItems, | |||
initiallySelectedItems, | |||
selectedItems, | |||
selectedItemsLabel, | |||
allItemsLabel, | |||
onChange, | |||
@@ -39,33 +39,31 @@ export const MultiSelectList: React.FC<TransferListProps> = ({ | |||
(a: number, b: number) => sortMap[a].index - sortMap[b].index, | |||
[sortMap], | |||
); | |||
const [selectedItems, setSelectedItems] = useState( | |||
initiallySelectedItems.map((item) => item.id), | |||
const handleChange = useCallback( | |||
(event: SelectChangeEvent<number[]>) => { | |||
const { | |||
target: { value }, | |||
} = event; | |||
const selectedValues = | |||
typeof value === "string" ? [Number(value)] : value; | |||
onChange(allItems.filter((item) => selectedValues.includes(item.id))); | |||
}, | |||
[allItems, onChange], | |||
); | |||
const handleChange = useCallback< | |||
NonNullable<SelectProps<typeof selectedItems>["onChange"]> | |||
>((event) => { | |||
const { | |||
target: { value }, | |||
} = event; | |||
setSelectedItems(typeof value === "string" ? [Number(value)] : value); | |||
}, []); | |||
const handleToggleAll = useCallback( | |||
() => () => { | |||
if (selectedItems.length === allItems.length) { | |||
setSelectedItems([]); | |||
onChange([]); | |||
} else { | |||
setSelectedItems(allItems.map((item) => item.id)); | |||
onChange(allItems); | |||
} | |||
}, | |||
[allItems, selectedItems.length], | |||
[allItems, onChange, selectedItems.length], | |||
); | |||
useEffect(() => { | |||
onChange(selectedItems.map((item) => sortMap[item])); | |||
}, [onChange, selectedItems, sortMap]); | |||
const { t } = useTranslation(); | |||
const groups: LabelGroup[] = uniqBy( | |||
[ | |||
@@ -85,7 +83,7 @@ export const MultiSelectList: React.FC<TransferListProps> = ({ | |||
<InputLabel>{selectedItemsLabel}</InputLabel> | |||
<Select | |||
multiple | |||
value={selectedItems} | |||
value={selectedItems.map((item) => item.id)} | |||
onChange={handleChange} | |||
renderValue={(values) => { | |||
return ( | |||
@@ -153,7 +151,11 @@ export const MultiSelectList: React.FC<TransferListProps> = ({ | |||
return ( | |||
<MenuItem key={item.id} value={item.id} disableRipple> | |||
<Checkbox | |||
checked={selectedItems.includes(item.id)} | |||
checked={Boolean( | |||
selectedItems.find( | |||
(selected) => selected.id === item.id, | |||
), | |||
)} | |||
disableRipple | |||
/> | |||
<ListItemText sx={{ whiteSpace: "normal" }}> | |||
@@ -33,7 +33,7 @@ export interface LabelWithId { | |||
export interface TransferListProps { | |||
allItems: LabelWithId[]; | |||
initiallySelectedItems: LabelWithId[]; | |||
selectedItems: LabelWithId[]; | |||
onChange: (selectedItems: LabelWithId[]) => void; | |||
allItemsLabel: string; | |||
selectedItemsLabel: string; | |||
@@ -144,7 +144,7 @@ const ItemList: React.FC<ItemListProps> = ({ | |||
const TransferList: React.FC<TransferListProps> = ({ | |||
allItems, | |||
initiallySelectedItems, | |||
selectedItems, | |||
allItemsLabel, | |||
selectedItemsLabel, | |||
onChange, | |||
@@ -163,12 +163,15 @@ const TransferList: React.FC<TransferListProps> = ({ | |||
const [checkedList, setCheckedList] = React.useState<LabelWithId[]>([]); | |||
const [leftList, setLeftList] = React.useState<LabelWithId[]>( | |||
differenceBy(allItems, initiallySelectedItems, "id"), | |||
); | |||
const [rightList, setRightList] = React.useState<LabelWithId[]>( | |||
initiallySelectedItems, | |||
differenceBy(allItems, selectedItems, "id"), | |||
); | |||
React.useEffect(() => { | |||
setLeftList(differenceBy(allItems, selectedItems, "id")); | |||
}, [allItems, selectedItems]); | |||
const rightList = selectedItems; | |||
const leftListChecked = intersection(checkedList, leftList); | |||
const rightListChecked = intersection(checkedList, rightList); | |||
@@ -196,23 +199,17 @@ const TransferList: React.FC<TransferListProps> = ({ | |||
); | |||
const handleCheckedRight = () => { | |||
setRightList([...rightList, ...leftListChecked].sort(compareFn)); | |||
onChange([...selectedItems, ...leftListChecked].sort(compareFn)); | |||
setLeftList(differenceBy(leftList, leftListChecked, "id").sort(compareFn)); | |||
setCheckedList(differenceBy(checkedList, leftListChecked, "id")); | |||
}; | |||
const handleCheckedLeft = () => { | |||
setLeftList([...leftList, ...rightListChecked].sort(compareFn)); | |||
setRightList( | |||
differenceBy(rightList, rightListChecked, "id").sort(compareFn), | |||
); | |||
onChange(differenceBy(rightList, rightListChecked, "id").sort(compareFn)); | |||
setCheckedList(differenceBy(checkedList, rightListChecked, "id")); | |||
}; | |||
React.useEffect(() => { | |||
onChange(rightList); | |||
}, [onChange, rightList]); | |||
return ( | |||
<Stack spacing={2} direction="row" alignItems="center" position="relative"> | |||
<ItemList | |||