IO-3624 Polish employee and team config layouts

This commit is contained in:
Dave
2026-03-24 12:50:11 -04:00
parent bcb693f03c
commit fd712da4a3
12 changed files with 533 additions and 405 deletions

View File

@@ -8,7 +8,7 @@ import { INSERT_VACATION } from "../../graphql/employees.queries";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx"; import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
export default function ShopEmployeeAddVacation({ employee }) { export default function ShopEmployeeAddVacation({ employee, buttonProps }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [insertVacation] = useMutation(INSERT_VACATION); const [insertVacation] = useMutation(INSERT_VACATION);
@@ -117,7 +117,7 @@ export default function ShopEmployeeAddVacation({ employee }) {
return ( return (
<Popover content={overlay} open={visibility}> <Popover content={overlay} open={visibility}>
<Button loading={loading} disabled={!employee?.active} onClick={handleClick}> <Button loading={loading} disabled={!employee?.active} onClick={handleClick} {...buttonProps}>
{t("employees.actions.addvacation")} {t("employees.actions.addvacation")}
</Button> </Button>
</Popover> </Popover>

View File

@@ -1,7 +1,7 @@
import { DeleteFilled } from "@ant-design/icons"; import { DeleteFilled } from "@ant-design/icons";
import { useApolloClient, useMutation, useQuery } from "@apollo/client/react"; import { useApolloClient, useMutation, useQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { Button, Card, Form, Input, InputNumber, Select, Space, Switch } from "antd"; import { Button, Card, Col, Form, Input, InputNumber, Row, Select, Space, Switch } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component"; import ResponsiveTable from "../responsive-table/responsive-table.component";
import { useForm } from "antd/es/form/Form"; import { useForm } from "antd/es/form/Form";
import queryString from "query-string"; import queryString from "query-string";
@@ -30,6 +30,7 @@ import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.c
import { getFormListItemTitle } from "../form-list-move-arrows/form-list-item-title.utils"; import { getFormListItemTitle } from "../form-list-move-arrows/form-list-item-title.utils";
import LayoutFormRow from "../layout-form-row/layout-form-row.component"; import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import ShopEmployeeAddVacation from "./shop-employees-add-vacation.component"; import ShopEmployeeAddVacation from "./shop-employees-add-vacation.component";
import FormItemEmail from "../form-items-formatted/email-form-item.component.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
@@ -42,6 +43,14 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [form] = useForm(); const [form] = useForm();
const employeeRates = Form.useWatch(["rates"], form) || []; const employeeRates = Form.useWatch(["rates"], form) || [];
const employeeOptionsColProps = {
xs: 24,
sm: 12,
md: 8,
lg: { flex: "0 0 320px" },
xl: { flex: "0 0 280px" },
xxl: { flex: "0 0 380px" }
};
const history = useNavigate(); const history = useNavigate();
const search = queryString.parse(useLocation().search); const search = queryString.parse(useLocation().search);
const [deleteVacation] = useMutation(DELETE_VACATION); const [deleteVacation] = useMutation(DELETE_VACATION);
@@ -177,7 +186,10 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
} }
> >
<Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}> <Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}>
<LayoutFormRow> <LayoutFormRow title={t("bodyshop.labels.employee_options")}>
<div style={{ display: "grid", rowGap: 16 }}>
<Row gutter={[16, 16]} wrap>
<Col {...employeeOptionsColProps}>
<Form.Item <Form.Item
name="first_name" name="first_name"
label={t("employees.fields.first_name")} label={t("employees.fields.first_name")}
@@ -190,6 +202,8 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
> >
<Input /> <Input />
</Form.Item> </Form.Item>
</Col>
<Col {...employeeOptionsColProps}>
<Form.Item <Form.Item
label={t("employees.fields.last_name")} label={t("employees.fields.last_name")}
name="last_name" name="last_name"
@@ -202,6 +216,8 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
> >
<Input /> <Input />
</Form.Item> </Form.Item>
</Col>
<Col {...employeeOptionsColProps}>
<Form.Item <Form.Item
name="employee_number" name="employee_number"
label={t("employees.fields.employee_number")} label={t("employees.fields.employee_number")}
@@ -240,6 +256,8 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
> >
<Input /> <Input />
</Form.Item> </Form.Item>
</Col>
<Col {...employeeOptionsColProps}>
<Form.Item <Form.Item
label={t("employees.fields.pin")} label={t("employees.fields.pin")}
name="pin" name="pin"
@@ -252,14 +270,20 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
> >
<Input /> <Input />
</Form.Item> </Form.Item>
</LayoutFormRow> </Col>
<LayoutFormRow> </Row>
<Row gutter={[16, 16]} wrap>
<Col {...employeeOptionsColProps}>
<Form.Item label={t("employees.fields.active")} valuePropName="checked" name="active"> <Form.Item label={t("employees.fields.active")} valuePropName="checked" name="active">
<Switch /> <Switch />
</Form.Item> </Form.Item>
</Col>
<Col {...employeeOptionsColProps}>
<Form.Item label={t("employees.fields.flat_rate")} name="flat_rate" valuePropName="checked"> <Form.Item label={t("employees.fields.flat_rate")} name="flat_rate" valuePropName="checked">
<Switch /> <Switch />
</Form.Item> </Form.Item>
</Col>
<Col {...employeeOptionsColProps}>
<Form.Item <Form.Item
name="hire_date" name="hire_date"
label={t("employees.fields.hire_date")} label={t("employees.fields.hire_date")}
@@ -272,9 +296,13 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
> >
<DateTimePicker isDateOnly /> <DateTimePicker isDateOnly />
</Form.Item> </Form.Item>
</Col>
<Col {...employeeOptionsColProps}>
<Form.Item label={t("employees.fields.termination_date")} name="termination_date"> <Form.Item label={t("employees.fields.termination_date")} name="termination_date">
<DateTimePicker isDateOnly /> <DateTimePicker isDateOnly />
</Form.Item> </Form.Item>
</Col>
<Col {...employeeOptionsColProps}>
<Form.Item <Form.Item
label={t("employees.fields.user_email")} label={t("employees.fields.user_email")}
name="user_email" name="user_email"
@@ -303,12 +331,18 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
}) })
]} ]}
> >
<Input /> <FormItemEmail />
</Form.Item> </Form.Item>
</Col>
<Col {...employeeOptionsColProps}>
<Form.Item label={t("employees.fields.external_id")} name="external_id"> <Form.Item label={t("employees.fields.external_id")} name="external_id">
<Input /> <Input />
</Form.Item> </Form.Item>
</Col>
</Row>
</div>
</LayoutFormRow> </LayoutFormRow>
<LayoutFormRow title={t("bodyshop.labels.employee_rates")}>
<Form.List name={["rates"]}> <Form.List name={["rates"]}>
{(fields, { add, remove, move }) => { {(fields, { add, remove, move }) => {
return ( return (
@@ -320,7 +354,11 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
<Form.Item key={field.key} style={{ padding: 0, margin: 2 }}> <Form.Item key={field.key} style={{ padding: 0, margin: 2 }}>
<LayoutFormRow <LayoutFormRow
grow grow
title={getFormListItemTitle(t("employees.fields.cost_center"), index, employeeRate.cost_center)} title={getFormListItemTitle(
t("employees.fields.cost_center"),
index,
employeeRate.cost_center
)}
extra={ extra={
<Space align="center" size="small"> <Space align="center" size="small">
<Button <Button
@@ -399,15 +437,29 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
); );
}} }}
</Form.List> </Form.List>
</LayoutFormRow>
</Form> </Form>
<LayoutFormRow title={t("bodyshop.labels.employee_vacation")}>
<div>
<ResponsiveTable <ResponsiveTable
title={() => <ShopEmployeeAddVacation employee={data && data.employees_by_pk} />}
columns={columns} columns={columns}
mobileColumnKeys={["start", "length", "actions"]} mobileColumnKeys={["start", "length", "actions"]}
rowKey={"id"} rowKey={"id"}
dataSource={data?.employees_by_pk?.employee_vacations ?? []} dataSource={data?.employees_by_pk?.employee_vacations ?? []}
pagination={false}
/> />
<div style={{ marginTop: 12 }}>
<ShopEmployeeAddVacation
employee={data && data.employees_by_pk}
buttonProps={{
type: "dashed",
block: true
}}
/>
</div>
</div>
</LayoutFormRow>
</Card> </Card>
); );
} }

View File

@@ -30,7 +30,7 @@ export default function ShopEmployeesListComponent({ loading, employees }) {
}; };
const columns = [ const columns = [
{ {
title: t("employees.fields.employee_number"), title: t("employees.labels.employee_number_short"),
dataIndex: "employee_number", dataIndex: "employee_number",
key: "employee_number", key: "employee_number",
sorter: (a, b) => alphaSort(a.employee_number, b.employee_number), sorter: (a, b) => alphaSort(a.employee_number, b.employee_number),

View File

@@ -1,29 +1,47 @@
import { useQuery } from "@apollo/client/react"; import { useQuery } from "@apollo/client/react";
import queryString from "query-string";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { useLocation } from "react-router-dom";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { QUERY_EMPLOYEES } from "../../graphql/employees.queries"; import { QUERY_EMPLOYEES } from "../../graphql/employees.queries";
import AlertComponent from "../alert/alert.component"; import AlertComponent from "../alert/alert.component";
import ShopEmployeesFormComponent from "./shop-employees-form.component"; import ShopEmployeesFormComponent from "./shop-employees-form.component";
import ShopEmployeesListComponent from "./shop-employees-list.component"; import ShopEmployeesListComponent from "./shop-employees-list.component";
import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component"; import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component";
import "./shop-employees.styles.scss";
const mapStateToProps = createStructuredSelector({}); const mapStateToProps = createStructuredSelector({});
function ShopEmployeesContainer() { function ShopEmployeesContainer() {
const search = queryString.parse(useLocation().search);
const { loading, error, data } = useQuery(QUERY_EMPLOYEES, { const { loading, error, data } = useQuery(QUERY_EMPLOYEES, {
fetchPolicy: "network-only", fetchPolicy: "network-only",
nextFetchPolicy: "network-only" nextFetchPolicy: "network-only"
}); });
const hasSelectedEmployee = Boolean(search.employeeId);
if (error) return <AlertComponent title={error.message} type="error" />; if (error) return <AlertComponent title={error.message} type="error" />;
return ( return (
<div>
<RbacWrapper action="employees:page"> <RbacWrapper action="employees:page">
<div
className={[
"shop-employees-layout",
hasSelectedEmployee ? "shop-employees-layout--with-detail" : null
]
.filter(Boolean)
.join(" ")}
>
<div className="shop-employees-layout__list">
<ShopEmployeesListComponent employees={data ? data.employees : []} loading={loading} /> <ShopEmployeesListComponent employees={data ? data.employees : []} loading={loading} />
<ShopEmployeesFormComponent />
</RbacWrapper>
</div> </div>
{hasSelectedEmployee ? (
<div className="shop-employees-layout__details">
<ShopEmployeesFormComponent />
</div>
) : null}
</div>
</RbacWrapper>
); );
} }

View File

@@ -0,0 +1,16 @@
.shop-employees-layout {
display: grid;
gap: 16px;
align-items: start;
}
.shop-employees-layout__list,
.shop-employees-layout__details {
min-width: 0;
}
@media (min-width: 1700px) {
.shop-employees-layout--with-detail {
grid-template-columns: minmax(420px, 500px) minmax(0, 1fr);
}
}

View File

@@ -1,4 +1,4 @@
import { Card, Typography } from "antd"; import { Card } from "antd";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
@@ -15,9 +15,8 @@ function ShopInfoConsentComponent({ bodyshop }) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Card> <Card title={t("settings.title")}>
<Typography.Title level={4}>{t("settings.title")}</Typography.Title> <PhoneNumberConsentList bodyshop={bodyshop} />
{<PhoneNumberConsentList bodyshop={bodyshop} />}
</Card> </Card>
); );
} }

View File

@@ -132,9 +132,22 @@ 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 isTeamHydrating = !isNewTeam && Boolean(search.employeeTeamId) && hydratedTeamId !== search.employeeTeamId; const isTeamHydrating = !isNewTeam && Boolean(search.employeeTeamId) && hydratedTeamId !== search.employeeTeamId;
const isAllocationTotalExact = hasExactSplitTotal(teamMembers);
const allocationTotalValue = formatAllocationPercentage(getSplitTotal(teamMembers))?.replace("%", "") || "0";
const teamNameDisplay = teamName?.trim() || t("employee_teams.fields.name");
const teamCardTitle = isTeamHydrating const teamCardTitle = isTeamHydrating
? t("employee_teams.fields.name") ? t("employee_teams.fields.name")
: teamName?.trim() || t("employee_teams.fields.name"); : (
<span>
<span>{teamNameDisplay}</span>
<span> - </span>
<Typography.Text type={isAllocationTotalExact ? undefined : "danger"}>
{t("employee_teams.labels.allocation_total", {
total: allocationTotalValue
})}
</Typography.Text>
</span>
);
const getTeamMemberTitle = (teamMember = {}) => { const getTeamMemberTitle = (teamMember = {}) => {
const employeeName = const employeeName =
@@ -241,7 +254,7 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
<Skeleton active title={false} paragraph={{ rows: 12 }} /> <Skeleton active title={false} paragraph={{ rows: 12 }} />
) : ( ) : (
<Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}> <Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}>
<LayoutFormRow> <LayoutFormRow title={t("employee_teams.labels.team_options")}>
<Form.Item <Form.Item
name="name" name="name"
label={t("employee_teams.fields.name")} label={t("employee_teams.fields.name")}
@@ -268,6 +281,7 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
<InputNumber min={0} precision={1} /> <InputNumber min={0} precision={1} />
</Form.Item> </Form.Item>
</LayoutFormRow> </LayoutFormRow>
<LayoutFormRow title={t("employee_teams.labels.members")}>
<Form.List name={["employee_team_members"]}> <Form.List name={["employee_team_members"]}>
{(fields, { add, remove, move }) => { {(fields, { add, remove, move }) => {
return ( return (
@@ -400,24 +414,11 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
{t("employee_teams.actions.newmember")} {t("employee_teams.actions.newmember")}
</Button> </Button>
</Form.Item> </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> </div>
); );
}} }}
</Form.List> </Form.List>
</LayoutFormRow>
</Form> </Form>
)} )}
</Card> </Card>

View File

@@ -1,36 +1,44 @@
import { useQuery } from "@apollo/client/react"; import { useQuery } from "@apollo/client/react";
import queryString from "query-string";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { useLocation } from "react-router-dom";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { QUERY_TEAMS } from "../../graphql/employee_teams.queries"; import { QUERY_TEAMS } from "../../graphql/employee_teams.queries";
import AlertComponent from "../alert/alert.component"; import AlertComponent from "../alert/alert.component";
import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component"; import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component";
import ShopEmployeeTeamsListComponent from "./shop-employee-teams.list"; import ShopEmployeeTeamsListComponent from "./shop-employee-teams.list";
import ShopEmployeeTeamsFormComponent from "./shop-employee-teams.form.component"; import ShopEmployeeTeamsFormComponent from "./shop-employee-teams.form.component";
import { Col, Row } from "antd"; import "./shop-teams.styles.scss";
const mapStateToProps = createStructuredSelector({}); const mapStateToProps = createStructuredSelector({});
function ShopTeamsContainer() { function ShopTeamsContainer() {
const search = queryString.parse(useLocation().search);
const { loading, error, data } = useQuery(QUERY_TEAMS, { const { loading, error, data } = useQuery(QUERY_TEAMS, {
fetchPolicy: "network-only", fetchPolicy: "network-only",
nextFetchPolicy: "network-only" nextFetchPolicy: "network-only"
}); });
const hasSelectedTeam = Boolean(search.employeeTeamId);
if (error) return <AlertComponent title={error.message} type="error" />; if (error) return <AlertComponent title={error.message} type="error" />;
return ( return (
<div>
<RbacWrapper action="employee_teams:page"> <RbacWrapper action="employee_teams:page">
<Row gutter={[16, 16]}> <div
<Col span={6}> className={["shop-teams-layout", hasSelectedTeam ? "shop-teams-layout--with-detail" : null]
.filter(Boolean)
.join(" ")}
>
<div className="shop-teams-layout__list">
<ShopEmployeeTeamsListComponent employee_teams={data ? data.employee_teams : []} loading={loading} /> <ShopEmployeeTeamsListComponent employee_teams={data ? data.employee_teams : []} loading={loading} />
</Col>
<Col span={18}>
<ShopEmployeeTeamsFormComponent />
</Col>
</Row>
</RbacWrapper>
</div> </div>
{hasSelectedTeam ? (
<div className="shop-teams-layout__details">
<ShopEmployeeTeamsFormComponent />
</div>
) : null}
</div>
</RbacWrapper>
); );
} }

View File

@@ -0,0 +1,16 @@
.shop-teams-layout {
display: grid;
gap: 16px;
align-items: start;
}
.shop-teams-layout__list,
.shop-teams-layout__details {
min-width: 0;
}
@media (min-width: 1700px) {
.shop-teams-layout--with-detail {
grid-template-columns: minmax(420px, 500px) minmax(0, 1fr);
}
}

View File

@@ -736,6 +736,9 @@
}, },
"emaillater": "Email Later", "emaillater": "Email Later",
"employee_teams": "Employee Teams", "employee_teams": "Employee Teams",
"employee_options": "Employee Options",
"employee_rates": "Employee Rates",
"employee_vacation": "Employee Vacation",
"employees": "Employees", "employees": "Employees",
"estimators": "Estimators", "estimators": "Estimators",
"filehandlers": "Adjusters", "filehandlers": "Adjusters",
@@ -1202,7 +1205,9 @@
"percentage": "Percent" "percentage": "Percent"
}, },
"labels": { "labels": {
"allocation_total": "Allocation Total: {{total}}%" "allocation_total": "Allocation Total: {{total}}%",
"members": "Members",
"team_options": "Team Options"
}, },
"options": { "options": {
"commission": "Commission", "commission": "Commission",
@@ -1246,6 +1251,7 @@
"labels": { "labels": {
"actions": "Actions", "actions": "Actions",
"active": "Active", "active": "Active",
"employee_number_short": "Employee #",
"endmustbeafterstart": "End date must be after start date.", "endmustbeafterstart": "End date must be after start date.",
"flat_rate": "Flat Rate", "flat_rate": "Flat Rate",
"inactive": "Inactive", "inactive": "Inactive",

View File

@@ -736,6 +736,9 @@
}, },
"emaillater": "", "emaillater": "",
"employee_teams": "", "employee_teams": "",
"employee_options": "",
"employee_rates": "",
"employee_vacation": "",
"employees": "", "employees": "",
"estimators": "", "estimators": "",
"filehandlers": "", "filehandlers": "",
@@ -1202,7 +1205,9 @@
"percentage": "" "percentage": ""
}, },
"labels": { "labels": {
"allocation_total": "" "allocation_total": "",
"members": "",
"team_options": ""
}, },
"options": { "options": {
"commission": "", "commission": "",
@@ -1246,6 +1251,7 @@
"labels": { "labels": {
"actions": "", "actions": "",
"active": "", "active": "",
"employee_number_short": "",
"endmustbeafterstart": "", "endmustbeafterstart": "",
"flat_rate": "", "flat_rate": "",
"inactive": "", "inactive": "",

View File

@@ -736,6 +736,9 @@
}, },
"emaillater": "", "emaillater": "",
"employee_teams": "", "employee_teams": "",
"employee_options": "",
"employee_rates": "",
"employee_vacation": "",
"employees": "", "employees": "",
"estimators": "", "estimators": "",
"filehandlers": "", "filehandlers": "",
@@ -1202,7 +1205,9 @@
"percentage": "" "percentage": ""
}, },
"labels": { "labels": {
"allocation_total": "" "allocation_total": "",
"members": "",
"team_options": ""
}, },
"options": { "options": {
"commission": "", "commission": "",
@@ -1246,6 +1251,7 @@
"labels": { "labels": {
"actions": "", "actions": "",
"active": "", "active": "",
"employee_number_short": "",
"endmustbeafterstart": "", "endmustbeafterstart": "",
"flat_rate": "", "flat_rate": "",
"inactive": "", "inactive": "",