@@ -1,15 +1,24 @@ | |||
import { Metadata } from "next"; | |||
import { I18nProvider } from "@/i18n"; | |||
import UserWorkspacePage from "@/components/UserWorkspacePage"; | |||
import { fetchTimesheets } from "@/app/api/timesheets"; | |||
import { authOptions } from "@/config/authConfig"; | |||
import { getServerSession } from "next-auth"; | |||
export const metadata: Metadata = { | |||
title: "User Workspace", | |||
}; | |||
const Home: React.FC = async () => { | |||
const session = await getServerSession(authOptions); | |||
// Get name for caching | |||
const username = session!.user!.name!; | |||
await fetchTimesheets(username); | |||
return ( | |||
<I18nProvider namespaces={["home"]}> | |||
<UserWorkspacePage /> | |||
<UserWorkspacePage username={username} /> | |||
</I18nProvider> | |||
); | |||
}; | |||
@@ -31,10 +31,10 @@ export default async function MainLayout({ | |||
padding: { xs: "1rem", sm: "1.5rem", lg: "3rem" }, | |||
}} | |||
> | |||
<Stack spacing={2}> | |||
<Breadcrumb /> | |||
{children} | |||
</Stack> | |||
<Stack spacing={2}> | |||
<Breadcrumb /> | |||
{children} | |||
</Stack> | |||
</Box> | |||
</> | |||
); | |||
@@ -1,13 +1,16 @@ | |||
"use server"; | |||
import { serverFetchJson, serverFetchWithNoContent } from "@/app/utils/fetchUtil"; | |||
import { | |||
serverFetchJson, | |||
serverFetchWithNoContent, | |||
} from "@/app/utils/fetchUtil"; | |||
import { BASE_API_URL } from "@/config/api"; | |||
import { Task, TaskGroup } from "../tasks"; | |||
import { Customer } from "../customer"; | |||
import { revalidateTag } from "next/cache"; | |||
export interface CreateProjectInputs { | |||
// Project | |||
// Project | |||
projectId: number | null; | |||
projectDeleted: boolean | null; | |||
projectCode: string; | |||
@@ -67,19 +70,22 @@ export interface PaymentInputs { | |||
} | |||
export interface CreateProjectResponse { | |||
id: number, | |||
name: string, | |||
code: string, | |||
category: string, | |||
team: string, | |||
client: string, | |||
id: number; | |||
name: string; | |||
code: string; | |||
category: string; | |||
team: string; | |||
client: string; | |||
} | |||
export const saveProject = async (data: CreateProjectInputs) => { | |||
const newProject = await serverFetchJson<CreateProjectResponse>(`${BASE_API_URL}/projects/new`, { | |||
method: "POST", | |||
body: JSON.stringify(data), | |||
headers: { "Content-Type": "application/json" }, | |||
}); | |||
const newProject = await serverFetchJson<CreateProjectResponse>( | |||
`${BASE_API_URL}/projects/new`, | |||
{ | |||
method: "POST", | |||
body: JSON.stringify(data), | |||
headers: { "Content-Type": "application/json" }, | |||
}, | |||
); | |||
revalidateTag("projects"); | |||
return newProject; | |||
@@ -94,5 +100,7 @@ export const deleteProject = async (id: number) => { | |||
}, | |||
); | |||
return project | |||
revalidateTag("projects"); | |||
revalidateTag("assignedProjects"); | |||
return project; | |||
}; |
@@ -1,9 +1,13 @@ | |||
"use server"; | |||
import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||
import { ProjectResult } from "../projects"; | |||
import { Task, TaskGroup } from "../tasks"; | |||
import { BASE_API_URL } from "@/config/api"; | |||
import { revalidateTag } from "next/cache"; | |||
export interface TimeEntry { | |||
id: number; | |||
projectId: ProjectResult["id"]; | |||
taskGroupId: TaskGroup["id"]; | |||
taskId: Task["id"]; | |||
@@ -13,3 +17,21 @@ export interface TimeEntry { | |||
export interface RecordTimesheetInput { | |||
[date: string]: TimeEntry[]; | |||
} | |||
export const saveTimesheet = async ( | |||
data: RecordTimesheetInput, | |||
username: string, | |||
) => { | |||
const savedRecords = await serverFetchJson<RecordTimesheetInput>( | |||
`${BASE_API_URL}/timesheets/save`, | |||
{ | |||
method: "POST", | |||
body: JSON.stringify(data), | |||
headers: { "Content-Type": "application/json" }, | |||
}, | |||
); | |||
revalidateTag(`timesheets_${username}`); | |||
return savedRecords; | |||
}; |
@@ -0,0 +1,10 @@ | |||
import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||
import { BASE_API_URL } from "@/config/api"; | |||
import { cache } from "react"; | |||
import { RecordTimesheetInput } from "./actions"; | |||
export const fetchTimesheets = cache(async (username: string) => { | |||
return serverFetchJson<RecordTimesheetInput>(`${BASE_API_URL}/timesheets`, { | |||
next: { tags: [`timesheets_${username}`] }, | |||
}); | |||
}); |
@@ -1,6 +1,6 @@ | |||
"use client"; | |||
import DoneIcon from '@mui/icons-material/Done' | |||
import DoneIcon from "@mui/icons-material/Done"; | |||
import Check from "@mui/icons-material/Check"; | |||
import Close from "@mui/icons-material/Close"; | |||
import Button from "@mui/material/Button"; | |||
@@ -22,7 +22,11 @@ import { | |||
SubmitHandler, | |||
useForm, | |||
} from "react-hook-form"; | |||
import { CreateProjectInputs, deleteProject, saveProject } from "@/app/api/projects/actions"; | |||
import { | |||
CreateProjectInputs, | |||
deleteProject, | |||
saveProject, | |||
} from "@/app/api/projects/actions"; | |||
import { Delete, Error, PlayArrow } from "@mui/icons-material"; | |||
import { | |||
BuildingType, | |||
@@ -38,7 +42,12 @@ import { Typography } from "@mui/material"; | |||
import { Grade } from "@/app/api/grades"; | |||
import { Customer, Subsidiary } from "@/app/api/customer"; | |||
import { isEmpty } from "lodash"; | |||
import { deleteDialog, errorDialog, submitDialog, successDialog } from "../Swal/CustomAlerts"; | |||
import { | |||
deleteDialog, | |||
errorDialog, | |||
submitDialog, | |||
successDialog, | |||
} from "../Swal/CustomAlerts"; | |||
import dayjs from "dayjs"; | |||
export interface Props { | |||
@@ -103,16 +112,15 @@ const CreateProject: React.FC<Props> = ({ | |||
const handleDelete = () => { | |||
deleteDialog(async () => { | |||
await deleteProject(formProps.getValues("projectId")!!) | |||
await deleteProject(formProps.getValues("projectId")!); | |||
const clickSuccessDialog = await successDialog("Delete Success", t) | |||
const clickSuccessDialog = await successDialog("Delete Success", t); | |||
if (clickSuccessDialog) { | |||
router.replace("/projects"); | |||
} | |||
}, t) | |||
} | |||
}, t); | |||
}; | |||
const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>( | |||
(_e, newValue) => { | |||
@@ -124,51 +132,55 @@ const CreateProject: React.FC<Props> = ({ | |||
const onSubmit = useCallback<SubmitHandler<CreateProjectInputs>>( | |||
async (data, event) => { | |||
try { | |||
console.log("first") | |||
console.log("first"); | |||
setServerError(""); | |||
let title = t("Do you want to submit?") | |||
let confirmButtonText = t("Submit") | |||
let successTitle = t("Submit Success") | |||
let errorTitle = t("Submit Fail") | |||
const buttonName = (event?.nativeEvent as any).submitter.name | |||
let title = t("Do you want to submit?"); | |||
let confirmButtonText = t("Submit"); | |||
let successTitle = t("Submit Success"); | |||
let errorTitle = t("Submit Fail"); | |||
const buttonName = (event?.nativeEvent as any).submitter.name; | |||
if (buttonName === "start") { | |||
title = t("Do you want to start?") | |||
confirmButtonText = t("Start") | |||
successTitle = t("Start Success") | |||
errorTitle = t("Start Fail") | |||
title = t("Do you want to start?"); | |||
confirmButtonText = t("Start"); | |||
successTitle = t("Start Success"); | |||
errorTitle = t("Start Fail"); | |||
} else if (buttonName === "complete") { | |||
title = t("Do you want to complete?") | |||
confirmButtonText = t("Complete") | |||
successTitle = t("Complete Success") | |||
errorTitle = t("Complete Fail") | |||
title = t("Do you want to complete?"); | |||
confirmButtonText = t("Complete"); | |||
successTitle = t("Complete Success"); | |||
errorTitle = t("Complete Fail"); | |||
} | |||
submitDialog(async () => { | |||
if (buttonName === "start") { | |||
data.projectActualStart = dayjs().format("YYYY-MM-DD") | |||
} else if (buttonName === "complete") { | |||
data.projectActualEnd = dayjs().format("YYYY-MM-DD") | |||
} | |||
submitDialog( | |||
async () => { | |||
if (buttonName === "start") { | |||
data.projectActualStart = dayjs().format("YYYY-MM-DD"); | |||
} else if (buttonName === "complete") { | |||
data.projectActualEnd = dayjs().format("YYYY-MM-DD"); | |||
} | |||
const response = await saveProject(data); | |||
const response = await saveProject(data); | |||
if (response.id > 0) { | |||
successDialog(successTitle, t).then(() => { | |||
router.replace("/projects"); | |||
}) | |||
} else { | |||
errorDialog(errorTitle, t).then(() => { | |||
return false | |||
}) | |||
} | |||
}, t, { title: title, confirmButtonText: confirmButtonText }) | |||
if (response.id > 0) { | |||
successDialog(successTitle, t).then(() => { | |||
router.replace("/projects"); | |||
}); | |||
} else { | |||
errorDialog(errorTitle, t).then(() => { | |||
return false; | |||
}); | |||
} | |||
}, | |||
t, | |||
{ title: title, confirmButtonText: confirmButtonText }, | |||
); | |||
} catch (e) { | |||
setServerError(t("An error has occurred. Please try again later.")); | |||
} | |||
}, | |||
[router, t, isEditMode], | |||
[router, t], | |||
); | |||
const onSubmitError = useCallback<SubmitErrorHandler<CreateProjectInputs>>( | |||
@@ -196,8 +208,8 @@ const CreateProject: React.FC<Props> = ({ | |||
// manhourPercentageByGrade should have a sensible default | |||
manhourPercentageByGrade: isEmpty(defaultInputs?.manhourPercentageByGrade) | |||
? grades.reduce((acc, grade) => { | |||
return { ...acc, [grade.id]: 1 / grades.length }; | |||
}, {}) | |||
return { ...acc, [grade.id]: 1 / grades.length }; | |||
}, {}) | |||
: defaultInputs?.manhourPercentageByGrade, | |||
}, | |||
}); | |||
@@ -214,15 +226,42 @@ const CreateProject: React.FC<Props> = ({ | |||
> | |||
{isEditMode && !(formProps.getValues("projectDeleted") === true) && ( | |||
<Stack direction="row" gap={1}> | |||
{!formProps.getValues("projectActualStart") && <Button name="start" type="submit" variant="contained" startIcon={<PlayArrow />} color="success"> | |||
{t("Start Project")} | |||
</Button>} | |||
{formProps.getValues("projectActualStart") && !formProps.getValues("projectActualEnd") && <Button name="complete" type="submit" variant="contained" startIcon={<DoneIcon />} color="info"> | |||
{t("Complete Project")} | |||
</Button>} | |||
{!(formProps.getValues("projectActualStart") && formProps.getValues("projectActualEnd")) && <Button variant="outlined" startIcon={<Delete />} color="error" onClick={handleDelete}> | |||
{t("Delete Project")} | |||
</Button>} | |||
{!formProps.getValues("projectActualStart") && ( | |||
<Button | |||
name="start" | |||
type="submit" | |||
variant="contained" | |||
startIcon={<PlayArrow />} | |||
color="success" | |||
> | |||
{t("Start Project")} | |||
</Button> | |||
)} | |||
{formProps.getValues("projectActualStart") && | |||
!formProps.getValues("projectActualEnd") && ( | |||
<Button | |||
name="complete" | |||
type="submit" | |||
variant="contained" | |||
startIcon={<DoneIcon />} | |||
color="info" | |||
> | |||
{t("Complete Project")} | |||
</Button> | |||
)} | |||
{!( | |||
formProps.getValues("projectActualStart") && | |||
formProps.getValues("projectActualEnd") | |||
) && ( | |||
<Button | |||
variant="outlined" | |||
startIcon={<Delete />} | |||
color="error" | |||
onClick={handleDelete} | |||
> | |||
{t("Delete Project")} | |||
</Button> | |||
)} | |||
</Stack> | |||
)} | |||
<Tabs | |||
@@ -290,7 +329,16 @@ const CreateProject: React.FC<Props> = ({ | |||
> | |||
{t("Cancel")} | |||
</Button> | |||
<Button variant="contained" startIcon={<Check />} type="submit" disabled={formProps.getValues("projectDeleted") === true || (!!formProps.getValues("projectActualStart") && !!formProps.getValues("projectActualEnd"))}> | |||
<Button | |||
variant="contained" | |||
startIcon={<Check />} | |||
type="submit" | |||
disabled={ | |||
formProps.getValues("projectDeleted") === true || | |||
(!!formProps.getValues("projectActualStart") && | |||
!!formProps.getValues("projectActualEnd")) | |||
} | |||
> | |||
{isEditMode ? t("Save") : t("Confirm")} | |||
</Button> | |||
</Stack> | |||
@@ -56,7 +56,7 @@ const CreateProjectWrapper: React.FC<Props> = async (props) => { | |||
]); | |||
const projectInfo = props.isEditMode | |||
? await fetchProjectDetails(props.projectId!!) | |||
? await fetchProjectDetails(props.projectId!) | |||
: undefined; | |||
return ( | |||
@@ -12,8 +12,11 @@ import { | |||
import TimesheetTable from "../TimesheetTable"; | |||
import { useTranslation } from "react-i18next"; | |||
import { Check, Close } from "@mui/icons-material"; | |||
import { FormProvider, useForm } from "react-hook-form"; | |||
import { RecordTimesheetInput } from "@/app/api/timesheets/actions"; | |||
import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; | |||
import { | |||
RecordTimesheetInput, | |||
saveTimesheet, | |||
} from "@/app/api/timesheets/actions"; | |||
import dayjs from "dayjs"; | |||
import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||
import { AssignedProject } from "@/app/api/projects"; | |||
@@ -23,6 +26,8 @@ interface Props { | |||
onClose: () => void; | |||
timesheetType: "time" | "leave"; | |||
assignedProjects: AssignedProject[]; | |||
username: string; | |||
defaultTimesheets?: RecordTimesheetInput; | |||
} | |||
const modalSx: SxProps = { | |||
@@ -40,6 +45,8 @@ const TimesheetModal: React.FC<Props> = ({ | |||
onClose, | |||
timesheetType, | |||
assignedProjects, | |||
username, | |||
defaultTimesheets, | |||
}) => { | |||
const { t } = useTranslation("home"); | |||
@@ -48,15 +55,37 @@ const TimesheetModal: React.FC<Props> = ({ | |||
return Array(7) | |||
.fill(undefined) | |||
.reduce<RecordTimesheetInput>((acc, _, index) => { | |||
const date = today.subtract(index, "day").format(INPUT_DATE_FORMAT); | |||
return { | |||
...acc, | |||
[today.subtract(index, "day").format(INPUT_DATE_FORMAT)]: [], | |||
[date]: defaultTimesheets?.[date] ?? [], | |||
}; | |||
}, {}); | |||
}, []); | |||
}, [defaultTimesheets]); | |||
const formProps = useForm<RecordTimesheetInput>({ defaultValues }); | |||
const onSubmit = useCallback<SubmitHandler<RecordTimesheetInput>>( | |||
async (data) => { | |||
const savedRecords = await saveTimesheet(data, username); | |||
const today = dayjs(); | |||
const newFormValues = Array(7) | |||
.fill(undefined) | |||
.reduce<RecordTimesheetInput>((acc, _, index) => { | |||
const date = today.subtract(index, "day").format(INPUT_DATE_FORMAT); | |||
return { | |||
...acc, | |||
[date]: savedRecords[date] ?? [], | |||
}; | |||
}, {}); | |||
formProps.reset(newFormValues); | |||
onClose(); | |||
}, | |||
[formProps, onClose, username], | |||
); | |||
const onCancel = useCallback(() => { | |||
formProps.reset(defaultValues); | |||
onClose(); | |||
@@ -66,7 +95,10 @@ const TimesheetModal: React.FC<Props> = ({ | |||
<Modal open={isOpen} onClose={onClose}> | |||
<Card sx={modalSx}> | |||
<FormProvider {...formProps}> | |||
<CardContent> | |||
<CardContent | |||
component="form" | |||
onSubmit={formProps.handleSubmit(onSubmit)} | |||
> | |||
<Typography variant="overline" display="block" marginBlockEnd={1}> | |||
{t(timesheetType === "time" ? "Timesheet Input" : "Record Leave")} | |||
</Typography> | |||
@@ -86,13 +118,8 @@ const TimesheetModal: React.FC<Props> = ({ | |||
> | |||
{t("Cancel")} | |||
</Button> | |||
<Button | |||
onClick={onClose} | |||
variant="contained" | |||
startIcon={<Check />} | |||
type="submit" | |||
> | |||
{t("Confirm")} | |||
<Button variant="contained" startIcon={<Check />} type="submit"> | |||
{t("Save")} | |||
</Button> | |||
</CardActions> | |||
</CardContent> | |||
@@ -36,7 +36,6 @@ type TimeEntryRow = Partial< | |||
_isNew: boolean; | |||
_error: string; | |||
isPlanned: boolean; | |||
id: string; | |||
} | |||
>; | |||
@@ -74,21 +73,19 @@ const EntryInputTable: React.FC<Props> = ({ day, assignedProjects }) => { | |||
const { getValues, setValue } = useFormContext<RecordTimesheetInput>(); | |||
const currentEntries = getValues(day); | |||
const [entries, setEntries] = useState<TimeEntryRow[]>( | |||
currentEntries.map((e, index) => ({ ...e, id: `${day}-${index}` })) || [], | |||
); | |||
const [entries, setEntries] = useState<TimeEntryRow[]>(currentEntries || []); | |||
const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({}); | |||
const apiRef = useGridApiRef(); | |||
const addRow = useCallback(() => { | |||
const id = `${day}-${Date.now()}`; | |||
const id = Date.now(); | |||
setEntries((e) => [...e, { id, _isNew: true }]); | |||
setRowModesModel((model) => ({ | |||
...model, | |||
[id]: { mode: GridRowModes.Edit, fieldToFocus: "projectId" }, | |||
})); | |||
}, [day]); | |||
}, []); | |||
const validateRow = useCallback( | |||
(id: GridRowId) => { | |||
@@ -318,9 +315,11 @@ const EntryInputTable: React.FC<Props> = ({ day, assignedProjects }) => { | |||
e.inputHours && | |||
e.projectId && | |||
e.taskId && | |||
e.taskGroupId, | |||
e.taskGroupId && | |||
e.id, | |||
) | |||
.map((e) => ({ | |||
id: e.id!, | |||
inputHours: e.inputHours!, | |||
projectId: e.projectId!, | |||
taskId: e.taskId!, | |||
@@ -14,9 +14,11 @@ import { Clear, Search } from "@mui/icons-material"; | |||
import ProjectGrid from "./ProjectGrid"; | |||
import { Props as UserWorkspaceProps } from "./UserWorkspacePage"; | |||
const AssignedProjects: React.FC<UserWorkspaceProps> = ({ | |||
assignedProjects, | |||
}) => { | |||
interface Props { | |||
assignedProjects: UserWorkspaceProps["assignedProjects"]; | |||
} | |||
const AssignedProjects: React.FC<Props> = ({ assignedProjects }) => { | |||
const { t } = useTranslation("home"); | |||
// Projects | |||
@@ -10,12 +10,19 @@ import ButtonGroup from "@mui/material/ButtonGroup"; | |||
import AssignedProjects from "./AssignedProjects"; | |||
import TimesheetModal from "../TimesheetModal"; | |||
import { AssignedProject } from "@/app/api/projects"; | |||
import { RecordTimesheetInput } from "@/app/api/timesheets/actions"; | |||
export interface Props { | |||
assignedProjects: AssignedProject[]; | |||
username: string; | |||
defaultTimesheets: RecordTimesheetInput; | |||
} | |||
const UserWorkspacePage: React.FC<Props> = ({ assignedProjects }) => { | |||
const UserWorkspacePage: React.FC<Props> = ({ | |||
assignedProjects, | |||
username, | |||
defaultTimesheets, | |||
}) => { | |||
const [isTimeheetModalVisible, setTimeheetModalVisible] = useState(false); | |||
const [isLeaveModalVisible, setLeaveModalVisible] = useState(false); | |||
const { t } = useTranslation("home"); | |||
@@ -75,12 +82,15 @@ const UserWorkspacePage: React.FC<Props> = ({ assignedProjects }) => { | |||
isOpen={isTimeheetModalVisible} | |||
onClose={handleCloseTimesheetModal} | |||
assignedProjects={assignedProjects} | |||
username={username} | |||
defaultTimesheets={defaultTimesheets} | |||
/> | |||
<TimesheetModal | |||
timesheetType="leave" | |||
isOpen={isLeaveModalVisible} | |||
onClose={handleCloseLeaveModal} | |||
assignedProjects={assignedProjects} | |||
username={username} | |||
/> | |||
<AssignedProjects assignedProjects={assignedProjects} /> | |||
</> | |||
@@ -1,9 +1,24 @@ | |||
import { fetchAssignedProjects } from "@/app/api/projects"; | |||
import UserWorkspacePage from "./UserWorkspacePage"; | |||
import { fetchTimesheets } from "@/app/api/timesheets"; | |||
const UserWorkspaceWrapper: React.FC = async () => { | |||
const assignedProjects = await fetchAssignedProjects(); | |||
return <UserWorkspacePage assignedProjects={assignedProjects} />; | |||
interface Props { | |||
username: string; | |||
} | |||
const UserWorkspaceWrapper: React.FC<Props> = async ({ username }) => { | |||
const [assignedProjects, timesheets] = await Promise.all([ | |||
fetchAssignedProjects(), | |||
fetchTimesheets(username), | |||
]); | |||
return ( | |||
<UserWorkspacePage | |||
assignedProjects={assignedProjects} | |||
username={username} | |||
defaultTimesheets={timesheets} | |||
/> | |||
); | |||
}; | |||
export default UserWorkspaceWrapper; |
@@ -8,12 +8,10 @@ export interface SessionWithTokens extends Session { | |||
refreshToken?: string; | |||
} | |||
export interface ability { | |||
actionSubjectCombo: string; | |||
} | |||
export const authOptions: AuthOptions = { | |||
debug: process.env.NODE_ENV === "development", | |||
providers: [ | |||
@@ -55,11 +53,12 @@ export const authOptions: AuthOptions = { | |||
const sessionWithToken: SessionWithTokens = { | |||
...session, | |||
// Add the data from the token to the session | |||
abilities: (token.abilities as ability[]).map((item: ability) => item.actionSubjectCombo) as string[], | |||
abilities: (token.abilities as ability[]).map( | |||
(item: ability) => item.actionSubjectCombo, | |||
) as string[], | |||
accessToken: token.accessToken as string | undefined, | |||
refreshToken: token.refreshToken as string | undefined, | |||
}; | |||
// console.log(sessionWithToken) | |||
return sessionWithToken; | |||
}, | |||