@@ -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 { BASE_API_URL } from "@/config/api"; | ||||
import { Task, TaskGroup } from "../tasks"; | import { Task, TaskGroup } from "../tasks"; | ||||
import { Customer } from "../customer"; | import { Customer } from "../customer"; | ||||
import { revalidateTag } from "next/cache"; | |||||
export interface CreateProjectInputs { | export interface CreateProjectInputs { | ||||
// Project details | // Project details | ||||
@@ -62,9 +63,12 @@ export interface PaymentInputs { | |||||
} | } | ||||
export const saveProject = async (data: CreateProjectInputs) => { | export const saveProject = async (data: CreateProjectInputs) => { | ||||
return serverFetchJson(`${BASE_API_URL}/projects/new`, { | |||||
const newProject = await serverFetchJson(`${BASE_API_URL}/projects/new`, { | |||||
method: "POST", | method: "POST", | ||||
body: JSON.stringify(data), | body: JSON.stringify(data), | ||||
headers: { "Content-Type": "application/json" }, | 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 { cache } from "react"; | ||||
import "server-only"; | import "server-only"; | ||||
import { Task, TaskGroup } from "../tasks"; | import { Task, TaskGroup } from "../tasks"; | ||||
import { CreateProjectInputs } from "./actions"; | |||||
export interface ProjectResult { | export interface ProjectResult { | ||||
id: number; | id: number; | ||||
@@ -55,8 +56,8 @@ export interface AssignedProject { | |||||
tasks: Task[]; | tasks: Task[]; | ||||
milestones: { | milestones: { | ||||
[taskGroupId: TaskGroup["id"]]: { | [taskGroupId: TaskGroup["id"]]: { | ||||
startDate: string; | |||||
endDate: string; | |||||
startDate?: string; | |||||
endDate?: string; | |||||
}; | }; | ||||
}; | }; | ||||
// Manhour info | // 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, | useForm, | ||||
} from "react-hook-form"; | } from "react-hook-form"; | ||||
import { CreateProjectInputs, saveProject } from "@/app/api/projects/actions"; | import { CreateProjectInputs, saveProject } from "@/app/api/projects/actions"; | ||||
import { Error } from "@mui/icons-material"; | |||||
import { Delete, Error, PlayArrow } from "@mui/icons-material"; | |||||
import { | import { | ||||
BuildingType, | BuildingType, | ||||
ContractType, | ContractType, | ||||
@@ -38,6 +38,8 @@ import { Grade } from "@/app/api/grades"; | |||||
import { Customer, Subsidiary } from "@/app/api/customer"; | import { Customer, Subsidiary } from "@/app/api/customer"; | ||||
export interface Props { | export interface Props { | ||||
isEditMode: boolean; | |||||
defaultInputs?: CreateProjectInputs; | |||||
allTasks: Task[]; | allTasks: Task[]; | ||||
projectCategories: ProjectCategory[]; | projectCategories: ProjectCategory[]; | ||||
taskTemplates: TaskTemplate[]; | taskTemplates: TaskTemplate[]; | ||||
@@ -69,6 +71,8 @@ const hasErrorsInTab = ( | |||||
}; | }; | ||||
const CreateProject: React.FC<Props> = ({ | const CreateProject: React.FC<Props> = ({ | ||||
isEditMode, | |||||
defaultInputs, | |||||
allTasks, | allTasks, | ||||
projectCategories, | projectCategories, | ||||
taskTemplates, | taskTemplates, | ||||
@@ -90,7 +94,7 @@ const CreateProject: React.FC<Props> = ({ | |||||
const router = useRouter(); | const router = useRouter(); | ||||
const handleCancel = () => { | const handleCancel = () => { | ||||
router.back(); | |||||
router.replace("/projects"); | |||||
}; | }; | ||||
const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>( | const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>( | ||||
@@ -128,7 +132,7 @@ const CreateProject: React.FC<Props> = ({ | |||||
); | ); | ||||
const formProps = useForm<CreateProjectInputs>({ | const formProps = useForm<CreateProjectInputs>({ | ||||
defaultValues: { | |||||
defaultValues: defaultInputs ?? { | |||||
taskGroups: {}, | taskGroups: {}, | ||||
allocatedStaffIds: [], | allocatedStaffIds: [], | ||||
milestones: {}, | milestones: {}, | ||||
@@ -142,76 +146,95 @@ const CreateProject: React.FC<Props> = ({ | |||||
const errors = formProps.formState.errors; | const errors = formProps.formState.errors; | ||||
return ( | 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> | ||||
<Button variant="contained" startIcon={<Check />} type="submit"> | |||||
{t("Confirm")} | |||||
<Button variant="outlined" startIcon={<Delete />} color="error"> | |||||
{t("Delete Project")} | |||||
</Button> | </Button> | ||||
</Stack> | </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, | fetchProjectBuildingTypes, | ||||
fetchProjectCategories, | fetchProjectCategories, | ||||
fetchProjectContractTypes, | fetchProjectContractTypes, | ||||
fetchProjectDetails, | |||||
fetchProjectFundingTypes, | fetchProjectFundingTypes, | ||||
fetchProjectLocationTypes, | fetchProjectLocationTypes, | ||||
fetchProjectServiceTypes, | fetchProjectServiceTypes, | ||||
@@ -13,7 +14,15 @@ import { fetchStaff, fetchTeamLeads } from "@/app/api/staff"; | |||||
import { fetchAllCustomers, fetchAllSubsidiaries } from "@/app/api/customer"; | import { fetchAllCustomers, fetchAllSubsidiaries } from "@/app/api/customer"; | ||||
import { fetchGrades } from "@/app/api/grades"; | 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 [ | const [ | ||||
tasks, | tasks, | ||||
taskTemplates, | taskTemplates, | ||||
@@ -46,8 +55,14 @@ const CreateProjectWrapper: React.FC = async () => { | |||||
fetchGrades(), | fetchGrades(), | ||||
]); | ]); | ||||
const projectInfo = props.isEditMode | |||||
? await fetchProjectDetails(props.projectId) | |||||
: undefined; | |||||
return ( | return ( | ||||
<CreateProject | <CreateProject | ||||
isEditMode={props.isEditMode} | |||||
defaultInputs={projectInfo} | |||||
allTasks={tasks} | allTasks={tasks} | ||||
projectCategories={projectCategories} | projectCategories={projectCategories} | ||||
taskTemplates={taskTemplates} | taskTemplates={taskTemplates} | ||||
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next"; | |||||
import SearchResults, { Column } from "../SearchResults"; | import SearchResults, { Column } from "../SearchResults"; | ||||
import EditNote from "@mui/icons-material/EditNote"; | import EditNote from "@mui/icons-material/EditNote"; | ||||
import uniq from "lodash/uniq"; | import uniq from "lodash/uniq"; | ||||
import { useRouter } from "next/navigation"; | |||||
interface Props { | interface Props { | ||||
projects: ProjectResult[]; | projects: ProjectResult[]; | ||||
@@ -17,6 +18,7 @@ type SearchQuery = Partial<Omit<ProjectResult, "id">>; | |||||
type SearchParamNames = keyof SearchQuery; | type SearchParamNames = keyof SearchQuery; | ||||
const ProjectSearch: React.FC<Props> = ({ projects, projectCategories }) => { | const ProjectSearch: React.FC<Props> = ({ projects, projectCategories }) => { | ||||
const router = useRouter(); | |||||
const { t } = useTranslation("projects"); | const { t } = useTranslation("projects"); | ||||
const [filteredProjects, setFilteredProjects] = useState(projects); | const [filteredProjects, setFilteredProjects] = useState(projects); | ||||
@@ -51,9 +53,12 @@ const ProjectSearch: React.FC<Props> = ({ projects, projectCategories }) => { | |||||
setFilteredProjects(projects); | setFilteredProjects(projects); | ||||
}, [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>[]>( | const columns = useMemo<Column<ProjectResult>[]>( | ||||
() => [ | () => [ | ||||
@@ -9,9 +9,9 @@ import { Box, Input, SxProps, TableCell } from "@mui/material"; | |||||
interface Props<T> { | interface Props<T> { | ||||
value: T; | value: T; | ||||
onChange: (newValue?: T) => void; | |||||
onChange: (newValue: T) => void; | |||||
renderValue?: (value: T) => string; | renderValue?: (value: T) => string; | ||||
convertValue: (inputValue?: string) => T; | |||||
convertValue: (inputValue: string) => T; | |||||
cellSx?: SxProps; | cellSx?: SxProps; | ||||
inputSx?: SxProps; | inputSx?: SxProps; | ||||
} | } | ||||
@@ -25,7 +25,7 @@ const TableCellEdit = <T,>({ | |||||
inputSx, | inputSx, | ||||
}: Props<T>) => { | }: Props<T>) => { | ||||
const [editMode, setEditMode] = useState(false); | const [editMode, setEditMode] = useState(false); | ||||
const [input, setInput] = useState<string>(); | |||||
const [input, setInput] = useState<string>(""); | |||||
const inputRef = useRef<HTMLInputElement>(null); | const inputRef = useRef<HTMLInputElement>(null); | ||||
const onClick = useCallback(() => { | const onClick = useCallback(() => { | ||||
@@ -41,7 +41,7 @@ const TableCellEdit = <T,>({ | |||||
const onBlur = useCallback(() => { | const onBlur = useCallback(() => { | ||||
setEditMode(false); | setEditMode(false); | ||||
onChange(convertValue(input)); | onChange(convertValue(input)); | ||||
setInput(undefined); | |||||
setInput(""); | |||||
}, [convertValue, input, onChange]); | }, [convertValue, input, onChange]); | ||||
useEffect(() => { | useEffect(() => { | ||||
@@ -37,7 +37,6 @@ type TimeEntryRow = Partial< | |||||
_error: string; | _error: string; | ||||
isPlanned: boolean; | isPlanned: boolean; | ||||
id: string; | id: string; | ||||
taskGroupId: number; | |||||
} | } | ||||
>; | >; | ||||
@@ -221,6 +220,9 @@ const EntryInputTable: React.FC<Props> = ({ day, assignedProjects }) => { | |||||
valueOptions() { | valueOptions() { | ||||
return assignedProjects.map((p) => ({ value: p.id, label: p.name })); | return assignedProjects.map((p) => ({ value: p.id, label: p.name })); | ||||
}, | }, | ||||
valueGetter({ value }) { | |||||
return value ?? ""; | |||||
}, | |||||
}, | }, | ||||
{ | { | ||||
field: "taskGroupId", | field: "taskGroupId", | ||||
@@ -228,6 +230,9 @@ const EntryInputTable: React.FC<Props> = ({ day, assignedProjects }) => { | |||||
width: 200, | width: 200, | ||||
editable: true, | editable: true, | ||||
type: "singleSelect", | type: "singleSelect", | ||||
valueGetter({ value }) { | |||||
return value ?? ""; | |||||
}, | |||||
valueOptions(params) { | valueOptions(params) { | ||||
const updatedRow = params.id | const updatedRow = params.id | ||||
? apiRef.current.getRowWithUpdatedValues(params.id, "") | ? apiRef.current.getRowWithUpdatedValues(params.id, "") | ||||
@@ -253,6 +258,9 @@ const EntryInputTable: React.FC<Props> = ({ day, assignedProjects }) => { | |||||
width: 200, | width: 200, | ||||
editable: true, | editable: true, | ||||
type: "singleSelect", | type: "singleSelect", | ||||
valueGetter({ value }) { | |||||
return value ?? ""; | |||||
}, | |||||
valueOptions(params) { | valueOptions(params) { | ||||
const updatedRow = params.id | const updatedRow = params.id | ||||
? apiRef.current.getRowWithUpdatedValues(params.id, "") | ? apiRef.current.getRowWithUpdatedValues(params.id, "") | ||||