| @@ -1,3 +1,4 @@ | |||||
| import { preloadAllTasks } from "@/app/api/tasks"; | |||||
| import CreateTaskTemplate from "@/components/CreateTaskTemplate"; | import CreateTaskTemplate from "@/components/CreateTaskTemplate"; | ||||
| import { getServerI18n } from "@/i18n"; | import { getServerI18n } from "@/i18n"; | ||||
| import Typography from "@mui/material/Typography"; | import Typography from "@mui/material/Typography"; | ||||
| @@ -9,6 +10,7 @@ export const metadata: Metadata = { | |||||
| const Projects: React.FC = async () => { | const Projects: React.FC = async () => { | ||||
| const { t } = await getServerI18n("tasks"); | const { t } = await getServerI18n("tasks"); | ||||
| preloadAllTasks(); | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| @@ -0,0 +1,19 @@ | |||||
| "use server"; | |||||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||||
| import { BASE_API_URL } from "@/config/api"; | |||||
| import { TaskTemplate } from "."; | |||||
| export interface NewTaskTemplateFormInputs { | |||||
| code: string; | |||||
| name: string; | |||||
| taskIds: number[]; | |||||
| } | |||||
| export const saveTaskTemplate = async (data: NewTaskTemplateFormInputs) => { | |||||
| return serverFetchJson<TaskTemplate>(`${BASE_API_URL}/tasks/templates/new`, { | |||||
| method: "POST", | |||||
| body: JSON.stringify(data), | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| }); | |||||
| }; | |||||
| @@ -1,7 +1,21 @@ | |||||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||||
| import { BASE_API_URL } from "@/config/api"; | |||||
| import { cache } from "react"; | import { cache } from "react"; | ||||
| import "server-only"; | import "server-only"; | ||||
| export interface TaskTemplateResult { | |||||
| export interface TaskGroup { | |||||
| id: number; | |||||
| name: string; | |||||
| } | |||||
| export interface Task { | |||||
| id: number; | |||||
| name: string; | |||||
| description: string | null; | |||||
| taskGroup: TaskGroup | null; | |||||
| } | |||||
| export interface TaskTemplate { | |||||
| id: number; | id: number; | ||||
| code: string; | code: string; | ||||
| name: string; | name: string; | ||||
| @@ -12,23 +26,13 @@ export const preloadTaskTemplates = () => { | |||||
| }; | }; | ||||
| export const fetchTaskTemplates = cache(async () => { | export const fetchTaskTemplates = cache(async () => { | ||||
| return mockProjects; | |||||
| return serverFetchJson<TaskTemplate[]>(`${BASE_API_URL}/tasks/templates`); | |||||
| }); | }); | ||||
| const mockProjects: TaskTemplateResult[] = [ | |||||
| { | |||||
| id: 1, | |||||
| code: "Pre-001", | |||||
| name: "Pre-contract Template", | |||||
| }, | |||||
| { | |||||
| id: 2, | |||||
| code: "Post-001", | |||||
| name: "Post-contract Template", | |||||
| }, | |||||
| { | |||||
| id: 3, | |||||
| code: "Full-001", | |||||
| name: "Full Project Template", | |||||
| }, | |||||
| ]; | |||||
| export const preloadAllTasks = () => { | |||||
| fetchAllTasks(); | |||||
| }; | |||||
| export const fetchAllTasks = cache(async () => { | |||||
| return serverFetchJson<Task[]>(`${BASE_API_URL}/tasks`); | |||||
| }); | |||||
| @@ -0,0 +1,7 @@ | |||||
| import LogoutPage from "@/components/LogoutPage"; | |||||
| const Logout: React.FC = async () => { | |||||
| return <LogoutPage />; | |||||
| }; | |||||
| export default Logout; | |||||
| @@ -0,0 +1,46 @@ | |||||
| import { SessionWithTokens, authOptions } from "@/config/authConfig"; | |||||
| import { getServerSession } from "next-auth"; | |||||
| import { headers } from "next/headers"; | |||||
| import { redirect } from "next/navigation"; | |||||
| export const serverFetch: typeof fetch = async (input, init) => { | |||||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |||||
| const session = await getServerSession<any, SessionWithTokens>(authOptions); | |||||
| const accessToken = session?.accessToken; | |||||
| return fetch(input, { | |||||
| ...init, | |||||
| headers: { | |||||
| ...init?.headers, | |||||
| ...(accessToken | |||||
| ? { | |||||
| Authorization: `Bearer ${accessToken}`, | |||||
| } | |||||
| : {}), | |||||
| }, | |||||
| }); | |||||
| }; | |||||
| type FetchParams = Parameters<typeof fetch>; | |||||
| export async function serverFetchJson<T>(...args: FetchParams) { | |||||
| const response = await serverFetch(...args); | |||||
| if (response.ok) { | |||||
| return response.json() as T; | |||||
| } else { | |||||
| switch (response.status) { | |||||
| case 401: | |||||
| signOutUser(); | |||||
| default: | |||||
| throw Error("Something went wrong fetching data in server."); | |||||
| } | |||||
| } | |||||
| } | |||||
| export const signOutUser = () => { | |||||
| const headersList = headers(); | |||||
| const referer = headersList.get("referer"); | |||||
| redirect( | |||||
| `/logout${referer ? `?callbackUrl=${encodeURIComponent(referer)}` : ""}`, | |||||
| ); | |||||
| }; | |||||
| @@ -11,6 +11,8 @@ import React, { useCallback, useState } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import ProjectClientDetails from "./ProjectClientDetails"; | import ProjectClientDetails from "./ProjectClientDetails"; | ||||
| import TaskSetup from "./TaskSetup"; | import TaskSetup from "./TaskSetup"; | ||||
| import StaffAllocation from "./StaffAllocation"; | |||||
| import ResourceMilestone from "./ResourceMilestone"; | |||||
| const CreateProject: React.FC = () => { | const CreateProject: React.FC = () => { | ||||
| const [tabIndex, setTabIndex] = useState(0); | const [tabIndex, setTabIndex] = useState(0); | ||||
| @@ -38,6 +40,8 @@ const CreateProject: React.FC = () => { | |||||
| </Tabs> | </Tabs> | ||||
| {tabIndex === 0 && <ProjectClientDetails />} | {tabIndex === 0 && <ProjectClientDetails />} | ||||
| {tabIndex === 1 && <TaskSetup />} | {tabIndex === 1 && <TaskSetup />} | ||||
| {tabIndex === 2 && <StaffAllocation initiallySelectedStaff={[]} />} | |||||
| {tabIndex === 3 && <ResourceMilestone />} | |||||
| <Stack direction="row" justifyContent="flex-end" gap={1}> | <Stack direction="row" justifyContent="flex-end" gap={1}> | ||||
| <Button variant="outlined" startIcon={<Close />} onClick={handleCancel}> | <Button variant="outlined" startIcon={<Close />} onClick={handleCancel}> | ||||
| {t("Cancel")} | {t("Cancel")} | ||||
| @@ -0,0 +1,31 @@ | |||||
| "use client"; | |||||
| import Card from "@mui/material/Card"; | |||||
| import CardContent from "@mui/material/CardContent"; | |||||
| import Typography from "@mui/material/Typography"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import Button from "@mui/material/Button"; | |||||
| import React from "react"; | |||||
| import CardActions from "@mui/material/CardActions"; | |||||
| import RestartAlt from "@mui/icons-material/RestartAlt"; | |||||
| const ResourceMilestone = () => { | |||||
| const { t } = useTranslation(); | |||||
| return ( | |||||
| <Card> | |||||
| <CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}> | |||||
| <Typography variant="overline" display="block" marginBlockEnd={1}> | |||||
| {t("Resource and Milestone")} | |||||
| </Typography> | |||||
| <CardActions sx={{ justifyContent: "flex-end" }}> | |||||
| <Button variant="text" startIcon={<RestartAlt />}> | |||||
| {t("Reset")} | |||||
| </Button> | |||||
| </CardActions> | |||||
| </CardContent> | |||||
| </Card> | |||||
| ); | |||||
| }; | |||||
| export default ResourceMilestone; | |||||
| @@ -0,0 +1,252 @@ | |||||
| "use client"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import React from "react"; | |||||
| import RestartAlt from "@mui/icons-material/RestartAlt"; | |||||
| import SearchResults, { Column } from "../SearchResults"; | |||||
| import { Search, Clear, PersonAdd, PersonRemove } from "@mui/icons-material"; | |||||
| import { | |||||
| Stack, | |||||
| Typography, | |||||
| Grid, | |||||
| TextField, | |||||
| InputAdornment, | |||||
| IconButton, | |||||
| FormControl, | |||||
| InputLabel, | |||||
| Select, | |||||
| MenuItem, | |||||
| Box, | |||||
| Button, | |||||
| Card, | |||||
| CardActions, | |||||
| CardContent, | |||||
| TabsProps, | |||||
| Tab, | |||||
| Tabs, | |||||
| } from "@mui/material"; | |||||
| import differenceBy from "lodash/differenceBy"; | |||||
| interface StaffResult { | |||||
| id: string; | |||||
| name: string; | |||||
| team: string; | |||||
| grade: string; | |||||
| title: string; | |||||
| } | |||||
| const mockStaffs: StaffResult[] = [ | |||||
| { | |||||
| name: "Albert", | |||||
| grade: "1", | |||||
| id: "1", | |||||
| team: "ABC", | |||||
| title: "Associate Quantity Surveyor", | |||||
| }, | |||||
| { | |||||
| name: "Bernard", | |||||
| grade: "2", | |||||
| id: "2", | |||||
| team: "ABC", | |||||
| title: "Quantity Surveyor", | |||||
| }, | |||||
| { | |||||
| name: "Carl", | |||||
| grade: "3", | |||||
| id: "3", | |||||
| team: "XYZ", | |||||
| title: "Senior Quantity Surveyor", | |||||
| }, | |||||
| { name: "Denis", grade: "4", id: "4", team: "ABC", title: "Manager" }, | |||||
| { name: "Edward", grade: "5", id: "5", team: "ABC", title: "Director" }, | |||||
| { name: "Fred", grade: "1", id: "6", team: "XYZ", title: "General Laborer" }, | |||||
| { name: "Gordon", grade: "2", id: "7", team: "ABC", title: "Inspector" }, | |||||
| { | |||||
| name: "Heather", | |||||
| grade: "3", | |||||
| id: "8", | |||||
| team: "XYZ", | |||||
| title: "Field Engineer", | |||||
| }, | |||||
| { name: "Ivan", grade: "4", id: "9", team: "ABC", title: "Senior Manager" }, | |||||
| { | |||||
| name: "Jackson", | |||||
| grade: "5", | |||||
| id: "10", | |||||
| team: "XYZ", | |||||
| title: "Senior Director", | |||||
| }, | |||||
| { | |||||
| name: "Kurt", | |||||
| grade: "1", | |||||
| id: "11", | |||||
| team: "ABC", | |||||
| title: "Construction Assistant", | |||||
| }, | |||||
| { name: "Lawrence", grade: "2", id: "12", team: "ABC", title: "Operator" }, | |||||
| ]; | |||||
| interface Props { | |||||
| allStaff?: StaffResult[]; | |||||
| initiallySelectedStaff: StaffResult[]; | |||||
| } | |||||
| const StaffAllocation: React.FC<Props> = ({ | |||||
| allStaff = mockStaffs, | |||||
| initiallySelectedStaff, | |||||
| }) => { | |||||
| const { t } = useTranslation(); | |||||
| const [filteredStaff, setFilteredStaff] = React.useState(allStaff); | |||||
| const [selectedStaff, setSelectedStaff] = React.useState< | |||||
| typeof filteredStaff | |||||
| >(initiallySelectedStaff); | |||||
| const filters = React.useMemo<(keyof StaffResult)[]>( | |||||
| () => ["team", "grade"], | |||||
| [], | |||||
| ); | |||||
| const addStaff = React.useCallback((staff: StaffResult) => { | |||||
| setSelectedStaff((staffs) => [...staffs, staff]); | |||||
| }, []); | |||||
| const removeStaff = React.useCallback((staff: StaffResult) => { | |||||
| setSelectedStaff((staffs) => staffs.filter((s) => s.id !== staff.id)); | |||||
| }, []); | |||||
| const staffPoolColumns = React.useMemo<Column<StaffResult>[]>( | |||||
| () => [ | |||||
| { | |||||
| label: t("Add"), | |||||
| name: "id", | |||||
| onClick: addStaff, | |||||
| buttonIcon: <PersonAdd />, | |||||
| }, | |||||
| { label: t("Staff ID"), name: "id" }, | |||||
| { label: t("Staff Name"), name: "name" }, | |||||
| { label: t("Team"), name: "team" }, | |||||
| { label: t("Grade"), name: "grade" }, | |||||
| { label: t("Title"), name: "title" }, | |||||
| ], | |||||
| [addStaff, t], | |||||
| ); | |||||
| const allocatedStaffColumns = React.useMemo<Column<StaffResult>[]>( | |||||
| () => [ | |||||
| { | |||||
| label: t("Remove"), | |||||
| name: "id", | |||||
| onClick: removeStaff, | |||||
| buttonIcon: <PersonRemove />, | |||||
| }, | |||||
| { label: t("Staff ID"), name: "id" }, | |||||
| { label: t("Staff Name"), name: "name" }, | |||||
| { label: t("Team"), name: "team" }, | |||||
| { label: t("Grade"), name: "grade" }, | |||||
| { label: t("Title"), name: "title" }, | |||||
| ], | |||||
| [removeStaff, t], | |||||
| ); | |||||
| const [query, setQuery] = React.useState(""); | |||||
| const onQueryInputChange = React.useCallback< | |||||
| React.ChangeEventHandler<HTMLInputElement> | |||||
| >((e) => { | |||||
| setQuery(e.target.value); | |||||
| }, []); | |||||
| const clearQueryInput = React.useCallback(() => { | |||||
| setQuery(""); | |||||
| }, []); | |||||
| React.useEffect(() => { | |||||
| setFilteredStaff( | |||||
| allStaff.filter( | |||||
| (staff) => | |||||
| staff.name.toLowerCase().includes(query) || | |||||
| staff.id.toLowerCase().includes(query) || | |||||
| staff.title.toLowerCase().includes(query), | |||||
| ), | |||||
| ); | |||||
| }, [allStaff, query]); | |||||
| const [tabIndex, setTabIndex] = React.useState(0); | |||||
| const handleTabChange = React.useCallback<NonNullable<TabsProps["onChange"]>>( | |||||
| (_e, newValue) => { | |||||
| setTabIndex(newValue); | |||||
| }, | |||||
| [], | |||||
| ); | |||||
| return ( | |||||
| <Card> | |||||
| <CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}> | |||||
| <Stack gap={2}> | |||||
| <Typography variant="overline" display="block"> | |||||
| {t("Staff Allocation")} | |||||
| </Typography> | |||||
| <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | |||||
| <Grid item xs={6} display="flex" alignItems="center"> | |||||
| <Search sx={{ marginInlineEnd: 1 }} /> | |||||
| <TextField | |||||
| variant="standard" | |||||
| fullWidth | |||||
| onChange={onQueryInputChange} | |||||
| value={query} | |||||
| placeholder={t("Search by staff ID, name or title")} | |||||
| InputProps={{ | |||||
| endAdornment: query && ( | |||||
| <InputAdornment position="end"> | |||||
| <IconButton onClick={clearQueryInput}> | |||||
| <Clear /> | |||||
| </IconButton> | |||||
| </InputAdornment> | |||||
| ), | |||||
| }} | |||||
| /> | |||||
| </Grid> | |||||
| {filters.map((filter, idx) => { | |||||
| const label = staffPoolColumns.find( | |||||
| (c) => c.name === filter, | |||||
| )!.label; | |||||
| return ( | |||||
| <Grid key={`${filter.toString()}-${idx}`} item xs={3}> | |||||
| <FormControl fullWidth> | |||||
| <InputLabel size="small">{label}</InputLabel> | |||||
| <Select label={label} size="small"> | |||||
| <MenuItem value={"All"}>{t("All")}</MenuItem> | |||||
| </Select> | |||||
| </FormControl> | |||||
| </Grid> | |||||
| ); | |||||
| })} | |||||
| </Grid> | |||||
| <Tabs value={tabIndex} onChange={handleTabChange}> | |||||
| <Tab label={t("Staff Pool")} /> | |||||
| <Tab label={t("Allocated Staff")} /> | |||||
| </Tabs> | |||||
| <Box sx={{ marginInline: -3 }}> | |||||
| {tabIndex === 0 && ( | |||||
| <SearchResults | |||||
| noWrapper | |||||
| items={differenceBy(filteredStaff, selectedStaff, "id")} | |||||
| columns={staffPoolColumns} | |||||
| /> | |||||
| )} | |||||
| {tabIndex === 1 && ( | |||||
| <SearchResults | |||||
| noWrapper | |||||
| items={selectedStaff} | |||||
| columns={allocatedStaffColumns} | |||||
| /> | |||||
| )} | |||||
| </Box> | |||||
| </Stack> | |||||
| <CardActions sx={{ justifyContent: "flex-end" }}> | |||||
| <Button variant="text" startIcon={<RestartAlt />}> | |||||
| {t("Reset")} | |||||
| </Button> | |||||
| </CardActions> | |||||
| </CardContent> | |||||
| </Card> | |||||
| ); | |||||
| }; | |||||
| export default StaffAllocation; | |||||
| @@ -13,8 +13,18 @@ import Close from "@mui/icons-material/Close"; | |||||
| import { useRouter } from "next/navigation"; | import { useRouter } from "next/navigation"; | ||||
| import React from "react"; | import React from "react"; | ||||
| import Stack from "@mui/material/Stack"; | import Stack from "@mui/material/Stack"; | ||||
| import { Task } from "@/app/api/tasks"; | |||||
| import { | |||||
| NewTaskTemplateFormInputs, | |||||
| saveTaskTemplate, | |||||
| } from "@/app/api/tasks/actions"; | |||||
| import { SubmitHandler, useForm } from "react-hook-form"; | |||||
| const CreateTaskTemplate = () => { | |||||
| interface Props { | |||||
| tasks: Task[]; | |||||
| } | |||||
| const CreateTaskTemplate: React.FC<Props> = ({ tasks }) => { | |||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const router = useRouter(); | const router = useRouter(); | ||||
| @@ -22,8 +32,40 @@ const CreateTaskTemplate = () => { | |||||
| router.back(); | router.back(); | ||||
| }; | }; | ||||
| const items = React.useMemo( | |||||
| () => | |||||
| tasks.map((task) => ({ | |||||
| id: task.id, | |||||
| label: task.name, | |||||
| group: task.taskGroup || undefined, | |||||
| })), | |||||
| [tasks], | |||||
| ); | |||||
| const [serverError, setServerError] = React.useState(""); | |||||
| const { | |||||
| register, | |||||
| handleSubmit, | |||||
| setValue, | |||||
| formState: { errors, isSubmitting }, | |||||
| } = useForm<NewTaskTemplateFormInputs>(); | |||||
| const onSubmit: SubmitHandler<NewTaskTemplateFormInputs> = React.useCallback( | |||||
| async (data) => { | |||||
| try { | |||||
| setServerError(""); | |||||
| await saveTaskTemplate(data); | |||||
| router.replace("/tasks"); | |||||
| } catch (e) { | |||||
| setServerError(t("An error has occurred. Please try again later.")); | |||||
| } | |||||
| }, | |||||
| [router, t], | |||||
| ); | |||||
| return ( | return ( | ||||
| <> | |||||
| <Stack component="form" onSubmit={handleSubmit(onSubmit)} gap={2}> | |||||
| <Card> | <Card> | ||||
| <CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}> | <CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}> | ||||
| <Typography variant="overline">{t("Task List Setup")}</Typography> | <Typography variant="overline">{t("Task List Setup")}</Typography> | ||||
| @@ -34,40 +76,61 @@ const CreateTaskTemplate = () => { | |||||
| marginBlockEnd={1} | marginBlockEnd={1} | ||||
| > | > | ||||
| <Grid item xs={6}> | <Grid item xs={6}> | ||||
| <TextField label={t("Task Template Code")} fullWidth /> | |||||
| <TextField | |||||
| label={t("Task Template Code")} | |||||
| fullWidth | |||||
| {...register("code", { | |||||
| required: t("Task template code is required"), | |||||
| })} | |||||
| error={Boolean(errors.code?.message)} | |||||
| helperText={errors.code?.message} | |||||
| /> | |||||
| </Grid> | </Grid> | ||||
| <Grid item xs={6}> | <Grid item xs={6}> | ||||
| <TextField label={t("Task Template Name")} fullWidth /> | |||||
| <TextField | |||||
| label={t("Task Template Name")} | |||||
| fullWidth | |||||
| {...register("name", { | |||||
| required: t("Task template name is required"), | |||||
| })} | |||||
| error={Boolean(errors.name?.message)} | |||||
| helperText={errors.name?.message} | |||||
| /> | |||||
| </Grid> | </Grid> | ||||
| </Grid> | </Grid> | ||||
| <TransferList | <TransferList | ||||
| allItems={[ | |||||
| { id: "1", label: "Task 1: Super long task name that will overflow to the next line" }, | |||||
| { id: "2", label: "Task 2" }, | |||||
| { id: "3", label: "Task 3" }, | |||||
| { id: "4", label: "Task 4" }, | |||||
| { id: "5", label: "Task 5" }, | |||||
| { id: "6", label: "Task 6" }, | |||||
| { id: "7", label: "Task 7" }, | |||||
| { id: "8", label: "Task 8" }, | |||||
| { id: "9", label: "Task 9" }, | |||||
| ]} | |||||
| allItems={items} | |||||
| initiallySelectedItems={[]} | initiallySelectedItems={[]} | ||||
| onChange={() => {}} | |||||
| onChange={(selectedItems) => { | |||||
| setValue( | |||||
| "taskIds", | |||||
| selectedItems.map((item) => item.id), | |||||
| ); | |||||
| }} | |||||
| allItemsLabel={t("Task Pool")} | allItemsLabel={t("Task Pool")} | ||||
| selectedItemsLabel={t("Task List Template")} | selectedItemsLabel={t("Task List Template")} | ||||
| /> | /> | ||||
| </CardContent> | </CardContent> | ||||
| </Card> | </Card> | ||||
| {serverError && ( | |||||
| <Typography variant="body2" color="error" alignSelf="flex-end"> | |||||
| {serverError} | |||||
| </Typography> | |||||
| )} | |||||
| <Stack direction="row" justifyContent="flex-end" gap={1}> | <Stack direction="row" justifyContent="flex-end" gap={1}> | ||||
| <Button variant="outlined" startIcon={<Close />} onClick={handleCancel}> | <Button variant="outlined" startIcon={<Close />} onClick={handleCancel}> | ||||
| {t("Cancel")} | {t("Cancel")} | ||||
| </Button> | </Button> | ||||
| <Button variant="contained" startIcon={<Check />}> | |||||
| <Button | |||||
| variant="contained" | |||||
| startIcon={<Check />} | |||||
| type="submit" | |||||
| disabled={isSubmitting} | |||||
| > | |||||
| {t("Confirm")} | {t("Confirm")} | ||||
| </Button> | </Button> | ||||
| </Stack> | </Stack> | ||||
| </> | |||||
| </Stack> | |||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -0,0 +1,11 @@ | |||||
| import React from "react"; | |||||
| import CreateTaskTemplate from "./CreateTaskTemplate"; | |||||
| import { fetchAllTasks } from "@/app/api/tasks"; | |||||
| const CreateTaskTemplateWrapper: React.FC = async () => { | |||||
| const tasks = await fetchAllTasks(); | |||||
| return <CreateTaskTemplate tasks={tasks} />; | |||||
| }; | |||||
| export default CreateTaskTemplateWrapper; | |||||
| @@ -1 +1 @@ | |||||
| export { default } from "./CreateTaskTemplate"; | |||||
| export { default } from "./CreateTaskTemplateWrapper"; | |||||
| @@ -0,0 +1,17 @@ | |||||
| "use client"; | |||||
| import { signOut } from "next-auth/react"; | |||||
| import { useSearchParams } from "next/navigation"; | |||||
| import { useEffect } from "react"; | |||||
| const LogoutPage = () => { | |||||
| const params = useSearchParams(); | |||||
| const callbackUrl = params.get("callbackUrl"); | |||||
| useEffect(() => { | |||||
| signOut({ redirect: true, callbackUrl: callbackUrl || "/" }); | |||||
| }); | |||||
| return null; | |||||
| }; | |||||
| export default LogoutPage; | |||||
| @@ -0,0 +1 @@ | |||||
| export { default } from "./LogoutPage"; | |||||
| @@ -1,10 +1,11 @@ | |||||
| "use client"; | "use client"; | ||||
| import { ProjectResult } from "@/app/api/projects"; | import { ProjectResult } from "@/app/api/projects"; | ||||
| import React, { useMemo, useState } from "react"; | |||||
| import React, { useCallback, useMemo, useState } from "react"; | |||||
| import SearchBox, { Criterion } from "../SearchBox"; | import SearchBox, { Criterion } from "../SearchBox"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import SearchResults, { Column } from "../SearchResults"; | import SearchResults, { Column } from "../SearchResults"; | ||||
| import EditNote from "@mui/icons-material/EditNote"; | |||||
| interface Props { | interface Props { | ||||
| projects: ProjectResult[]; | projects: ProjectResult[]; | ||||
| @@ -45,16 +46,25 @@ const ProjectSearch: React.FC<Props> = ({ projects }) => { | |||||
| [t], | [t], | ||||
| ); | ); | ||||
| const onProjectClick = useCallback((project: ProjectResult) => { | |||||
| console.log(project); | |||||
| }, []); | |||||
| const columns = useMemo<Column<ProjectResult>[]>( | const columns = useMemo<Column<ProjectResult>[]>( | ||||
| () => [ | () => [ | ||||
| { name: "id", label: t("Details") }, | |||||
| { | |||||
| name: "id", | |||||
| label: t("Details"), | |||||
| onClick: onProjectClick, | |||||
| buttonIcon: <EditNote />, | |||||
| }, | |||||
| { name: "code", label: t("Project Code") }, | { name: "code", label: t("Project Code") }, | ||||
| { name: "name", label: t("Project Name") }, | { name: "name", label: t("Project Name") }, | ||||
| { name: "category", label: t("Project Category") }, | { name: "category", label: t("Project Category") }, | ||||
| { name: "team", label: t("Team") }, | { name: "team", label: t("Team") }, | ||||
| { name: "client", label: t("Client") }, | { name: "client", label: t("Client") }, | ||||
| ], | ], | ||||
| [t], | |||||
| [t, onProjectClick], | |||||
| ); | ); | ||||
| return ( | return ( | ||||
| @@ -35,9 +35,14 @@ export type Criterion<T extends string> = TextCriterion<T> | SelectCriterion<T>; | |||||
| interface Props<T extends string> { | interface Props<T extends string> { | ||||
| criteria: Criterion<T>[]; | criteria: Criterion<T>[]; | ||||
| onSearch: (inputs: Record<T, string>) => void; | onSearch: (inputs: Record<T, string>) => void; | ||||
| onReset?: () => void; | |||||
| } | } | ||||
| function SearchBox<T extends string>({ criteria, onSearch }: Props<T>) { | |||||
| function SearchBox<T extends string>({ | |||||
| criteria, | |||||
| onSearch, | |||||
| onReset, | |||||
| }: Props<T>) { | |||||
| const { t } = useTranslation("common"); | const { t } = useTranslation("common"); | ||||
| const defaultInputs = useMemo( | const defaultInputs = useMemo( | ||||
| () => | () => | ||||
| @@ -68,6 +73,7 @@ function SearchBox<T extends string>({ criteria, onSearch }: Props<T>) { | |||||
| const handleReset = () => { | const handleReset = () => { | ||||
| setInputs(defaultInputs); | setInputs(defaultInputs); | ||||
| onReset?.(); | |||||
| }; | }; | ||||
| const handleSearch = () => { | const handleSearch = () => { | ||||
| @@ -12,23 +12,42 @@ import TablePagination, { | |||||
| } from "@mui/material/TablePagination"; | } from "@mui/material/TablePagination"; | ||||
| import TableRow from "@mui/material/TableRow"; | import TableRow from "@mui/material/TableRow"; | ||||
| import IconButton from "@mui/material/IconButton"; | import IconButton from "@mui/material/IconButton"; | ||||
| import EditNote from "@mui/icons-material/EditNote"; | |||||
| interface ResultWithId { | |||||
| export interface ResultWithId { | |||||
| id: string | number; | id: string | number; | ||||
| } | } | ||||
| export interface Column<T extends ResultWithId> { | |||||
| interface BaseColumn<T extends ResultWithId> { | |||||
| name: keyof T; | name: keyof T; | ||||
| label: string; | label: string; | ||||
| } | } | ||||
| interface ColumnWithAction<T extends ResultWithId> extends BaseColumn<T> { | |||||
| onClick: (item: T) => void; | |||||
| buttonIcon: React.ReactNode; | |||||
| } | |||||
| export type Column<T extends ResultWithId> = | |||||
| | BaseColumn<T> | |||||
| | ColumnWithAction<T>; | |||||
| interface Props<T extends ResultWithId> { | interface Props<T extends ResultWithId> { | ||||
| items: T[]; | items: T[]; | ||||
| columns: Column<T>[]; | columns: Column<T>[]; | ||||
| noWrapper?: boolean; | |||||
| } | } | ||||
| function SearchResults<T extends ResultWithId>({ items, columns }: Props<T>) { | |||||
| function isActionColumn<T extends ResultWithId>( | |||||
| column: Column<T>, | |||||
| ): column is ColumnWithAction<T> { | |||||
| return Boolean((column as ColumnWithAction<T>).onClick); | |||||
| } | |||||
| function SearchResults<T extends ResultWithId>({ | |||||
| items, | |||||
| columns, | |||||
| noWrapper, | |||||
| }: Props<T>) { | |||||
| const [page, setPage] = React.useState(0); | const [page, setPage] = React.useState(0); | ||||
| const [rowsPerPage, setRowsPerPage] = React.useState(10); | const [rowsPerPage, setRowsPerPage] = React.useState(10); | ||||
| @@ -46,14 +65,14 @@ function SearchResults<T extends ResultWithId>({ items, columns }: Props<T>) { | |||||
| setPage(0); | setPage(0); | ||||
| }; | }; | ||||
| return ( | |||||
| <Paper sx={{ overflow: "hidden" }}> | |||||
| const table = ( | |||||
| <> | |||||
| <TableContainer sx={{ maxHeight: 440 }}> | <TableContainer sx={{ maxHeight: 440 }}> | ||||
| <Table stickyHeader> | <Table stickyHeader> | ||||
| <TableHead> | <TableHead> | ||||
| <TableRow> | <TableRow> | ||||
| {columns.map((column) => ( | |||||
| <TableCell key={column.name.toString()}> | |||||
| {columns.map((column, idx) => ( | |||||
| <TableCell key={`${column.name.toString()}${idx}`}> | |||||
| {column.label} | {column.label} | ||||
| </TableCell> | </TableCell> | ||||
| ))} | ))} | ||||
| @@ -65,12 +84,17 @@ function SearchResults<T extends ResultWithId>({ items, columns }: Props<T>) { | |||||
| .map((item) => { | .map((item) => { | ||||
| return ( | return ( | ||||
| <TableRow hover tabIndex={-1} key={item.id}> | <TableRow hover tabIndex={-1} key={item.id}> | ||||
| {columns.map(({ name: columnName }) => { | |||||
| {columns.map((column, idx) => { | |||||
| const columnName = column.name; | |||||
| return ( | return ( | ||||
| <TableCell key={columnName.toString()}> | |||||
| {columnName === "id" ? ( | |||||
| <IconButton color="primary"> | |||||
| <EditNote /> | |||||
| <TableCell key={`${columnName.toString()}-${idx}`}> | |||||
| {isActionColumn(column) ? ( | |||||
| <IconButton | |||||
| color="primary" | |||||
| onClick={() => column.onClick(item)} | |||||
| > | |||||
| {column.buttonIcon} | |||||
| </IconButton> | </IconButton> | ||||
| ) : ( | ) : ( | ||||
| <>{item[columnName]}</> | <>{item[columnName]}</> | ||||
| @@ -93,8 +117,10 @@ function SearchResults<T extends ResultWithId>({ items, columns }: Props<T>) { | |||||
| onPageChange={handleChangePage} | onPageChange={handleChangePage} | ||||
| onRowsPerPageChange={handleChangeRowsPerPage} | onRowsPerPageChange={handleChangeRowsPerPage} | ||||
| /> | /> | ||||
| </Paper> | |||||
| </> | |||||
| ); | ); | ||||
| return noWrapper ? table : <Paper sx={{ overflow: "hidden" }}>{table}</Paper>; | |||||
| } | } | ||||
| export default SearchResults; | export default SearchResults; | ||||
| @@ -1,24 +1,23 @@ | |||||
| "use client"; | "use client"; | ||||
| import { TaskTemplateResult } from "@/app/api/tasks"; | |||||
| import React, { useMemo, useState } from "react"; | |||||
| import { TaskTemplate } from "@/app/api/tasks"; | |||||
| import React, { useCallback, useMemo, useState } from "react"; | |||||
| import SearchBox, { Criterion } from "../SearchBox"; | import SearchBox, { Criterion } from "../SearchBox"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import SearchResults, { Column } from "../SearchResults"; | import SearchResults, { Column } from "../SearchResults"; | ||||
| import EditNote from "@mui/icons-material/EditNote"; | |||||
| interface Props { | interface Props { | ||||
| taskTemplates: TaskTemplateResult[]; | |||||
| taskTemplates: TaskTemplate[]; | |||||
| } | } | ||||
| type SearchQuery = Partial<Omit<TaskTemplateResult, "id">>; | |||||
| type SearchQuery = Partial<Omit<TaskTemplate, "id">>; | |||||
| type SearchParamNames = keyof SearchQuery; | type SearchParamNames = keyof SearchQuery; | ||||
| const TaskTemplateSearch: React.FC<Props> = ({ taskTemplates }) => { | const TaskTemplateSearch: React.FC<Props> = ({ taskTemplates }) => { | ||||
| const { t } = useTranslation("tasks"); | const { t } = useTranslation("tasks"); | ||||
| // If task searching is done on the server-side, then no need for this. | |||||
| const [filteredTemplates, setFilteredTemplates] = useState(taskTemplates); | const [filteredTemplates, setFilteredTemplates] = useState(taskTemplates); | ||||
| const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | ||||
| () => [ | () => [ | ||||
| { label: t("Task Template Code"), paramName: "code", type: "text" }, | { label: t("Task Template Code"), paramName: "code", type: "text" }, | ||||
| @@ -26,14 +25,26 @@ const TaskTemplateSearch: React.FC<Props> = ({ taskTemplates }) => { | |||||
| ], | ], | ||||
| [t], | [t], | ||||
| ); | ); | ||||
| const onReset = useCallback(() => { | |||||
| setFilteredTemplates(taskTemplates); | |||||
| }, [taskTemplates]); | |||||
| const onTaskClick = useCallback((taskTemplate: TaskTemplate) => { | |||||
| console.log(taskTemplate); | |||||
| }, []); | |||||
| const columns = useMemo<Column<TaskTemplateResult>[]>( | |||||
| const columns = useMemo<Column<TaskTemplate>[]>( | |||||
| () => [ | () => [ | ||||
| { name: "id", label: t("Details") }, | |||||
| { | |||||
| name: "id", | |||||
| label: t("Details"), | |||||
| onClick: onTaskClick, | |||||
| buttonIcon: <EditNote />, | |||||
| }, | |||||
| { name: "code", label: t("Task Template Code") }, | { name: "code", label: t("Task Template Code") }, | ||||
| { name: "name", label: t("Task Template Name") }, | { name: "name", label: t("Task Template Name") }, | ||||
| ], | ], | ||||
| [t], | |||||
| [onTaskClick, t], | |||||
| ); | ); | ||||
| return ( | return ( | ||||
| @@ -41,8 +52,15 @@ const TaskTemplateSearch: React.FC<Props> = ({ taskTemplates }) => { | |||||
| <SearchBox | <SearchBox | ||||
| criteria={searchCriteria} | criteria={searchCriteria} | ||||
| onSearch={(query) => { | onSearch={(query) => { | ||||
| console.log(query); | |||||
| setFilteredTemplates( | |||||
| taskTemplates.filter( | |||||
| (task) => | |||||
| task.code.toLowerCase().includes(query.code) && | |||||
| task.name.toLowerCase().includes(query.name), | |||||
| ), | |||||
| ); | |||||
| }} | }} | ||||
| onReset={onReset} | |||||
| /> | /> | ||||
| <SearchResults items={filteredTemplates} columns={columns} /> | <SearchResults items={filteredTemplates} columns={columns} /> | ||||
| </> | </> | ||||
| @@ -15,8 +15,11 @@ import { | |||||
| Stack, | Stack, | ||||
| Typography, | Typography, | ||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import React, { useCallback, useState } from "react"; | |||||
| import { LabelWithId, TransferListProps } from "./TransferList"; | |||||
| import React, { useCallback, useEffect, useState } from "react"; | |||||
| import { LabelGroup, LabelWithId, TransferListProps } from "./TransferList"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import uniqBy from "lodash/uniqBy"; | |||||
| import groupBy from "lodash/groupBy"; | |||||
| export const MultiSelectList: React.FC<TransferListProps> = ({ | export const MultiSelectList: React.FC<TransferListProps> = ({ | ||||
| allItems, | allItems, | ||||
| @@ -33,7 +36,7 @@ export const MultiSelectList: React.FC<TransferListProps> = ({ | |||||
| ); | ); | ||||
| }, [allItems]); | }, [allItems]); | ||||
| const compareFn = React.useCallback( | const compareFn = React.useCallback( | ||||
| (a: string, b: string) => sortMap[a].index - sortMap[b].index, | |||||
| (a: number, b: number) => sortMap[a].index - sortMap[b].index, | |||||
| [sortMap], | [sortMap], | ||||
| ); | ); | ||||
| const [selectedItems, setSelectedItems] = useState( | const [selectedItems, setSelectedItems] = useState( | ||||
| @@ -45,7 +48,7 @@ export const MultiSelectList: React.FC<TransferListProps> = ({ | |||||
| const { | const { | ||||
| target: { value }, | target: { value }, | ||||
| } = event; | } = event; | ||||
| setSelectedItems(typeof value === "string" ? [value] : value); | |||||
| setSelectedItems(typeof value === "string" ? [Number(value)] : value); | |||||
| }, []); | }, []); | ||||
| const handleToggleAll = useCallback( | const handleToggleAll = useCallback( | ||||
| @@ -59,6 +62,23 @@ export const MultiSelectList: React.FC<TransferListProps> = ({ | |||||
| [allItems, selectedItems.length], | [allItems, selectedItems.length], | ||||
| ); | ); | ||||
| useEffect(() => { | |||||
| onChange(selectedItems.map((item) => sortMap[item])); | |||||
| }, [onChange, selectedItems, sortMap]); | |||||
| const { t } = useTranslation(); | |||||
| const groups: LabelGroup[] = uniqBy( | |||||
| [ | |||||
| ...allItems.reduce<LabelGroup[]>((acc, item) => { | |||||
| return item.group ? [...acc, item.group] : acc; | |||||
| }, []), | |||||
| // Items with no group | |||||
| { id: 0, name: t("Ungrouped") }, | |||||
| ], | |||||
| "id", | |||||
| ); | |||||
| const groupedItems = groupBy(allItems, (item) => item.group?.id ?? 0); | |||||
| return ( | return ( | ||||
| <Box> | <Box> | ||||
| <FormControl fullWidth> | <FormControl fullWidth> | ||||
| @@ -121,18 +141,28 @@ export const MultiSelectList: React.FC<TransferListProps> = ({ | |||||
| </Stack> | </Stack> | ||||
| <Divider /> | <Divider /> | ||||
| </ListSubheader> | </ListSubheader> | ||||
| {allItems.map((item) => { | |||||
| return ( | |||||
| <MenuItem key={item.id} value={item.id} disableRipple> | |||||
| <Checkbox | |||||
| checked={selectedItems.includes(item.id)} | |||||
| disableRipple | |||||
| /> | |||||
| <ListItemText sx={{ whiteSpace: "normal" }}> | |||||
| {item.label} | |||||
| </ListItemText> | |||||
| </MenuItem> | |||||
| ); | |||||
| {groups.flatMap((group) => { | |||||
| const groupItems = groupedItems[group.id]; | |||||
| if (!groupItems) return null; | |||||
| return [ | |||||
| <ListSubheader disableSticky key={`${group.id}-${group.name}`}> | |||||
| {group.name} | |||||
| </ListSubheader>, | |||||
| ...groupItems.map((item) => { | |||||
| return ( | |||||
| <MenuItem key={item.id} value={item.id} disableRipple> | |||||
| <Checkbox | |||||
| checked={selectedItems.includes(item.id)} | |||||
| disableRipple | |||||
| /> | |||||
| <ListItemText sx={{ whiteSpace: "normal" }}> | |||||
| {item.label} | |||||
| </ListItemText> | |||||
| </MenuItem> | |||||
| ); | |||||
| }), | |||||
| ]; | |||||
| })} | })} | ||||
| </Select> | </Select> | ||||
| </FormControl> | </FormControl> | ||||
| @@ -16,16 +16,25 @@ import Stack from "@mui/material/Stack"; | |||||
| import Paper from "@mui/material/Paper"; | import Paper from "@mui/material/Paper"; | ||||
| import Typography from "@mui/material/Typography"; | import Typography from "@mui/material/Typography"; | ||||
| import ListSubheader from "@mui/material/ListSubheader"; | import ListSubheader from "@mui/material/ListSubheader"; | ||||
| import groupBy from "lodash/groupBy"; | |||||
| import uniqBy from "lodash/uniqBy"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| export interface LabelGroup { | |||||
| id: number; | |||||
| name: string; | |||||
| } | |||||
| export interface LabelWithId { | export interface LabelWithId { | ||||
| id: string; | |||||
| id: number; | |||||
| label: string; | label: string; | ||||
| group?: LabelGroup; | |||||
| } | } | ||||
| export interface TransferListProps { | export interface TransferListProps { | ||||
| allItems: LabelWithId[]; | allItems: LabelWithId[]; | ||||
| initiallySelectedItems: LabelWithId[]; | initiallySelectedItems: LabelWithId[]; | ||||
| onChange: () => void; | |||||
| onChange: (selectedItems: LabelWithId[]) => void; | |||||
| allItemsLabel: string; | allItemsLabel: string; | ||||
| selectedItemsLabel: string; | selectedItemsLabel: string; | ||||
| } | } | ||||
| @@ -48,6 +57,19 @@ const ItemList: React.FC<ItemListProps> = ({ | |||||
| handleToggle, | handleToggle, | ||||
| handleToggleAll, | handleToggleAll, | ||||
| }) => { | }) => { | ||||
| const { t } = useTranslation(); | |||||
| const groups: LabelGroup[] = uniqBy( | |||||
| [ | |||||
| ...items.reduce<LabelGroup[]>((acc, item) => { | |||||
| return item.group ? [...acc, item.group] : acc; | |||||
| }, []), | |||||
| // Items with no group | |||||
| { id: 0, name: t("Ungrouped") }, | |||||
| ], | |||||
| "id", | |||||
| ); | |||||
| const groupedItems = groupBy(items, (item) => item.group?.id ?? 0); | |||||
| return ( | return ( | ||||
| <Paper sx={{ width: "100%" }} variant="outlined"> | <Paper sx={{ width: "100%" }} variant="outlined"> | ||||
| <List | <List | ||||
| @@ -87,14 +109,32 @@ const ItemList: React.FC<ItemListProps> = ({ | |||||
| </ListSubheader> | </ListSubheader> | ||||
| } | } | ||||
| > | > | ||||
| {items.map((item) => { | |||||
| {groups.map((group) => { | |||||
| const groupItems = groupedItems[group.id]; | |||||
| if (!groupItems) return null; | |||||
| return ( | return ( | ||||
| <ListItem key={item.id} onClick={handleToggle(item)}> | |||||
| <ListItemIcon> | |||||
| <Checkbox checked={checkedItems.includes(item)} tabIndex={-1} /> | |||||
| </ListItemIcon> | |||||
| <ListItemText primary={item.label} /> | |||||
| </ListItem> | |||||
| <React.Fragment key={group.id}> | |||||
| <ListSubheader | |||||
| disableSticky | |||||
| sx={{ paddingBlock: 2, lineHeight: 1.8 }} | |||||
| > | |||||
| {group.name} | |||||
| </ListSubheader> | |||||
| {groupItems.map((item) => { | |||||
| return ( | |||||
| <ListItem key={item.id} onClick={handleToggle(item)}> | |||||
| <ListItemIcon> | |||||
| <Checkbox | |||||
| checked={checkedItems.includes(item)} | |||||
| tabIndex={-1} | |||||
| /> | |||||
| </ListItemIcon> | |||||
| <ListItemText primary={item.label} /> | |||||
| </ListItem> | |||||
| ); | |||||
| })} | |||||
| </React.Fragment> | |||||
| ); | ); | ||||
| })} | })} | ||||
| </List> | </List> | ||||
| @@ -167,6 +207,10 @@ const TransferList: React.FC<TransferListProps> = ({ | |||||
| setCheckedList(difference(checkedList, rightListChecked)); | setCheckedList(difference(checkedList, rightListChecked)); | ||||
| }; | }; | ||||
| React.useEffect(() => { | |||||
| onChange(rightList); | |||||
| }, [onChange, rightList]); | |||||
| return ( | return ( | ||||
| <Stack spacing={2} direction="row" alignItems="center" position="relative"> | <Stack spacing={2} direction="row" alignItems="center" position="relative"> | ||||
| <ItemList | <ItemList | ||||
| @@ -1,2 +1,2 @@ | |||||
| export const BASE_API_URL = `${process.env.API_PROTOCOL}://${process.env.API_HOST}:${process.env.API_PORT}`; | |||||
| export const LOGIN_API_PATH = `${BASE_API_URL}/api/login`; | |||||
| export const BASE_API_URL = `${process.env.API_PROTOCOL}://${process.env.API_HOST}:${process.env.API_PORT}/api`; | |||||
| export const LOGIN_API_PATH = `${BASE_API_URL}/login`; | |||||
| @@ -2,7 +2,7 @@ import { AuthOptions, Session } from "next-auth"; | |||||
| import CredentialsProvider from "next-auth/providers/credentials"; | import CredentialsProvider from "next-auth/providers/credentials"; | ||||
| import { LOGIN_API_PATH } from "./api"; | import { LOGIN_API_PATH } from "./api"; | ||||
| interface SessionWithTokens extends Session { | |||||
| export interface SessionWithTokens extends Session { | |||||
| accessToken?: string; | accessToken?: string; | ||||
| refreshToken?: string; | refreshToken?: string; | ||||
| } | } | ||||
| @@ -2,7 +2,7 @@ import { NextRequestWithAuth, withAuth } from "next-auth/middleware"; | |||||
| import { authOptions } from "@/config/authConfig"; | import { authOptions } from "@/config/authConfig"; | ||||
| import { NextFetchEvent, NextResponse } from "next/server"; | import { NextFetchEvent, NextResponse } from "next/server"; | ||||
| const PUBLIC_ROUTES = ["/login"]; | |||||
| const PUBLIC_ROUTES = ["/login", "/logout"]; | |||||
| const LANG_QUERY_PARAM = "lang"; | const LANG_QUERY_PARAM = "lang"; | ||||
| const authMiddleware = withAuth({ | const authMiddleware = withAuth({ | ||||