@@ -0,0 +1,62 @@ | |||
import { fetchAllCustomers, fetchAllSubsidiaries } from "@/app/api/customer"; | |||
import { fetchGrades } from "@/app/api/grades"; | |||
import { | |||
fetchProjectBuildingTypes, | |||
fetchProjectCategories, | |||
fetchProjectContractTypes, | |||
fetchProjectDetails, | |||
fetchProjectFundingTypes, | |||
fetchProjectLocationTypes, | |||
fetchProjectServiceTypes, | |||
fetchProjectWorkNatures, | |||
} from "@/app/api/projects"; | |||
import { preloadStaff, preloadTeamLeads } from "@/app/api/staff"; | |||
import { fetchAllTasks, fetchTaskTemplates } from "@/app/api/tasks"; | |||
import CreateProject from "@/components/CreateProject"; | |||
import { I18nProvider, getServerI18n } from "@/i18n"; | |||
import Typography from "@mui/material/Typography"; | |||
import { Metadata } from "next"; | |||
interface Props { | |||
params: { | |||
projectId: string; | |||
}; | |||
} | |||
export const metadata: Metadata = { | |||
title: "Edit Project", | |||
}; | |||
const Projects: React.FC<Props> = async ({ params }) => { | |||
const { t } = await getServerI18n("projects"); | |||
// Preload necessary dependencies | |||
fetchAllTasks(); | |||
fetchTaskTemplates(); | |||
fetchProjectCategories(); | |||
fetchProjectContractTypes(); | |||
fetchProjectFundingTypes(); | |||
fetchProjectLocationTypes(); | |||
fetchProjectServiceTypes(); | |||
fetchProjectBuildingTypes(); | |||
fetchProjectWorkNatures(); | |||
fetchAllCustomers(); | |||
fetchAllSubsidiaries(); | |||
fetchGrades(); | |||
preloadTeamLeads(); | |||
preloadStaff(); | |||
// TODO: Handle not found | |||
const fetchedProject = await fetchProjectDetails(params.projectId); | |||
return ( | |||
<> | |||
<Typography variant="h4">{t("Edit Project")}</Typography> | |||
<I18nProvider namespaces={["projects"]}> | |||
<CreateProject isEditMode projectId={params.projectId} /> | |||
</I18nProvider> | |||
</> | |||
); | |||
}; | |||
export default Projects; |
@@ -4,6 +4,7 @@ import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||
import { BASE_API_URL } from "@/config/api"; | |||
import { Task, TaskGroup } from "../tasks"; | |||
import { Customer } from "../customer"; | |||
import { revalidateTag } from "next/cache"; | |||
export interface CreateProjectInputs { | |||
// Project details | |||
@@ -62,9 +63,12 @@ export interface PaymentInputs { | |||
} | |||
export const saveProject = async (data: CreateProjectInputs) => { | |||
return serverFetchJson(`${BASE_API_URL}/projects/new`, { | |||
const newProject = await serverFetchJson(`${BASE_API_URL}/projects/new`, { | |||
method: "POST", | |||
body: JSON.stringify(data), | |||
headers: { "Content-Type": "application/json" }, | |||
}); | |||
revalidateTag("projects"); | |||
return newProject; | |||
}; |
@@ -3,6 +3,7 @@ import { BASE_API_URL } from "@/config/api"; | |||
import { cache } from "react"; | |||
import "server-only"; | |||
import { Task, TaskGroup } from "../tasks"; | |||
import { CreateProjectInputs } from "./actions"; | |||
export interface ProjectResult { | |||
id: number; | |||
@@ -55,8 +56,8 @@ export interface AssignedProject { | |||
tasks: Task[]; | |||
milestones: { | |||
[taskGroupId: TaskGroup["id"]]: { | |||
startDate: string; | |||
endDate: string; | |||
startDate?: string; | |||
endDate?: string; | |||
}; | |||
}; | |||
// Manhour info | |||
@@ -145,3 +146,12 @@ export const fetchAssignedProjects = cache(async () => { | |||
}, | |||
); | |||
}); | |||
export const fetchProjectDetails = cache(async (projectId: string) => { | |||
return serverFetchJson<CreateProjectInputs>( | |||
`${BASE_API_URL}/projects/projectDetails/${projectId}`, | |||
{ | |||
next: { tags: [`projectDetails_${projectId}`] }, | |||
}, | |||
); | |||
}); |
@@ -22,7 +22,7 @@ import { | |||
useForm, | |||
} from "react-hook-form"; | |||
import { CreateProjectInputs, saveProject } from "@/app/api/projects/actions"; | |||
import { Error } from "@mui/icons-material"; | |||
import { Delete, Error, PlayArrow } from "@mui/icons-material"; | |||
import { | |||
BuildingType, | |||
ContractType, | |||
@@ -38,6 +38,8 @@ import { Grade } from "@/app/api/grades"; | |||
import { Customer, Subsidiary } from "@/app/api/customer"; | |||
export interface Props { | |||
isEditMode: boolean; | |||
defaultInputs?: CreateProjectInputs; | |||
allTasks: Task[]; | |||
projectCategories: ProjectCategory[]; | |||
taskTemplates: TaskTemplate[]; | |||
@@ -69,6 +71,8 @@ const hasErrorsInTab = ( | |||
}; | |||
const CreateProject: React.FC<Props> = ({ | |||
isEditMode, | |||
defaultInputs, | |||
allTasks, | |||
projectCategories, | |||
taskTemplates, | |||
@@ -90,7 +94,7 @@ const CreateProject: React.FC<Props> = ({ | |||
const router = useRouter(); | |||
const handleCancel = () => { | |||
router.back(); | |||
router.replace("/projects"); | |||
}; | |||
const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>( | |||
@@ -128,7 +132,7 @@ const CreateProject: React.FC<Props> = ({ | |||
); | |||
const formProps = useForm<CreateProjectInputs>({ | |||
defaultValues: { | |||
defaultValues: defaultInputs ?? { | |||
taskGroups: {}, | |||
allocatedStaffIds: [], | |||
milestones: {}, | |||
@@ -142,76 +146,95 @@ const CreateProject: React.FC<Props> = ({ | |||
const errors = formProps.formState.errors; | |||
return ( | |||
<FormProvider {...formProps}> | |||
<Stack | |||
spacing={2} | |||
component="form" | |||
onSubmit={formProps.handleSubmit(onSubmit, onSubmitError)} | |||
> | |||
<Tabs value={tabIndex} onChange={handleTabChange} variant="scrollable"> | |||
<Tab | |||
label={t("Project and Client Details")} | |||
icon={ | |||
hasErrorsInTab(0, errors) ? ( | |||
<Error sx={{ marginInlineEnd: 1 }} color="error" /> | |||
) : undefined | |||
} | |||
iconPosition="end" | |||
/> | |||
<Tab label={t("Project Task Setup")} iconPosition="end" /> | |||
<Tab label={t("Staff Allocation and Resource")} iconPosition="end" /> | |||
<Tab label={t("Milestone")} iconPosition="end" /> | |||
</Tabs> | |||
{ | |||
<ProjectClientDetails | |||
buildingTypes={buildingTypes} | |||
workNatures={workNatures} | |||
contractTypes={contractTypes} | |||
fundingTypes={fundingTypes} | |||
locationTypes={locationTypes} | |||
serviceTypes={serviceTypes} | |||
allCustomers={allCustomers} | |||
allSubsidiaries={allSubsidiaries} | |||
projectCategories={projectCategories} | |||
teamLeads={teamLeads} | |||
isActive={tabIndex === 0} | |||
/> | |||
} | |||
{ | |||
<TaskSetup | |||
allTasks={allTasks} | |||
taskTemplates={taskTemplates} | |||
isActive={tabIndex === 1} | |||
/> | |||
} | |||
{ | |||
<StaffAllocation | |||
isActive={tabIndex === 2} | |||
allTasks={allTasks} | |||
grades={grades} | |||
allStaffs={allStaffs} | |||
/> | |||
} | |||
{<Milestone allTasks={allTasks} isActive={tabIndex === 3} />} | |||
{serverError && ( | |||
<Typography variant="body2" color="error" alignSelf="flex-end"> | |||
{serverError} | |||
</Typography> | |||
)} | |||
<Stack direction="row" justifyContent="flex-end" gap={1}> | |||
<Button | |||
variant="outlined" | |||
startIcon={<Close />} | |||
onClick={handleCancel} | |||
> | |||
{t("Cancel")} | |||
<> | |||
{isEditMode && ( | |||
<Stack direction="row" gap={1}> | |||
<Button variant="contained" startIcon={<PlayArrow />} color="success"> | |||
{t("Start Project")} | |||
</Button> | |||
<Button variant="contained" startIcon={<Check />} type="submit"> | |||
{t("Confirm")} | |||
<Button variant="outlined" startIcon={<Delete />} color="error"> | |||
{t("Delete Project")} | |||
</Button> | |||
</Stack> | |||
</Stack> | |||
</FormProvider> | |||
)} | |||
<FormProvider {...formProps}> | |||
<Stack | |||
spacing={2} | |||
component="form" | |||
onSubmit={formProps.handleSubmit(onSubmit, onSubmitError)} | |||
> | |||
<Tabs | |||
value={tabIndex} | |||
onChange={handleTabChange} | |||
variant="scrollable" | |||
> | |||
<Tab | |||
label={t("Project and Client Details")} | |||
icon={ | |||
hasErrorsInTab(0, errors) ? ( | |||
<Error sx={{ marginInlineEnd: 1 }} color="error" /> | |||
) : undefined | |||
} | |||
iconPosition="end" | |||
/> | |||
<Tab label={t("Project Task Setup")} iconPosition="end" /> | |||
<Tab | |||
label={t("Staff Allocation and Resource")} | |||
iconPosition="end" | |||
/> | |||
<Tab label={t("Milestone")} iconPosition="end" /> | |||
</Tabs> | |||
{ | |||
<ProjectClientDetails | |||
buildingTypes={buildingTypes} | |||
workNatures={workNatures} | |||
contractTypes={contractTypes} | |||
fundingTypes={fundingTypes} | |||
locationTypes={locationTypes} | |||
serviceTypes={serviceTypes} | |||
allCustomers={allCustomers} | |||
allSubsidiaries={allSubsidiaries} | |||
projectCategories={projectCategories} | |||
teamLeads={teamLeads} | |||
isActive={tabIndex === 0} | |||
/> | |||
} | |||
{ | |||
<TaskSetup | |||
allTasks={allTasks} | |||
taskTemplates={taskTemplates} | |||
isActive={tabIndex === 1} | |||
/> | |||
} | |||
{ | |||
<StaffAllocation | |||
isActive={tabIndex === 2} | |||
allTasks={allTasks} | |||
grades={grades} | |||
allStaffs={allStaffs} | |||
/> | |||
} | |||
{<Milestone allTasks={allTasks} isActive={tabIndex === 3} />} | |||
{serverError && ( | |||
<Typography variant="body2" color="error" alignSelf="flex-end"> | |||
{serverError} | |||
</Typography> | |||
)} | |||
<Stack direction="row" justifyContent="flex-end" gap={1}> | |||
<Button | |||
variant="outlined" | |||
startIcon={<Close />} | |||
onClick={handleCancel} | |||
> | |||
{t("Cancel")} | |||
</Button> | |||
<Button variant="contained" startIcon={<Check />} type="submit"> | |||
{isEditMode ? t("Save") : t("Confirm")} | |||
</Button> | |||
</Stack> | |||
</Stack> | |||
</FormProvider> | |||
</> | |||
); | |||
}; | |||
@@ -4,6 +4,7 @@ import { | |||
fetchProjectBuildingTypes, | |||
fetchProjectCategories, | |||
fetchProjectContractTypes, | |||
fetchProjectDetails, | |||
fetchProjectFundingTypes, | |||
fetchProjectLocationTypes, | |||
fetchProjectServiceTypes, | |||
@@ -13,7 +14,15 @@ import { fetchStaff, fetchTeamLeads } from "@/app/api/staff"; | |||
import { fetchAllCustomers, fetchAllSubsidiaries } from "@/app/api/customer"; | |||
import { fetchGrades } from "@/app/api/grades"; | |||
const CreateProjectWrapper: React.FC = async () => { | |||
type CreateProjectProps = { isEditMode: false }; | |||
interface EditProjectProps { | |||
isEditMode: true; | |||
projectId: string; | |||
} | |||
type Props = CreateProjectProps | EditProjectProps; | |||
const CreateProjectWrapper: React.FC<Props> = async (props) => { | |||
const [ | |||
tasks, | |||
taskTemplates, | |||
@@ -46,8 +55,14 @@ const CreateProjectWrapper: React.FC = async () => { | |||
fetchGrades(), | |||
]); | |||
const projectInfo = props.isEditMode | |||
? await fetchProjectDetails(props.projectId) | |||
: undefined; | |||
return ( | |||
<CreateProject | |||
isEditMode={props.isEditMode} | |||
defaultInputs={projectInfo} | |||
allTasks={tasks} | |||
projectCategories={projectCategories} | |||
taskTemplates={taskTemplates} | |||
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next"; | |||
import SearchResults, { Column } from "../SearchResults"; | |||
import EditNote from "@mui/icons-material/EditNote"; | |||
import uniq from "lodash/uniq"; | |||
import { useRouter } from "next/navigation"; | |||
interface Props { | |||
projects: ProjectResult[]; | |||
@@ -17,6 +18,7 @@ type SearchQuery = Partial<Omit<ProjectResult, "id">>; | |||
type SearchParamNames = keyof SearchQuery; | |||
const ProjectSearch: React.FC<Props> = ({ projects, projectCategories }) => { | |||
const router = useRouter(); | |||
const { t } = useTranslation("projects"); | |||
const [filteredProjects, setFilteredProjects] = useState(projects); | |||
@@ -51,9 +53,12 @@ const ProjectSearch: React.FC<Props> = ({ projects, projectCategories }) => { | |||
setFilteredProjects(projects); | |||
}, [projects]); | |||
const onProjectClick = useCallback((project: ProjectResult) => { | |||
console.log(project); | |||
}, []); | |||
const onProjectClick = useCallback( | |||
(project: ProjectResult) => { | |||
router.push(`/projects/edit/${project.id}`); | |||
}, | |||
[router], | |||
); | |||
const columns = useMemo<Column<ProjectResult>[]>( | |||
() => [ | |||
@@ -9,9 +9,9 @@ import { Box, Input, SxProps, TableCell } from "@mui/material"; | |||
interface Props<T> { | |||
value: T; | |||
onChange: (newValue?: T) => void; | |||
onChange: (newValue: T) => void; | |||
renderValue?: (value: T) => string; | |||
convertValue: (inputValue?: string) => T; | |||
convertValue: (inputValue: string) => T; | |||
cellSx?: SxProps; | |||
inputSx?: SxProps; | |||
} | |||
@@ -25,7 +25,7 @@ const TableCellEdit = <T,>({ | |||
inputSx, | |||
}: Props<T>) => { | |||
const [editMode, setEditMode] = useState(false); | |||
const [input, setInput] = useState<string>(); | |||
const [input, setInput] = useState<string>(""); | |||
const inputRef = useRef<HTMLInputElement>(null); | |||
const onClick = useCallback(() => { | |||
@@ -41,7 +41,7 @@ const TableCellEdit = <T,>({ | |||
const onBlur = useCallback(() => { | |||
setEditMode(false); | |||
onChange(convertValue(input)); | |||
setInput(undefined); | |||
setInput(""); | |||
}, [convertValue, input, onChange]); | |||
useEffect(() => { | |||
@@ -37,7 +37,6 @@ type TimeEntryRow = Partial< | |||
_error: string; | |||
isPlanned: boolean; | |||
id: string; | |||
taskGroupId: number; | |||
} | |||
>; | |||
@@ -221,6 +220,9 @@ const EntryInputTable: React.FC<Props> = ({ day, assignedProjects }) => { | |||
valueOptions() { | |||
return assignedProjects.map((p) => ({ value: p.id, label: p.name })); | |||
}, | |||
valueGetter({ value }) { | |||
return value ?? ""; | |||
}, | |||
}, | |||
{ | |||
field: "taskGroupId", | |||
@@ -228,6 +230,9 @@ const EntryInputTable: React.FC<Props> = ({ day, assignedProjects }) => { | |||
width: 200, | |||
editable: true, | |||
type: "singleSelect", | |||
valueGetter({ value }) { | |||
return value ?? ""; | |||
}, | |||
valueOptions(params) { | |||
const updatedRow = params.id | |||
? apiRef.current.getRowWithUpdatedValues(params.id, "") | |||
@@ -253,6 +258,9 @@ const EntryInputTable: React.FC<Props> = ({ day, assignedProjects }) => { | |||
width: 200, | |||
editable: true, | |||
type: "singleSelect", | |||
valueGetter({ value }) { | |||
return value ?? ""; | |||
}, | |||
valueOptions(params) { | |||
const updatedRow = params.id | |||
? apiRef.current.getRowWithUpdatedValues(params.id, "") | |||