feature/IO-3587-Commision-Cut - Additional test, layout enhancements

This commit is contained in:
Dave
2026-03-17 11:03:14 -04:00
parent aebd8da4ae
commit 45688c0dde
3 changed files with 1501 additions and 1304 deletions

View File

@@ -1,9 +1,23 @@
import { DeleteFilled } from "@ant-design/icons"; import { DeleteFilled } from "@ant-design/icons";
import { useMutation, useQuery } from "@apollo/client/react"; import { useMutation, useQuery } from "@apollo/client/react";
import { Button, Card, Col, Form, Input, InputNumber, Row, Select, Space, Switch, Tag, Typography } from "antd"; import {
Button,
Card,
Col,
Form,
Input,
InputNumber,
Row,
Select,
Skeleton,
Space,
Switch,
Tag,
Typography
} from "antd";
import querystring from "query-string"; import querystring from "query-string";
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
@@ -85,21 +99,40 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
const history = useNavigate(); const history = useNavigate();
const search = querystring.parse(useLocation().search); const search = querystring.parse(useLocation().search);
const notification = useNotification(); const notification = useNotification();
const [hydratedTeamId, setHydratedTeamId] = useState(search.employeeTeamId === "new" ? "new" : null);
const isNewTeam = search.employeeTeamId === "new";
const { error, data } = useQuery(QUERY_EMPLOYEE_TEAM_BY_ID, { const { error, data, loading } = useQuery(QUERY_EMPLOYEE_TEAM_BY_ID, {
variables: { id: search.employeeTeamId }, variables: { id: search.employeeTeamId },
skip: !search.employeeTeamId || search.employeeTeamId === "new", skip: !search.employeeTeamId || isNewTeam,
fetchPolicy: "network-only", fetchPolicy: "network-only",
nextFetchPolicy: "network-only" nextFetchPolicy: "network-only",
notifyOnNetworkStatusChange: true
}); });
useEffect(() => { useEffect(() => {
if (data?.employee_teams_by_pk) { 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)); form.setFieldsValue(normalizeEmployeeTeam(data.employee_teams_by_pk));
setHydratedTeamId(search.employeeTeamId);
} else { } else {
form.resetFields(); form.resetFields();
setHydratedTeamId(search.employeeTeamId);
} }
}, [form, data, search.employeeTeamId]); }, [data, form, isNewTeam, loading, search.employeeTeamId]);
const [updateEmployeeTeam] = useMutation(UPDATE_EMPLOYEE_TEAM); const [updateEmployeeTeam] = useMutation(UPDATE_EMPLOYEE_TEAM);
const [insertEmployeeTeam] = useMutation(INSERT_EMPLOYEE_TEAM); const [insertEmployeeTeam] = useMutation(INSERT_EMPLOYEE_TEAM);
@@ -109,7 +142,10 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
})); }));
const teamName = Form.useWatch("name", form); const teamName = Form.useWatch("name", form);
const teamMembers = Form.useWatch(["employee_team_members"], form) || []; const teamMembers = Form.useWatch(["employee_team_members"], form) || [];
const teamCardTitle = teamName?.trim() || t("employee_teams.fields.name"); 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 getTeamMemberTitle = (teamMember = {}) => {
const employeeName = const employeeName =
@@ -123,10 +159,10 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
return ( return (
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 8 }}> <div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 8 }}>
<Typography.Text strong>{employeeName}</Typography.Text> <Typography.Text strong>{employeeName}</Typography.Text>
<Tag bordered={false} color="geekblue"> <Tag variant="filled" color="geekblue">
{`${t("employee_teams.fields.allocation")}: ${allocation || "--"}`} {`${t("employee_teams.fields.allocation")}: ${allocation || "--"}`}
</Tag> </Tag>
<Tag bordered={false} color={getPayoutMethodTagColor(teamMember.payout_method)}> <Tag variant="filled" color={getPayoutMethodTagColor(teamMember.payout_method)}>
{payoutMethod} {payoutMethod}
</Tag> </Tag>
</div> </div>
@@ -228,191 +264,194 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
<Card <Card
title={teamCardTitle} title={teamCardTitle}
extra={ extra={
<Button type="primary" onClick={() => form.submit()}> <Button type="primary" onClick={() => form.submit()} disabled={isTeamHydrating}>
{t("general.actions.save")} {t("general.actions.save")}
</Button> </Button>
} }
> >
<Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}> {isTeamHydrating ? (
<LayoutFormRow> <Skeleton active title={false} paragraph={{ rows: 12 }} />
<Form.Item ) : (
name="name" <Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}>
label={t("employee_teams.fields.name")} <LayoutFormRow>
rules={[ <Form.Item
{ name="name"
required: true 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 /> <Input />
</Form.Item> </Form.Item>
<Form.Item <Form.Item label={t("employee_teams.fields.active")} name="active" valuePropName="checked">
label={t("employee_teams.fields.max_load")} <Switch />
name="max_load" </Form.Item>
rules={[ <Form.Item
{ label={t("employee_teams.fields.max_load")}
required: true name="max_load"
} rules={[
]} {
> required: true
<InputNumber min={0} precision={1} /> }
</Form.Item> ]}
</LayoutFormRow> >
<Form.List name={["employee_team_members"]}> <InputNumber min={0} precision={1} />
{(fields, { add, remove, move }) => { </Form.Item>
return ( </LayoutFormRow>
<div> <Form.List name={["employee_team_members"]}>
{fields.map((field, index) => { {(fields, { add, remove, move }) => {
const teamMember = normalizeTeamMember(teamMembers[field.name]); return (
<div>
return ( {fields.map((field, index) => {
<Form.Item key={field.key} style={{ padding: 0, margin: 2 }}> const teamMember = normalizeTeamMember(teamMembers[field.name]);
<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 ( return (
<Typography.Text type={hasExactSplitTotal(teamMembers) ? undefined : "danger"}> <Form.Item key={field.key} style={{ padding: 0, margin: 2 }}>
{t("employee_teams.labels.allocation_total", { <Form.Item label={t("employees.fields.id")} key={`${index}`} name={[field.name, "id"]} hidden>
total: splitTotal.toFixed(2) <Input type="hidden" />
})} </Form.Item>
</Typography.Text> <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> <Form.Item>
</div> <Button
); type="dashed"
}} onClick={() => {
</Form.List> add({
</Form> 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> </Card>
); );
} }

2346
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,25 +18,25 @@
"job-totals-fixtures:local": "docker exec node-app /usr/bin/node /app/download-job-totals-fixtures.js" "job-totals-fixtures:local": "docker exec node-app /usr/bin/node /app/download-job-totals-fixtures.js"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-cloudwatch-logs": "^3.997.0", "@aws-sdk/client-cloudwatch-logs": "^3.1010.0",
"@aws-sdk/client-elasticache": "^3.997.0", "@aws-sdk/client-elasticache": "^3.1010.0",
"@aws-sdk/client-s3": "^3.997.0", "@aws-sdk/client-s3": "^3.1010.0",
"@aws-sdk/client-secrets-manager": "^3.997.0", "@aws-sdk/client-secrets-manager": "^3.1010.0",
"@aws-sdk/client-ses": "^3.997.0", "@aws-sdk/client-ses": "^3.1010.0",
"@aws-sdk/client-sqs": "^3.997.0", "@aws-sdk/client-sqs": "^3.1010.0",
"@aws-sdk/client-textract": "^3.997.0", "@aws-sdk/client-textract": "^3.1010.0",
"@aws-sdk/credential-provider-node": "^3.972.12", "@aws-sdk/credential-provider-node": "^3.972.21",
"@aws-sdk/lib-storage": "^3.997.0", "@aws-sdk/lib-storage": "^3.1010.0",
"@aws-sdk/s3-request-presigner": "^3.997.0", "@aws-sdk/s3-request-presigner": "^3.1010.0",
"@opensearch-project/opensearch": "^2.13.0", "@opensearch-project/opensearch": "^2.13.0",
"@socket.io/admin-ui": "^0.5.1", "@socket.io/admin-ui": "^0.5.1",
"@socket.io/redis-adapter": "^8.3.0", "@socket.io/redis-adapter": "^8.3.0",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"aws4": "^1.13.2", "aws4": "^1.13.2",
"axios": "^1.13.5", "axios": "^1.13.6",
"axios-curlirize": "^2.0.0", "axios-curlirize": "^2.0.0",
"better-queue": "^3.8.12", "better-queue": "^3.8.12",
"bullmq": "^5.70.1", "bullmq": "^5.71.0",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"cloudinary": "^2.9.0", "cloudinary": "^2.9.0",
"compression": "^1.8.1", "compression": "^1.8.1",
@@ -46,20 +46,20 @@
"dinero.js": "^1.9.1", "dinero.js": "^1.9.1",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"express": "^4.21.1", "express": "^4.21.1",
"fast-xml-parser": "^5.4.1", "fast-xml-parser": "^5.5.6",
"firebase-admin": "^13.6.1", "firebase-admin": "^13.7.0",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"graphql": "^16.13.0", "graphql": "^16.13.1",
"graphql-request": "^6.1.0", "graphql-request": "^6.1.0",
"intuit-oauth": "^4.2.2", "intuit-oauth": "^4.2.2",
"ioredis": "^5.9.3", "ioredis": "^5.10.0",
"json-2-csv": "^5.5.10", "json-2-csv": "^5.5.10",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"juice": "^11.1.1", "juice": "^11.1.1",
"lodash": "^4.17.23", "lodash": "^4.17.23",
"moment": "^2.30.1", "moment": "^2.30.1",
"moment-timezone": "^0.6.0", "moment-timezone": "^0.6.0",
"multer": "^2.0.2", "multer": "^2.1.1",
"mustache": "^4.2.0", "mustache": "^4.2.0",
"node-persist": "^4.0.4", "node-persist": "^4.0.4",
"nodemailer": "^6.10.0", "nodemailer": "^6.10.0",
@@ -69,15 +69,15 @@
"recursive-diff": "^1.0.9", "recursive-diff": "^1.0.9",
"rimraf": "^6.1.3", "rimraf": "^6.1.3",
"skia-canvas": "^3.0.8", "skia-canvas": "^3.0.8",
"soap": "^1.7.1", "soap": "^1.8.0",
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"socket.io-adapter": "^2.5.6", "socket.io-adapter": "^2.5.6",
"ssh2-sftp-client": "^11.0.0", "ssh2-sftp-client": "^11.0.0",
"twilio": "^5.12.2", "twilio": "^5.13.0",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"winston": "^3.19.0", "winston": "^3.19.0",
"winston-cloudwatch": "^6.3.0", "winston-cloudwatch": "^6.3.0",
"xml-formatter": "^3.6.7", "xml-formatter": "^3.7.0",
"xml2js": "^0.6.2", "xml2js": "^0.6.2",
"xmlbuilder2": "^4.0.3", "xmlbuilder2": "^4.0.3",
"yazl": "^3.3.1" "yazl": "^3.3.1"
@@ -86,11 +86,11 @@
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"globals": "^17.3.0", "globals": "^17.4.0",
"mock-require": "^3.0.3", "mock-require": "^3.0.3",
"p-limit": "^3.1.0", "p-limit": "^3.1.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"supertest": "^7.2.2", "supertest": "^7.2.2",
"vitest": "^4.0.18" "vitest": "^4.1.0"
} }
} }