projects
em main
1 ano atrás
@@ -27,7 +27,7 @@ export default async function MainLayout({ | |||
<Box | |||
component="main" | |||
sx={{ | |||
marginInlineStart: { xs: 0, lg: NAVIGATION_CONTENT_WIDTH }, | |||
marginInlineStart: { xs: 0, xl: NAVIGATION_CONTENT_WIDTH }, | |||
padding: { xs: "1rem", sm: "1.5rem", lg: "3rem" }, | |||
}} | |||
> | |||
@@ -1,6 +1,5 @@ | |||
import { preloadProjects } from "@/app/api/projects"; | |||
import ProjectSearch from "@/components/ProjectSearch"; | |||
import ProgressByClientSearch from "@/components/ProgressByClientSearch"; | |||
import { getServerI18n } from "@/i18n"; | |||
import Add from "@mui/icons-material/Add"; | |||
import Button from "@mui/material/Button"; | |||
@@ -1,3 +1,4 @@ | |||
import { preloadAllTasks } from "@/app/api/tasks"; | |||
import CreateTaskTemplate from "@/components/CreateTaskTemplate"; | |||
import { getServerI18n } from "@/i18n"; | |||
import Typography from "@mui/material/Typography"; | |||
@@ -9,6 +10,7 @@ export const metadata: Metadata = { | |||
const Projects: React.FC = async () => { | |||
const { t } = await getServerI18n("tasks"); | |||
preloadAllTasks(); | |||
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 "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; | |||
code: string; | |||
name: string; | |||
@@ -12,23 +26,13 @@ export const preloadTaskTemplates = () => { | |||
}; | |||
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)}` : ""}`, | |||
); | |||
}; |
@@ -18,7 +18,7 @@ const NavigationToggle: React.FC = () => { | |||
return ( | |||
<> | |||
<Drawer variant="permanent" sx={{ display: { xs: "none", xl: "block" } }}> | |||
<NavigationContent/> | |||
<NavigationContent /> | |||
</Drawer> | |||
<Drawer | |||
sx={{ display: { xl: "none" } }} | |||
@@ -28,7 +28,7 @@ const NavigationToggle: React.FC = () => { | |||
keepMounted: true, | |||
}} | |||
> | |||
<NavigationContent/> | |||
<NavigationContent /> | |||
</Drawer> | |||
<IconButton | |||
sx={{ display: { xl: "none" } }} | |||
@@ -11,6 +11,8 @@ import React, { useCallback, useState } from "react"; | |||
import { useTranslation } from "react-i18next"; | |||
import ProjectClientDetails from "./ProjectClientDetails"; | |||
import TaskSetup from "./TaskSetup"; | |||
import StaffAllocation from "./StaffAllocation"; | |||
import ResourceMilestone from "./ResourceMilestone"; | |||
const CreateProject: React.FC = () => { | |||
const [tabIndex, setTabIndex] = useState(0); | |||
@@ -38,6 +40,8 @@ const CreateProject: React.FC = () => { | |||
</Tabs> | |||
{tabIndex === 0 && <ProjectClientDetails />} | |||
{tabIndex === 1 && <TaskSetup />} | |||
{tabIndex === 2 && <StaffAllocation initiallySelectedStaff={[]} />} | |||
{tabIndex === 3 && <ResourceMilestone />} | |||
<Stack direction="row" justifyContent="flex-end" gap={1}> | |||
<Button variant="outlined" startIcon={<Close />} onClick={handleCancel}> | |||
{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 React from "react"; | |||
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 router = useRouter(); | |||
@@ -22,8 +32,40 @@ const CreateTaskTemplate = () => { | |||
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 ( | |||
<> | |||
<Stack component="form" onSubmit={handleSubmit(onSubmit)} gap={2}> | |||
<Card> | |||
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}> | |||
<Typography variant="overline">{t("Task List Setup")}</Typography> | |||
@@ -34,40 +76,61 @@ const CreateTaskTemplate = () => { | |||
marginBlockEnd={1} | |||
> | |||
<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 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> | |||
<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={[]} | |||
onChange={() => {}} | |||
onChange={(selectedItems) => { | |||
setValue( | |||
"taskIds", | |||
selectedItems.map((item) => item.id), | |||
); | |||
}} | |||
allItemsLabel={t("Task Pool")} | |||
selectedItemsLabel={t("Task List Template")} | |||
/> | |||
</CardContent> | |||
</Card> | |||
{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 />}> | |||
<Button | |||
variant="contained" | |||
startIcon={<Check />} | |||
type="submit" | |||
disabled={isSubmitting} | |||
> | |||
{t("Confirm")} | |||
</Button> | |||
</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"; | |||
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 { useTranslation } from "react-i18next"; | |||
import SearchResults, { Column } from "../SearchResults"; | |||
import EditNote from "@mui/icons-material/EditNote"; | |||
interface Props { | |||
projects: ProjectResult[]; | |||
@@ -45,16 +46,25 @@ const ProjectSearch: React.FC<Props> = ({ projects }) => { | |||
[t], | |||
); | |||
const onProjectClick = useCallback((project: ProjectResult) => { | |||
console.log(project); | |||
}, []); | |||
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: "name", label: t("Project Name") }, | |||
{ name: "category", label: t("Project Category") }, | |||
{ name: "team", label: t("Team") }, | |||
{ name: "client", label: t("Client") }, | |||
], | |||
[t], | |||
[t, onProjectClick], | |||
); | |||
return ( | |||
@@ -35,9 +35,14 @@ export type Criterion<T extends string> = TextCriterion<T> | SelectCriterion<T>; | |||
interface Props<T extends string> { | |||
criteria: Criterion<T>[]; | |||
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 defaultInputs = useMemo( | |||
() => | |||
@@ -68,6 +73,7 @@ function SearchBox<T extends string>({ criteria, onSearch }: Props<T>) { | |||
const handleReset = () => { | |||
setInputs(defaultInputs); | |||
onReset?.(); | |||
}; | |||
const handleSearch = () => { | |||
@@ -77,7 +83,7 @@ function SearchBox<T extends string>({ criteria, onSearch }: Props<T>) { | |||
return ( | |||
<Card> | |||
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}> | |||
<Typography variant="overline" style={{fontWeight:"600"}}>{t("Search Criteria")}</Typography> | |||
<Typography variant="overline">{t("Search Criteria")}</Typography> | |||
<Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | |||
{criteria.map((c) => { | |||
return ( | |||
@@ -12,23 +12,42 @@ import TablePagination, { | |||
} from "@mui/material/TablePagination"; | |||
import TableRow from "@mui/material/TableRow"; | |||
import IconButton from "@mui/material/IconButton"; | |||
import EditNote from "@mui/icons-material/EditNote"; | |||
interface ResultWithId { | |||
export interface ResultWithId { | |||
id: string | number; | |||
} | |||
export interface Column<T extends ResultWithId> { | |||
interface BaseColumn<T extends ResultWithId> { | |||
name: keyof T; | |||
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> { | |||
items: 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 [rowsPerPage, setRowsPerPage] = React.useState(10); | |||
@@ -46,14 +65,14 @@ function SearchResults<T extends ResultWithId>({ items, columns }: Props<T>) { | |||
setPage(0); | |||
}; | |||
return ( | |||
<Paper sx={{ overflow: "hidden" }}> | |||
const table = ( | |||
<> | |||
<TableContainer sx={{ maxHeight: 440 }}> | |||
<Table stickyHeader> | |||
<TableHead> | |||
<TableRow> | |||
{columns.map((column) => ( | |||
<TableCell key={column.name.toString()}> | |||
{columns.map((column, idx) => ( | |||
<TableCell key={`${column.name.toString()}${idx}`}> | |||
{column.label} | |||
</TableCell> | |||
))} | |||
@@ -65,12 +84,17 @@ function SearchResults<T extends ResultWithId>({ items, columns }: Props<T>) { | |||
.map((item) => { | |||
return ( | |||
<TableRow hover tabIndex={-1} key={item.id}> | |||
{columns.map(({ name: columnName }) => { | |||
{columns.map((column, idx) => { | |||
const columnName = column.name; | |||
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> | |||
) : ( | |||
<>{item[columnName]}</> | |||
@@ -93,8 +117,10 @@ function SearchResults<T extends ResultWithId>({ items, columns }: Props<T>) { | |||
onPageChange={handleChangePage} | |||
onRowsPerPageChange={handleChangeRowsPerPage} | |||
/> | |||
</Paper> | |||
</> | |||
); | |||
return noWrapper ? table : <Paper sx={{ overflow: "hidden" }}>{table}</Paper>; | |||
} | |||
export default SearchResults; |
@@ -1,24 +1,23 @@ | |||
"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 { useTranslation } from "react-i18next"; | |||
import SearchResults, { Column } from "../SearchResults"; | |||
import EditNote from "@mui/icons-material/EditNote"; | |||
interface Props { | |||
taskTemplates: TaskTemplateResult[]; | |||
taskTemplates: TaskTemplate[]; | |||
} | |||
type SearchQuery = Partial<Omit<TaskTemplateResult, "id">>; | |||
type SearchQuery = Partial<Omit<TaskTemplate, "id">>; | |||
type SearchParamNames = keyof SearchQuery; | |||
const TaskTemplateSearch: React.FC<Props> = ({ taskTemplates }) => { | |||
const { t } = useTranslation("tasks"); | |||
// If task searching is done on the server-side, then no need for this. | |||
const [filteredTemplates, setFilteredTemplates] = useState(taskTemplates); | |||
const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | |||
() => [ | |||
{ label: t("Task Template Code"), paramName: "code", type: "text" }, | |||
@@ -26,14 +25,26 @@ const TaskTemplateSearch: React.FC<Props> = ({ taskTemplates }) => { | |||
], | |||
[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: "name", label: t("Task Template Name") }, | |||
], | |||
[t], | |||
[onTaskClick, t], | |||
); | |||
return ( | |||
@@ -41,8 +52,15 @@ const TaskTemplateSearch: React.FC<Props> = ({ taskTemplates }) => { | |||
<SearchBox | |||
criteria={searchCriteria} | |||
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} /> | |||
</> | |||
@@ -15,8 +15,11 @@ import { | |||
Stack, | |||
Typography, | |||
} 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> = ({ | |||
allItems, | |||
@@ -33,7 +36,7 @@ export const MultiSelectList: React.FC<TransferListProps> = ({ | |||
); | |||
}, [allItems]); | |||
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], | |||
); | |||
const [selectedItems, setSelectedItems] = useState( | |||
@@ -45,7 +48,7 @@ export const MultiSelectList: React.FC<TransferListProps> = ({ | |||
const { | |||
target: { value }, | |||
} = event; | |||
setSelectedItems(typeof value === "string" ? [value] : value); | |||
setSelectedItems(typeof value === "string" ? [Number(value)] : value); | |||
}, []); | |||
const handleToggleAll = useCallback( | |||
@@ -59,6 +62,23 @@ export const MultiSelectList: React.FC<TransferListProps> = ({ | |||
[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 ( | |||
<Box> | |||
<FormControl fullWidth> | |||
@@ -121,18 +141,28 @@ export const MultiSelectList: React.FC<TransferListProps> = ({ | |||
</Stack> | |||
<Divider /> | |||
</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> | |||
</FormControl> | |||
@@ -16,16 +16,25 @@ import Stack from "@mui/material/Stack"; | |||
import Paper from "@mui/material/Paper"; | |||
import Typography from "@mui/material/Typography"; | |||
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 { | |||
id: string; | |||
id: number; | |||
label: string; | |||
group?: LabelGroup; | |||
} | |||
export interface TransferListProps { | |||
allItems: LabelWithId[]; | |||
initiallySelectedItems: LabelWithId[]; | |||
onChange: () => void; | |||
onChange: (selectedItems: LabelWithId[]) => void; | |||
allItemsLabel: string; | |||
selectedItemsLabel: string; | |||
} | |||
@@ -48,6 +57,19 @@ const ItemList: React.FC<ItemListProps> = ({ | |||
handleToggle, | |||
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 ( | |||
<Paper sx={{ width: "100%" }} variant="outlined"> | |||
<List | |||
@@ -87,14 +109,32 @@ const ItemList: React.FC<ItemListProps> = ({ | |||
</ListSubheader> | |||
} | |||
> | |||
{items.map((item) => { | |||
{groups.map((group) => { | |||
const groupItems = groupedItems[group.id]; | |||
if (!groupItems) return null; | |||
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> | |||
@@ -167,6 +207,10 @@ const TransferList: React.FC<TransferListProps> = ({ | |||
setCheckedList(difference(checkedList, rightListChecked)); | |||
}; | |||
React.useEffect(() => { | |||
onChange(rightList); | |||
}, [onChange, rightList]); | |||
return ( | |||
<Stack spacing={2} direction="row" alignItems="center" position="relative"> | |||
<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 { LOGIN_API_PATH } from "./api"; | |||
interface SessionWithTokens extends Session { | |||
export interface SessionWithTokens extends Session { | |||
accessToken?: string; | |||
refreshToken?: string; | |||
} | |||
@@ -2,7 +2,7 @@ import { NextRequestWithAuth, withAuth } from "next-auth/middleware"; | |||
import { authOptions } from "@/config/authConfig"; | |||
import { NextFetchEvent, NextResponse } from "next/server"; | |||
const PUBLIC_ROUTES = ["/login"]; | |||
const PUBLIC_ROUTES = ["/login", "/logout"]; | |||
const LANG_QUERY_PARAM = "lang"; | |||
const authMiddleware = withAuth({ | |||
@@ -189,7 +189,7 @@ const components: ThemeOptions["components"] = { | |||
}, | |||
[`&.Mui-focused`]: { | |||
backgroundColor: "transparent", | |||
borderColor: "palette.primary.main", | |||
borderColor: palette.primary.main, | |||
boxShadow: `${palette.primary.main} 0 0 0 2px`, | |||
}, | |||
[`&.Mui-error`]: { | |||
@@ -216,7 +216,7 @@ const components: ThemeOptions["components"] = { | |||
[`&.Mui-focused`]: { | |||
backgroundColor: "transparent", | |||
[`& .MuiOutlinedInput-notchedOutline`]: { | |||
borderColor: "palette.primary.main", | |||
borderColor: palette.primary.main, | |||
boxShadow: `${palette.primary.main} 0 0 0 2px`, | |||
}, | |||
}, | |||