Ver a proveniência

Update new resource allocation

tags/Baseline_30082024_FRONTEND_UAT
Wayne há 1 ano
ascendente
cometimento
e96ab5f481
11 ficheiros alterados com 447 adições e 220 eliminações
  1. +5
    -0
      src/app/api/grades/index.ts
  2. +10
    -0
      src/app/api/projects/actions.ts
  3. +5
    -0
      src/app/utils/formatUtil.ts
  4. +17
    -1
      src/components/CreateProject/CreateProject.tsx
  5. +8
    -0
      src/components/CreateProject/CreateProjectWrapper.tsx
  6. +15
    -9
      src/components/CreateProject/Milestone.tsx
  7. +258
    -196
      src/components/CreateProject/ResourceAllocation.tsx
  8. +4
    -0
      src/components/CreateProject/StaffAllocation.tsx
  9. +38
    -14
      src/components/CreateProject/TaskSetup.tsx
  10. +86
    -0
      src/components/TableCellEdit/TableCellEdit.tsx
  11. +1
    -0
      src/components/TableCellEdit/index.ts

+ 5
- 0
src/app/api/grades/index.ts Ver ficheiro

@@ -0,0 +1,5 @@
export interface Grade {
name: string;
id: number;
code: string;
}

+ 10
- 0
src/app/api/projects/actions.ts Ver ficheiro

@@ -27,6 +27,16 @@ export interface CreateProjectInputs {
};
};

totalManhour: number;
manhourPercentageByGrade: ManhourAllocation;

taskGroups: {
[taskGroup: TaskGroup["id"]]: {
taskIds: Task["id"][];
percentAllocation: number;
};
};

// Staff
allocatedStaffIds: number[];



+ 5
- 0
src/app/utils/formatUtil.ts Ver ficheiro

@@ -7,3 +7,8 @@ export const moneyFormatter = new Intl.NumberFormat("en-HK", {
style: "currency",
currency: "HKD",
});

export const percentFormatter = new Intl.NumberFormat("en-HK", {
style: "percent",
maximumFractionDigits: 2,
});

+ 17
- 1
src/components/CreateProject/CreateProject.tsx Ver ficheiro

@@ -26,12 +26,16 @@ import { Error } from "@mui/icons-material";
import { ProjectCategory } from "@/app/api/projects";
import { StaffResult } from "@/app/api/staff";
import { Typography } from "@mui/material";
import { Grade } from "@/app/api/grades";

export interface Props {
allTasks: Task[];
projectCategories: ProjectCategory[];
taskTemplates: TaskTemplate[];
teamLeads: StaffResult[];

// Mocked
grades: Grade[];
}

const hasErrorsInTab = (
@@ -51,6 +55,7 @@ const CreateProject: React.FC<Props> = ({
projectCategories,
taskTemplates,
teamLeads,
grades,
}) => {
const [serverError, setServerError] = useState("");
const [tabIndex, setTabIndex] = useState(0);
@@ -95,8 +100,13 @@ const CreateProject: React.FC<Props> = ({
const formProps = useForm<CreateProjectInputs>({
defaultValues: {
tasks: {},
taskGroups: {},
allocatedStaffIds: [],
milestones: {},
totalManhour: 0,
manhourPercentageByGrade: grades.reduce((acc, grade) => {
return { ...acc, [grade.id]: 1 / grades.length };
}, {}),
// TODO: Remove this
clientSubsidiary: "Test subsidiary",
},
@@ -139,7 +149,13 @@ const CreateProject: React.FC<Props> = ({
isActive={tabIndex === 1}
/>
}
{<StaffAllocation isActive={tabIndex === 2} allTasks={allTasks} />}
{
<StaffAllocation
isActive={tabIndex === 2}
allTasks={allTasks}
grades={grades}
/>
}
{<Milestone allTasks={allTasks} isActive={tabIndex === 3} />}
{serverError && (
<Typography variant="body2" color="error" alignSelf="flex-end">


+ 8
- 0
src/components/CreateProject/CreateProjectWrapper.tsx Ver ficheiro

@@ -18,6 +18,14 @@ const CreateProjectWrapper: React.FC = async () => {
projectCategories={projectCategories}
taskTemplates={taskTemplates}
teamLeads={teamLeads}
// Mocks
grades={[
{ name: "Grade 1", id: 1, code: "1" },
{ name: "Grade 2", id: 2, code: "2" },
{ name: "Grade 3", id: 3, code: "3" },
{ name: "Grade 4", id: 4, code: "4" },
{ name: "Grade 5", id: 5, code: "5" },
]}
/>
);
};


+ 15
- 9
src/components/CreateProject/Milestone.tsx Ver ficheiro

@@ -30,15 +30,21 @@ export interface Props {
const Milestone: React.FC<Props> = ({ allTasks, isActive }) => {
const { t } = useTranslation();
const { watch } = useFormContext<CreateProjectInputs>();
const currentTaskGroups = watch("taskGroups");
const taskGroups = useMemo(
() =>
uniqBy(
allTasks.reduce<TaskGroup[]>((acc, task) => {
if (currentTaskGroups[task.taskGroup.id]) {
return [...acc, task.taskGroup];
}
return acc;
}, []),
"id",
),
[allTasks, currentTaskGroups],
);

const tasks = allTasks.filter((task) => watch("tasks")[task.id]);

const taskGroups = useMemo(() => {
return uniqBy(
tasks.map((task) => task.taskGroup),
"id",
);
}, [tasks]);
const [currentTaskGroupId, setCurrentTaskGroupId] = useState(
taskGroups[0].id,
);
@@ -103,7 +109,7 @@ const NoTaskState: React.FC<Pick<Props, "isActive">> = ({ isActive }) => {
const MilestoneWrapper: React.FC<Props> = (props) => {
const { getValues } = useFormContext<CreateProjectInputs>();

if (Object.keys(getValues("tasks")).length === 0) {
if (Object.keys(getValues("taskGroups")).length === 0) {
return <NoTaskState isActive={props.isActive} />;
}



+ 258
- 196
src/components/CreateProject/ResourceAllocation.tsx Ver ficheiro

@@ -1,232 +1,289 @@
import { Task } from "@/app/api/tasks";
import { Task, TaskGroup } from "@/app/api/tasks";
import {
Box,
Typography,
Grid,
Paper,
List,
ListItemButton,
ListItemText,
TextField,
Alert,
TableContainer,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Stack,
SxProps,
} from "@mui/material";
import { useState, useCallback, useEffect, useMemo } from "react";
import React, { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Props as StaffAllocationProps } from "./StaffAllocation";
import StyledDataGrid from "../StyledDataGrid";
import { useForm, useFormContext } from "react-hook-form";
import { GridColDef, GridRowModel, useGridApiRef } from "@mui/x-data-grid";
import {
CreateProjectInputs,
ManhourAllocation,
} from "@/app/api/projects/actions";
import isEmpty from "lodash/isEmpty";
import _reduce from "lodash/reduce";

const mockGrades = [1, 2, 3, 4, 5];
import { useFormContext } from "react-hook-form";
import { CreateProjectInputs } from "@/app/api/projects/actions";
import uniqBy from "lodash/uniqBy";
import { Grade } from "@/app/api/grades";
import { manhourFormatter, percentFormatter } from "@/app/utils/formatUtil";
import TableCellEdit from "../TableCellEdit";

interface Props {
allTasks: Task[];
manhourBreakdownByGrade: StaffAllocationProps["defaultManhourBreakdownByGrade"];
grades: Grade[];
}

type Row = ManhourAllocation & { id: "manhourAllocation" };
const leftBorderCellSx: SxProps = {
borderLeft: "1px solid",
borderColor: "divider",
};

const parseValidManhours = (value: number | string): number => {
const inputNumber = Number(value);
return isNaN(inputNumber) || inputNumber < 0 ? 0 : inputNumber;
const rightBorderCellSx: SxProps = {
borderRight: "1px solid",
borderColor: "divider",
};

const ResourceAllocation: React.FC<Props> = ({
allTasks,
manhourBreakdownByGrade = mockGrades.reduce<
NonNullable<Props["manhourBreakdownByGrade"]>
>((acc, grade) => {
return { ...acc, [grade]: 1 };
}, {}),
}) => {
const leftRightBorderCellSx: SxProps = {
borderLeft: "1px solid",
borderRight: "1px solid",
borderColor: "divider",
};

const ResourceAllocationByGrade: React.FC<Props> = ({ grades }) => {
const { t } = useTranslation();
const { watch } = useFormContext<CreateProjectInputs>();
const currentTasks = watch("tasks");
const tasks = useMemo(
() => allTasks.filter((task) => currentTasks[task.id]),
[allTasks, currentTasks],
);
const { watch, register, setValue } = useFormContext<CreateProjectInputs>();

const [selectedTaskId, setSelectedTaskId] = useState(tasks[0].id);
const makeOnTaskSelect = useCallback(
(taskId: Task["id"]): React.MouseEventHandler =>
() => {
return setSelectedTaskId(taskId);
},
[],
const manhourPercentageByGrade = watch("manhourPercentageByGrade");
const totalManhour = watch("totalManhour");
const totalPercentage = Object.values(manhourPercentageByGrade).reduce(
(acc, percent) => acc + percent,
0,
);

useEffect(() => {
setSelectedTaskId(tasks[0].id);
}, [tasks]);

const { getValues, setValue } = useFormContext<CreateProjectInputs>();

const updateTaskAllocations = useCallback(
(newAllocations: ManhourAllocation) => {
setValue("tasks", {
...getValues("tasks"),
[selectedTaskId]: {
manhourAllocation: newAllocations,
},
});
const makeUpdatePercentage = useCallback(
(gradeId: Grade["id"]) => (percentage?: number) => {
if (percentage !== undefined) {
setValue("manhourPercentageByGrade", {
...manhourPercentageByGrade,
[gradeId]: percentage,
});
}
},
[getValues, selectedTaskId, setValue],
[manhourPercentageByGrade, setValue],
);

const gridApiRef = useGridApiRef();
const columns = useMemo(() => {
return mockGrades.map<GridColDef>((grade) => ({
field: grade.toString(),
editable: true,
sortable: false,
width: 120,
headerName: t("Grade {{grade}}", { grade }),
type: "number",
valueParser: parseValidManhours,
valueFormatter(params) {
return Number(params.value).toFixed(2);
},
}));
}, [t]);

const rows = useMemo<Row[]>(() => {
const initialAllocation =
getValues("tasks")[selectedTaskId]?.manhourAllocation;
if (!isEmpty(initialAllocation)) {
return [{ ...initialAllocation, id: "manhourAllocation" }];
}
return [
mockGrades.reduce(
(acc, grade) => {
return { ...acc, [grade]: 0 };
},
{ id: "manhourAllocation" },
),
];
}, [getValues, selectedTaskId]);

const initialManhours = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { id, ...allocations } = rows[0];
return Object.values(allocations).reduce((acc, hours) => acc + hours, 0);
}, [rows]);
return (
<Box>
<Typography variant="overline" display="block" marginBlockEnd={1}>
{t("Manhour Allocation By Grade")}
</Typography>
<TextField
label={t("Total Project Manhour")}
fullWidth
type="number"
{...register("totalManhour", {
valueAsNumber: true,
})}
/>
<Box
sx={(theme) => ({
marginBlockStart: 2,
marginInline: -3,
borderBottom: `1px solid ${theme.palette.divider}`,
})}
>
<TableContainer sx={{ maxHeight: 440 }}>
<Table>
<TableHead>
<TableRow>
<TableCell sx={rightBorderCellSx}>
{t("Allocation Type")}
</TableCell>
{grades.map((column, idx) => (
<TableCell key={`${column.id}${idx}`}>
{column.name}
</TableCell>
))}
<TableCell sx={leftBorderCellSx}>{t("Total")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow>
<TableCell sx={rightBorderCellSx}>{t("Percentage")}</TableCell>
{grades.map((column, idx) => (
<TableCellEdit
key={`${column.id}${idx}`}
value={manhourPercentageByGrade[column.id]}
renderValue={(val) => percentFormatter.format(val)}
onChange={makeUpdatePercentage(column.id)}
convertValue={(inputValue) => Number(inputValue)}
cellSx={{ backgroundColor: "primary.lightest" }}
inputSx={{ width: "3rem" }}
/>
))}
<TableCell sx={leftBorderCellSx}>
{percentFormatter.format(totalPercentage)}
</TableCell>
</TableRow>
<TableRow>
<TableCell sx={rightBorderCellSx}>{t("Manhour")}</TableCell>
{grades.map((column, idx) => (
<TableCell key={`${column.id}${idx}`}>
{manhourFormatter.format(
manhourPercentageByGrade[column.id] * totalManhour,
)}
</TableCell>
))}
<TableCell sx={leftBorderCellSx}>
{manhourFormatter.format(totalManhour)}
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</Box>
</Box>
);
};

const {
register,
reset,
getValues: getManhourFormValues,
setValue: setManhourFormValue,
} = useForm<{ manhour: number }>({
defaultValues: {
manhour: initialManhours,
},
});
const ResourceAllocationByStage: React.FC<Props> = ({ grades, allTasks }) => {
const { t } = useTranslation();
const { watch, setValue } = useFormContext<CreateProjectInputs>();

// Reset man hour input when task changes
useEffect(() => {
reset({ manhour: initialManhours });
}, [initialManhours, reset, selectedTaskId]);
const currentTaskGroups = watch("taskGroups");
const taskGroups = useMemo(
() =>
uniqBy(
allTasks.reduce<TaskGroup[]>((acc, task) => {
if (currentTaskGroups[task.taskGroup.id]) {
return [...acc, task.taskGroup];
}
return acc;
}, []),
"id",
),
[allTasks, currentTaskGroups],
);

const updateAllocation = useCallback(() => {
const inputHour = getManhourFormValues("manhour");
const ratioSum = Object.values(manhourBreakdownByGrade).reduce(
(acc, ratio) => acc + ratio,
0,
);
const newAllocations = _reduce(
manhourBreakdownByGrade,
(acc, value, key) => {
return { ...acc, [key]: (inputHour / ratioSum) * value };
},
{},
);
gridApiRef.current.updateRows([
{ id: "manhourAllocation", ...newAllocations },
]);
updateTaskAllocations(newAllocations);
}, [
getManhourFormValues,
gridApiRef,
manhourBreakdownByGrade,
updateTaskAllocations,
]);
const manhourPercentageByGrade = watch("manhourPercentageByGrade");
const totalManhour = watch("totalManhour");

const processRowUpdate = useCallback(
(newRow: GridRowModel) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { id: rowId, ...newAllocations } = newRow;
const totalHours = Object.values(
newAllocations as ManhourAllocation,
).reduce<number>((acc, hour) => acc + hour, 0);
setManhourFormValue("manhour", totalHours);
updateTaskAllocations(newAllocations);
return newRow;
const makeUpdatePercentage = useCallback(
(taskGroupId: TaskGroup["id"]) => (percentage?: number) => {
if (percentage !== undefined) {
setValue("taskGroups", {
...currentTaskGroups,
[taskGroupId]: {
...currentTaskGroups[taskGroupId],
percentAllocation: percentage,
},
});
}
},
[setManhourFormValue, updateTaskAllocations],
[currentTaskGroups, setValue],
);

return (
<Box>
<Typography variant="overline" display="block" marginBlockEnd={1}>
{t("Task Breakdown")}
{t("Manhour Allocation By Stage By Grade")}
</Typography>
<Grid container spacing={2} columns={{ xs: 6, sm: 12 }}>
<Grid item xs={6}>
<Paper elevation={2}>
<List dense sx={{ maxHeight: 300, overflow: "auto" }}>
{tasks.map((task, index) => {
return (
<ListItemButton
selected={selectedTaskId === task.id}
key={`${task.id}-${index}`}
onClick={makeOnTaskSelect(task.id)}
>
<ListItemText primary={task.name} />
</ListItemButton>
);
})}
</List>
</Paper>
</Grid>
<Grid item xs={6}>
<TextField
label={t("Mahours Allocated to Task")}
fullWidth
type="number"
{...register("manhour", {
valueAsNumber: true,
onBlur: updateAllocation,
})}
/>
<Paper
elevation={2}
sx={{
marginBlockStart: 2,
".MuiDataGrid-root .MuiDataGrid-columnHeader:focus-within": {
outlineOffset: -2,
},
}}
>
<StyledDataGrid
apiRef={gridApiRef}
disableColumnMenu
hideFooter
disableRowSelectionOnClick
rows={rows}
columns={columns}
processRowUpdate={processRowUpdate}
sx={{ paddingBlockEnd: 2 }}
/>
</Paper>
</Grid>
</Grid>
<Box
sx={(theme) => ({
marginBlockStart: 2,
marginInline: -3,
borderBottom: `1px solid ${theme.palette.divider}`,
})}
>
<TableContainer sx={{ maxHeight: 440 }}>
<Table>
<TableHead>
<TableRow>
<TableCell>{t("Stage")}</TableCell>
<TableCell sx={leftBorderCellSx}>{t("Task Count")}</TableCell>
<TableCell colSpan={2} sx={leftRightBorderCellSx}>
{t("Total Manhour")}
</TableCell>
{grades.map((column, idx) => (
<TableCell key={`${column.id}${idx}`}>
{column.name}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{taskGroups.map((tg, idx) => (
<TableRow key={`${tg.id}${idx}`}>
<TableCell>{tg.name}</TableCell>
<TableCell sx={leftBorderCellSx}>
{currentTaskGroups[tg.id].taskIds.length}
</TableCell>
<TableCellEdit
value={currentTaskGroups[tg.id].percentAllocation}
renderValue={(val) => percentFormatter.format(val)}
onChange={makeUpdatePercentage(tg.id)}
convertValue={(inputValue) => Number(inputValue)}
cellSx={{ backgroundColor: "primary.lightest" }}
inputSx={{ width: "3rem" }}
/>
<TableCell sx={rightBorderCellSx}>
{manhourFormatter.format(
currentTaskGroups[tg.id].percentAllocation * totalManhour,
)}
</TableCell>
{grades.map((column, idx) => {
const stageHours =
currentTaskGroups[tg.id].percentAllocation * totalManhour;
return (
<TableCell key={`${column.id}${idx}`}>
{manhourFormatter.format(
manhourPercentageByGrade[column.id] * stageHours,
)}
</TableCell>
);
})}
</TableRow>
))}
<TableRow>
<TableCell>{t("Total")}</TableCell>
<TableCell sx={leftBorderCellSx}>
{Object.values(currentTaskGroups).reduce(
(acc, tg) => acc + tg.taskIds.length,
0,
)}
</TableCell>
<TableCell sx={leftBorderCellSx}>
{percentFormatter.format(
Object.values(currentTaskGroups).reduce(
(acc, tg) => acc + tg.percentAllocation,
0,
),
)}
</TableCell>
<TableCell sx={rightBorderCellSx}>
{manhourFormatter.format(
Object.values(currentTaskGroups).reduce(
(acc, tg) => acc + tg.percentAllocation * totalManhour,
0,
),
)}
</TableCell>
{grades.map((column, idx) => {
const hours = Object.values(currentTaskGroups).reduce(
(acc, tg) =>
acc +
tg.percentAllocation *
totalManhour *
manhourPercentageByGrade[column.id],
0,
);
return (
<TableCell key={`${column.id}${idx}`}>
{manhourFormatter.format(hours)}
</TableCell>
);
})}
</TableRow>
</TableBody>
</Table>
</TableContainer>
</Box>
</Box>
);
};
@@ -248,11 +305,16 @@ const NoTaskState: React.FC = () => {
const ResourceAllocationWrapper: React.FC<Props> = (props) => {
const { getValues } = useFormContext<CreateProjectInputs>();

if (Object.keys(getValues("tasks")).length === 0) {
if (Object.keys(getValues("taskGroups")).length === 0) {
return <NoTaskState />;
}

return <ResourceAllocation {...props} />;
return (
<Stack spacing={4}>
<ResourceAllocationByGrade {...props} />
<ResourceAllocationByStage {...props} />
</Stack>
);
};

export default ResourceAllocationWrapper;

+ 4
- 0
src/components/CreateProject/StaffAllocation.tsx Ver ficheiro

@@ -33,6 +33,7 @@ import { useFormContext } from "react-hook-form";
import { CreateProjectInputs } from "@/app/api/projects/actions";
import ResourceAllocation from "./ResourceAllocation";
import { Task } from "@/app/api/tasks";
import { Grade } from "@/app/api/grades";

interface StaffResult {
id: number;
@@ -106,6 +107,7 @@ export interface Props {
isActive: boolean;
defaultManhourBreakdownByGrade?: { [gradeId: number]: number };
allTasks: Task[];
grades: Grade[];
}

const StaffAllocation: React.FC<Props> = ({
@@ -113,6 +115,7 @@ const StaffAllocation: React.FC<Props> = ({
allTasks,
isActive,
defaultManhourBreakdownByGrade,
grades,
}) => {
const { t } = useTranslation();
const { setValue, getValues } = useFormContext<CreateProjectInputs>();
@@ -348,6 +351,7 @@ const StaffAllocation: React.FC<Props> = ({
<Card>
<CardContent>
<ResourceAllocation
grades={grades}
allTasks={allTasks}
manhourBreakdownByGrade={defaultManhourBreakdownByGrade}
/>


+ 38
- 14
src/components/CreateProject/TaskSetup.tsx Ver ficheiro

@@ -18,6 +18,7 @@ import { Task, TaskTemplate } from "@/app/api/tasks";
import { useFormContext } from "react-hook-form";
import { CreateProjectInputs } from "@/app/api/projects/actions";
import isNumber from "lodash/isNumber";
import intersectionWith from "lodash/intersectionWith";

interface Props {
allTasks: Task[];
@@ -32,10 +33,16 @@ const TaskSetup: React.FC<Props> = ({
}) => {
const { t } = useTranslation();
const { setValue, watch } = useFormContext<CreateProjectInputs>();
const currentTasks = watch("tasks");
const currentTaskGroups = watch("taskGroups");
const currentTaskIds = Object.values(currentTaskGroups).reduce<Task["id"][]>(
(acc, group) => {
return [...acc, ...group.taskIds];
},
[],
);

const onReset = useCallback(() => {
setValue("tasks", {});
setValue("taskGroups", {});
}, [setValue]);

const [selectedTaskTemplateId, setSelectedTaskTemplateId] = useState<
@@ -67,10 +74,12 @@ const TaskSetup: React.FC<Props> = ({
}, [tasks, selectedTaskTemplateId, taskTemplates]);

const selectedItems = useMemo(() => {
return tasks
.filter((t) => currentTasks[t.id])
.map((t) => ({ id: t.id, label: t.name, group: t.taskGroup }));
}, [currentTasks, tasks]);
return intersectionWith(
tasks,
currentTaskIds,
(task, taskId) => task.id === taskId,
).map((t) => ({ id: t.id, label: t.name, group: t.taskGroup }));
}, [currentTaskIds, tasks]);

return (
<Card sx={{ display: isActive ? "block" : "none" }}>
@@ -106,18 +115,33 @@ const TaskSetup: React.FC<Props> = ({
allItems={items}
selectedItems={selectedItems}
onChange={(selectedTasks) => {
const newTasks = selectedTasks.reduce<CreateProjectInputs["tasks"]>(
(acc, item) => {
// Reuse the task from currentTasks if present
const newTaskGroups = selectedTasks.reduce<
CreateProjectInputs["taskGroups"]
>((acc, item) => {
if (!item.group) {
// TODO: this should not happen (all tasks are part of a group)
return acc;
}
if (!acc[item.group.id]) {
return {
...acc,
[item.id]: currentTasks[item.id] ?? { manhourAllocation: {} },
[item.group.id]: {
taskIds: [item.id],
percentAllocation:
currentTaskGroups[item.group.id]?.percentAllocation || 0,
},
};
},
{},
);
}
return {
...acc,
[item.group.id]: {
...acc[item.group.id],
taskIds: [...acc[item.group.id].taskIds, item.id],
},
};
}, {});

setValue("tasks", newTasks);
setValue("taskGroups", newTaskGroups);
}}
allItemsLabel={t("Task Pool")}
selectedItemsLabel={t("Project Task List")}


+ 86
- 0
src/components/TableCellEdit/TableCellEdit.tsx Ver ficheiro

@@ -0,0 +1,86 @@
import React, {
ChangeEventHandler,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { Box, Input, SxProps, TableCell } from "@mui/material";

interface Props<T> {
value: T;
onChange: (newValue?: T) => void;
renderValue?: (value: T) => string;
convertValue: (inputValue?: string) => T;
cellSx?: SxProps;
inputSx?: SxProps;
}

const TableCellEdit = <T,>({
value,
renderValue = (val) => `${val}`,
convertValue,
onChange,
cellSx,
inputSx,
}: Props<T>) => {
const [editMode, setEditMode] = useState(false);
const [input, setInput] = useState<string>();
const inputRef = useRef<HTMLInputElement>(null);

const onClick = useCallback(() => {
setEditMode(true);
setInput(`${value}`);
}, [value]);

const onInputChange = useCallback<ChangeEventHandler<HTMLInputElement>>(
(e) => setInput(e.target.value),
[],
);

const onBlur = useCallback(() => {
setEditMode(false);
onChange(convertValue(input));
setInput(undefined);
}, [convertValue, input, onChange]);

useEffect(() => {
if (editMode && inputRef.current) {
inputRef.current?.focus();
}
}, [editMode]);

return (
<TableCell
sx={{
outline: editMode ? "1px solid" : undefined,
outlineColor: editMode ? "primary.main" : undefined,
...cellSx,
}}
>
<Input
sx={{
display: editMode ? "block" : "none",
"::after": { display: "none " },
"::before": { display: "none " },
height: "inherit",
width: "inherit",
padding: 0,
fontWeight: "inherit",
lineHeight: "inherit",
...inputSx,
}}
inputRef={inputRef}
value={input}
onChange={onInputChange}
onBlur={onBlur}
type={typeof value === "number" ? "number" : "text"}
/>
<Box sx={{ display: editMode ? "none" : "block" }} onClick={onClick}>
{renderValue(value)}
</Box>
</TableCell>
);
};

export default TableCellEdit;

+ 1
- 0
src/components/TableCellEdit/index.ts Ver ficheiro

@@ -0,0 +1 @@
export { default } from "./TableCellEdit";

Carregando…
Cancelar
Guardar