428 lines
16 KiB
JavaScript
428 lines
16 KiB
JavaScript
import { DeleteFilled } from "@ant-design/icons";
|
|
import { useMutation, useQuery } from "@apollo/client/react";
|
|
import {
|
|
Button,
|
|
Card,
|
|
Col,
|
|
Form,
|
|
Input,
|
|
InputNumber,
|
|
Row,
|
|
Select,
|
|
Skeleton,
|
|
Space,
|
|
Switch,
|
|
Tag,
|
|
Typography
|
|
} from "antd";
|
|
|
|
import querystring from "query-string";
|
|
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { connect } from "react-redux";
|
|
import { useLocation, useNavigate } from "react-router-dom";
|
|
import { createStructuredSelector } from "reselect";
|
|
import { logImEXEvent } from "../../firebase/firebase.utils";
|
|
import { selectBodyshop } from "../../redux/user/user.selectors";
|
|
import AlertComponent from "../alert/alert.component";
|
|
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
|
|
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
|
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
|
|
|
import {
|
|
INSERT_EMPLOYEE_TEAM,
|
|
QUERY_EMPLOYEE_TEAM_BY_ID,
|
|
UPDATE_EMPLOYEE_TEAM
|
|
} from "../../graphql/employee_teams.queries";
|
|
import EmployeeSearchSelectComponent from "../employee-search-select/employee-search-select.component";
|
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
|
import {
|
|
LABOR_TYPES,
|
|
getSplitTotal,
|
|
hasExactSplitTotal,
|
|
normalizeEmployeeTeam,
|
|
normalizeTeamMember,
|
|
validateEmployeeTeamMembers
|
|
} from "./shop-employee-teams.form.utils.js";
|
|
|
|
const mapStateToProps = createStructuredSelector({
|
|
bodyshop: selectBodyshop
|
|
});
|
|
const mapDispatchToProps = () => ({});
|
|
|
|
const PAYOUT_METHOD_OPTIONS = [
|
|
{ labelKey: "employee_teams.options.hourly", value: "hourly" },
|
|
{ labelKey: "employee_teams.options.commission_percentage", value: "commission" }
|
|
];
|
|
|
|
const TEAM_MEMBER_PRIMARY_FIELD_COLS = {
|
|
employee: { xs: 24, lg: 13, xxl: 14 },
|
|
allocation: { xs: 24, sm: 12, lg: 4, xxl: 4 },
|
|
payoutMethod: { xs: 24, sm: 12, lg: 7, xxl: 6 }
|
|
};
|
|
|
|
const TEAM_MEMBER_RATE_FIELD_COLS = { xs: 24, sm: 12, md: 8, lg: 6, xxl: 4 };
|
|
|
|
const getPayoutMethodTagColor = (payoutMethod) => (payoutMethod === "commission" ? "gold" : "blue");
|
|
|
|
const getEmployeeDisplayName = (employees = [], employeeId) => {
|
|
const employee = employees.find((currentEmployee) => currentEmployee.id === employeeId);
|
|
if (!employee) return null;
|
|
|
|
const fullName = [employee.first_name, employee.last_name].filter(Boolean).join(" ").trim();
|
|
return fullName || employee.employee_number || null;
|
|
};
|
|
|
|
const formatAllocationPercentage = (percentage) => {
|
|
if (percentage === null || percentage === undefined || percentage === "") return null;
|
|
|
|
const numericValue = Number(percentage);
|
|
if (!Number.isFinite(numericValue)) return null;
|
|
|
|
return `${numericValue.toFixed(2).replace(/\.?0+$/, "")}%`;
|
|
};
|
|
|
|
export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
|
|
const { t } = useTranslation();
|
|
const [form] = Form.useForm();
|
|
const history = useNavigate();
|
|
const search = querystring.parse(useLocation().search);
|
|
const notification = useNotification();
|
|
const [hydratedTeamId, setHydratedTeamId] = useState(search.employeeTeamId === "new" ? "new" : null);
|
|
const isNewTeam = search.employeeTeamId === "new";
|
|
|
|
const { error, data, loading } = useQuery(QUERY_EMPLOYEE_TEAM_BY_ID, {
|
|
variables: { id: search.employeeTeamId },
|
|
skip: !search.employeeTeamId || isNewTeam,
|
|
fetchPolicy: "network-only",
|
|
nextFetchPolicy: "network-only",
|
|
notifyOnNetworkStatusChange: true
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!search.employeeTeamId) return;
|
|
|
|
if (isNewTeam) {
|
|
form.resetFields();
|
|
setHydratedTeamId("new");
|
|
return;
|
|
}
|
|
|
|
setHydratedTeamId(null);
|
|
}, [form, isNewTeam, search.employeeTeamId]);
|
|
|
|
useEffect(() => {
|
|
if (!search.employeeTeamId || isNewTeam || loading) return;
|
|
|
|
if (data?.employee_teams_by_pk?.id === search.employeeTeamId) {
|
|
form.setFieldsValue(normalizeEmployeeTeam(data.employee_teams_by_pk));
|
|
setHydratedTeamId(search.employeeTeamId);
|
|
} else {
|
|
form.resetFields();
|
|
setHydratedTeamId(search.employeeTeamId);
|
|
}
|
|
}, [data, form, isNewTeam, loading, search.employeeTeamId]);
|
|
|
|
const [updateEmployeeTeam] = useMutation(UPDATE_EMPLOYEE_TEAM);
|
|
const [insertEmployeeTeam] = useMutation(INSERT_EMPLOYEE_TEAM);
|
|
const payoutMethodOptions = PAYOUT_METHOD_OPTIONS.map(({ labelKey, value }) => ({
|
|
label: t(labelKey),
|
|
value
|
|
}));
|
|
const teamName = Form.useWatch("name", form);
|
|
const teamMembers = Form.useWatch(["employee_team_members"], form) || [];
|
|
const isTeamHydrating = !isNewTeam && Boolean(search.employeeTeamId) && hydratedTeamId !== search.employeeTeamId;
|
|
const teamCardTitle = isTeamHydrating
|
|
? t("employee_teams.fields.name")
|
|
: teamName?.trim() || t("employee_teams.fields.name");
|
|
|
|
const getTeamMemberTitle = (teamMember = {}) => {
|
|
const employeeName =
|
|
getEmployeeDisplayName(bodyshop.employees, teamMember.employeeid) || t("employee_teams.fields.employeeid");
|
|
const allocation = formatAllocationPercentage(teamMember.percentage);
|
|
const payoutMethod =
|
|
teamMember.payout_method === "commission"
|
|
? t("employee_teams.options.commission")
|
|
: t("employee_teams.options.hourly");
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 8 }}>
|
|
<Typography.Text strong>{employeeName}</Typography.Text>
|
|
<Tag variant="filled" color="geekblue">
|
|
{`${t("employee_teams.fields.allocation")}: ${allocation || "--"}`}
|
|
</Tag>
|
|
<Tag variant="filled" color={getPayoutMethodTagColor(teamMember.payout_method)}>
|
|
{payoutMethod}
|
|
</Tag>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const handleFinish = async ({ employee_team_members = [], ...values }) => {
|
|
const { normalizedTeamMembers, errorKey } = validateEmployeeTeamMembers(employee_team_members);
|
|
|
|
if (errorKey) {
|
|
notification.error({
|
|
title: t(errorKey)
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (search.employeeTeamId && search.employeeTeamId !== "new") {
|
|
logImEXEvent("shop_employee_update");
|
|
|
|
const result = await updateEmployeeTeam({
|
|
variables: {
|
|
employeeTeamId: search.employeeTeamId,
|
|
employeeTeam: values,
|
|
teamMemberUpdates: normalizedTeamMembers
|
|
.filter((teamMember) => teamMember.id)
|
|
.map((teamMember) => ({
|
|
where: { id: { _eq: teamMember.id } },
|
|
_set: teamMember
|
|
})),
|
|
teamMemberInserts: normalizedTeamMembers
|
|
.filter((teamMember) => teamMember.id === null || teamMember.id === undefined)
|
|
.map((teamMember) => ({ ...teamMember, teamid: search.employeeTeamId })),
|
|
teamMemberDeletes: data.employee_teams_by_pk.employee_team_members
|
|
.filter(
|
|
(teamMember) => !normalizedTeamMembers.find((currentTeamMember) => currentTeamMember.id === teamMember.id)
|
|
)
|
|
.map((teamMember) => teamMember.id)
|
|
}
|
|
});
|
|
|
|
if (!result.errors) {
|
|
notification.success({
|
|
title: t("employees.successes.save")
|
|
});
|
|
} else {
|
|
notification.error({
|
|
title: t("employees.errors.save", {
|
|
message: JSON.stringify(error)
|
|
})
|
|
});
|
|
}
|
|
} else {
|
|
logImEXEvent("shop_employee_insert");
|
|
|
|
insertEmployeeTeam({
|
|
variables: {
|
|
employeeTeam: {
|
|
...values,
|
|
employee_team_members: { data: normalizedTeamMembers },
|
|
bodyshopid: bodyshop.id
|
|
}
|
|
},
|
|
refetchQueries: ["QUERY_TEAMS"]
|
|
}).then((response) => {
|
|
search.employeeTeamId = response.data.insert_employee_teams_one.id;
|
|
history({ search: querystring.stringify(search) });
|
|
notification.success({
|
|
title: t("employees.successes.save")
|
|
});
|
|
});
|
|
}
|
|
};
|
|
|
|
if (!search.employeeTeamId) return null;
|
|
if (error) return <AlertComponent title={error.message} type="error" />;
|
|
|
|
return (
|
|
<Card
|
|
title={teamCardTitle}
|
|
extra={
|
|
<Button type="primary" onClick={() => form.submit()} disabled={isTeamHydrating}>
|
|
{t("general.actions.save")}
|
|
</Button>
|
|
}
|
|
>
|
|
{isTeamHydrating ? (
|
|
<Skeleton active title={false} paragraph={{ rows: 12 }} />
|
|
) : (
|
|
<Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}>
|
|
<LayoutFormRow>
|
|
<Form.Item
|
|
name="name"
|
|
label={t("employee_teams.fields.name")}
|
|
rules={[
|
|
{
|
|
required: true
|
|
}
|
|
]}
|
|
>
|
|
<Input />
|
|
</Form.Item>
|
|
<Form.Item label={t("employee_teams.fields.active")} name="active" valuePropName="checked">
|
|
<Switch />
|
|
</Form.Item>
|
|
<Form.Item
|
|
label={t("employee_teams.fields.max_load")}
|
|
name="max_load"
|
|
rules={[
|
|
{
|
|
required: true
|
|
}
|
|
]}
|
|
>
|
|
<InputNumber min={0} precision={1} />
|
|
</Form.Item>
|
|
</LayoutFormRow>
|
|
<Form.List name={["employee_team_members"]}>
|
|
{(fields, { add, remove, move }) => {
|
|
return (
|
|
<div>
|
|
{fields.map((field, index) => {
|
|
const teamMember = normalizeTeamMember(teamMembers[field.name]);
|
|
|
|
return (
|
|
<Form.Item key={field.key} style={{ padding: 0, margin: 2 }}>
|
|
<Form.Item label={t("employees.fields.id")} key={`${index}`} name={[field.name, "id"]} hidden>
|
|
<Input type="hidden" />
|
|
</Form.Item>
|
|
<LayoutFormRow
|
|
grow
|
|
title={getTeamMemberTitle(teamMember)}
|
|
extra={
|
|
<Space align="center" size="small">
|
|
<Button
|
|
type="text"
|
|
icon={<DeleteFilled />}
|
|
onClick={() => {
|
|
remove(field.name);
|
|
}}
|
|
/>
|
|
<FormListMoveArrows
|
|
move={move}
|
|
index={index}
|
|
total={fields.length}
|
|
orientation="horizontal"
|
|
/>
|
|
</Space>
|
|
}
|
|
>
|
|
<div>
|
|
<Row gutter={[16, 0]}>
|
|
<Col {...TEAM_MEMBER_PRIMARY_FIELD_COLS.employee}>
|
|
<Form.Item
|
|
label={t("employee_teams.fields.employeeid")}
|
|
key={`${index}`}
|
|
name={[field.name, "employeeid"]}
|
|
rules={[
|
|
{
|
|
required: true
|
|
}
|
|
]}
|
|
>
|
|
<EmployeeSearchSelectComponent options={bodyshop.employees} />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col {...TEAM_MEMBER_PRIMARY_FIELD_COLS.allocation}>
|
|
<Form.Item
|
|
label={t("employee_teams.fields.allocation_percentage")}
|
|
key={`${index}`}
|
|
name={[field.name, "percentage"]}
|
|
rules={[
|
|
{
|
|
required: true
|
|
}
|
|
]}
|
|
>
|
|
<InputNumber min={0} max={100} precision={2} />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col {...TEAM_MEMBER_PRIMARY_FIELD_COLS.payoutMethod}>
|
|
<Form.Item
|
|
label={t("employee_teams.fields.payout_method")}
|
|
key={`${index}-payout-method`}
|
|
name={[field.name, "payout_method"]}
|
|
initialValue="hourly"
|
|
rules={[
|
|
{
|
|
required: true
|
|
}
|
|
]}
|
|
>
|
|
<Select options={payoutMethodOptions} />
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
<Form.Item noStyle dependencies={[["employee_team_members", field.name, "payout_method"]]}>
|
|
{() => {
|
|
const payoutMethod =
|
|
form.getFieldValue(["employee_team_members", field.name, "payout_method"]) ||
|
|
"hourly";
|
|
const fieldName = payoutMethod === "commission" ? "commission_rates" : "labor_rates";
|
|
|
|
return (
|
|
<Row gutter={[16, 0]}>
|
|
{LABOR_TYPES.map((laborType) => (
|
|
<Col {...TEAM_MEMBER_RATE_FIELD_COLS} key={`${index}-${fieldName}-${laborType}`}>
|
|
<Form.Item
|
|
label={t(`joblines.fields.lbr_types.${laborType}`)}
|
|
name={[field.name, fieldName, laborType]}
|
|
rules={[
|
|
{
|
|
required: true
|
|
}
|
|
]}
|
|
>
|
|
{payoutMethod === "commission" ? (
|
|
<InputNumber min={0} max={100} precision={2} />
|
|
) : (
|
|
<CurrencyInput />
|
|
)}
|
|
</Form.Item>
|
|
</Col>
|
|
))}
|
|
</Row>
|
|
);
|
|
}}
|
|
</Form.Item>
|
|
</div>
|
|
</LayoutFormRow>
|
|
</Form.Item>
|
|
);
|
|
})}
|
|
<Form.Item>
|
|
<Button
|
|
type="dashed"
|
|
onClick={() => {
|
|
add({
|
|
percentage: 0,
|
|
payout_method: "hourly",
|
|
labor_rates: {},
|
|
commission_rates: {}
|
|
});
|
|
}}
|
|
style={{ width: "100%" }}
|
|
>
|
|
{t("employee_teams.actions.newmember")}
|
|
</Button>
|
|
</Form.Item>
|
|
<Form.Item noStyle shouldUpdate>
|
|
{() => {
|
|
const teamMembers = form.getFieldValue(["employee_team_members"]) || [];
|
|
const splitTotal = getSplitTotal(teamMembers);
|
|
|
|
return (
|
|
<Typography.Text type={hasExactSplitTotal(teamMembers) ? undefined : "danger"}>
|
|
{t("employee_teams.labels.allocation_total", {
|
|
total: splitTotal.toFixed(2)
|
|
})}
|
|
</Typography.Text>
|
|
);
|
|
}}
|
|
</Form.Item>
|
|
</div>
|
|
);
|
|
}}
|
|
</Form.List>
|
|
</Form>
|
|
)}
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export default connect(mapStateToProps, mapDispatchToProps)(ShopEmployeeTeamsFormComponent);
|