IO-3624 Polish config empty states and admin cards

This commit is contained in:
Dave
2026-03-25 10:16:48 -04:00
parent 3aa19ec09f
commit b8246e03c1
22 changed files with 4976 additions and 4967 deletions

View File

@@ -13,6 +13,7 @@ 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 ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import {
INLINE_TITLE_GROUP_STYLE,
@@ -20,7 +21,8 @@ import {
INLINE_TITLE_INPUT_STYLE,
INLINE_TITLE_LABEL_STYLE,
INLINE_TITLE_ROW_STYLE,
INLINE_TITLE_SEPARATOR_STYLE
INLINE_TITLE_SEPARATOR_STYLE,
INLINE_TITLE_SWITCH_GROUP_STYLE
} from "../layout-form-row/inline-form-row-title.utils.js";
import {
@@ -209,8 +211,8 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
<Card
title={isTeamHydrating ? undefined : teamCardTitle}
extra={
<Button type="primary" onClick={() => form.submit()} disabled={isTeamHydrating}>
{t("general.actions.save")}
<Button type="primary" onClick={() => form.submit()} disabled={isTeamHydrating} style={{ minWidth: 190 }}>
{t("employee_teams.actions.save_team")}
</Button>
}
>
@@ -239,8 +241,7 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
</div>
<div
style={{
...INLINE_TITLE_GROUP_STYLE,
flex: "0 0 auto",
...INLINE_TITLE_SWITCH_GROUP_STYLE,
marginLeft: "auto"
}}
>
@@ -277,188 +278,181 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
<InputNumber min={0} precision={1} suffix="%" />
</Form.Item>
</LayoutFormRow>
<LayoutFormRow title={t("employee_teams.labels.members")}>
<Form.List name={["employee_team_members"]}>
{(fields, { add, remove, move }) => {
return (
<Form.List name={["employee_team_members"]}>
{(fields, { add, remove, move }) => {
return (
<LayoutFormRow
title={t("employee_teams.labels.members")}
actions={[
<Button
key="add-team-member"
type="primary"
block
onClick={() => {
add({
percentage: 0,
payout_method: "hourly",
labor_rates: {},
commission_rates: {}
});
}}
>
{t("employee_teams.actions.newmember")}
</Button>
]}
>
<div>
{fields.map((field, index) => {
return (
<Form.Item key={field.key} style={{ padding: 0, margin: 2 }}>
<Form.Item name={[field.name, "id"]} hidden>
<Input type="hidden" />
</Form.Item>
<LayoutFormRow
grow
title={
<div style={INLINE_TITLE_ROW_STYLE}>
<HolderOutlined style={INLINE_TITLE_HANDLE_STYLE} />
<div
style={{
...INLINE_TITLE_GROUP_STYLE,
flex: "1 1 320px"
}}
>
<div style={INLINE_TITLE_LABEL_STYLE}>{t("employee_teams.fields.employeeid")}</div>
<Form.Item
noStyle
name={[field.name, "employeeid"]}
rules={[
{
required: true
}
]}
>
<EmployeeSearchSelectComponent options={bodyshop.employees} />
</Form.Item>
{fields.length === 0 ? (
<ConfigListEmptyState actionLabel={t("employee_teams.actions.newmember")} />
) : (
fields.map((field, index) => {
return (
<Form.Item key={field.key} style={{ padding: 0, margin: 2 }}>
<Form.Item name={[field.name, "id"]} hidden>
<Input type="hidden" />
</Form.Item>
<LayoutFormRow
grow
title={
<div style={INLINE_TITLE_ROW_STYLE}>
<HolderOutlined style={INLINE_TITLE_HANDLE_STYLE} />
<div style={INLINE_TITLE_GROUP_STYLE}>
<div style={INLINE_TITLE_LABEL_STYLE}>{t("employee_teams.fields.employeeid")}</div>
<Form.Item
noStyle
name={[field.name, "employeeid"]}
rules={[
{
required: true
}
]}
>
<EmployeeSearchSelectComponent options={bodyshop.employees} />
</Form.Item>
</div>
<div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} />
<div style={INLINE_TITLE_GROUP_STYLE}>
<div style={INLINE_TITLE_LABEL_STYLE}>{t("employee_teams.fields.allocation")}</div>
<Form.Item
noStyle
name={[field.name, "percentage"]}
rules={[
{
required: true
}
]}
>
<InputNumber
min={0}
max={100}
precision={2}
size="small"
aria-label={t("employee_teams.fields.allocation")}
suffix="%"
style={{
...INLINE_TITLE_INPUT_STYLE,
width: "100%"
}}
/>
</Form.Item>
</div>
<div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} />
<div style={INLINE_TITLE_GROUP_STYLE}>
<div style={INLINE_TITLE_LABEL_STYLE}>{t("employee_teams.fields.payout_method")}</div>
<Form.Item
noStyle
key={`${index}-payout-method`}
name={[field.name, "payout_method"]}
initialValue="hourly"
rules={[
{
required: true
}
]}
>
<Select
aria-label={t("employee_teams.fields.payout_method")}
size="small"
options={payoutMethodOptions}
style={{ width: "100%" }}
styles={{
selector: INLINE_TITLE_INPUT_STYLE
}}
/>
</Form.Item>
</div>
</div>
<div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} />
<div
style={{
...INLINE_TITLE_GROUP_STYLE,
flex: "0 1 170px"
}}
}
wrapTitle
extra={
<Space align="center" size="small">
<Button
type="text"
danger
icon={<DeleteFilled />}
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows
move={move}
index={index}
total={fields.length}
orientation="horizontal"
/>
</Space>
}
>
<div>
<Form.Item
noStyle
dependencies={[["employee_team_members", field.name, "payout_method"]]}
>
<div style={INLINE_TITLE_LABEL_STYLE}>{t("employee_teams.fields.allocation")}</div>
<Form.Item
noStyle
name={[field.name, "percentage"]}
rules={[
{
required: true
}
]}
>
<InputNumber
min={0}
max={100}
precision={2}
size="small"
aria-label={t("employee_teams.fields.allocation")}
suffix="%"
style={{
...INLINE_TITLE_INPUT_STYLE,
width: "100%"
}}
/>
</Form.Item>
</div>
<div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} />
<div
style={{
...INLINE_TITLE_GROUP_STYLE,
flex: "0 1 260px"
}}
>
<div style={INLINE_TITLE_LABEL_STYLE}>{t("employee_teams.fields.payout_method")}</div>
<Form.Item
noStyle
key={`${index}-payout-method`}
name={[field.name, "payout_method"]}
initialValue="hourly"
rules={[
{
required: true
}
]}
>
<Select
aria-label={t("employee_teams.fields.payout_method")}
size="small"
options={payoutMethodOptions}
style={{ width: "100%" }}
styles={{
selector: INLINE_TITLE_INPUT_STYLE
}}
/>
</Form.Item>
</div>
</div>
}
wrapTitle
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>
<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";
{() => {
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
}
]}
return (
<Row gutter={[16, 0]}>
{LABOR_TYPES.map((laborType) => (
<Col
{...TEAM_MEMBER_RATE_FIELD_COLS}
key={`${index}-${fieldName}-${laborType}`}
>
{payoutMethod === "commission" ? (
<InputNumber min={0} max={100} precision={2} suffix="%" />
) : (
<CurrencyInput prefix="$" />
)}
</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
label={t(`joblines.fields.lbr_types.${laborType}`)}
name={[field.name, fieldName, laborType]}
rules={[
{
required: true
}
]}
>
{payoutMethod === "commission" ? (
<InputNumber min={0} max={100} precision={2} suffix="%" />
) : (
<CurrencyInput prefix="$" />
)}
</Form.Item>
</Col>
))}
</Row>
);
}}
</Form.Item>
</div>
</LayoutFormRow>
</Form.Item>
);
})
)}
</div>
);
}}
</Form.List>
</LayoutFormRow>
</LayoutFormRow>
);
}}
</Form.List>
</Form>
)}
</Card>

View File

@@ -42,9 +42,11 @@ vi.mock("react-i18next", () => ({
"employee_teams.options.commission": "Commission",
"employee_teams.options.commission_percentage": "Commission",
"employee_teams.actions.newmember": "New Team Member",
"employee_teams.actions.save_team": "Save Employee Team",
"employee_teams.errors.minimum_one_member": "Add at least one team member.",
"employee_teams.errors.duplicate_member": "Team members must be unique.",
"employee_teams.errors.allocation_total_exact": "Allocation must total exactly 100%.",
"general.labels.click_to_begin": `Click ${values.action ?? ""} to begin`,
"general.actions.save": "Save",
"employees.successes.save": "Saved"
};
@@ -101,11 +103,12 @@ vi.mock("../form-items-formatted/currency-form-item.component", () => ({
}));
vi.mock("../layout-form-row/layout-form-row.component", () => ({
default: ({ title, extra, children }) => (
default: ({ title, extra, actions, children }) => (
<div>
{title}
{extra}
{children}
{actions}
</div>
)
}));
@@ -211,7 +214,7 @@ describe("ShopEmployeeTeamsFormComponent", () => {
rate: 27.5
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
fireEvent.click(screen.getByRole("button", { name: "Save Employee Team" }));
await waitFor(() => {
expect(insertEmployeeTeamMock).toHaveBeenCalledWith({

View File

@@ -2,6 +2,8 @@ import { Button } from "antd";
import queryString from "query-string";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import ResponsiveTable from "../responsive-table/responsive-table.component";
export default function ShopEmployeeTeamsListComponent({ loading, employee_teams }) {
@@ -9,13 +11,28 @@ export default function ShopEmployeeTeamsListComponent({ loading, employee_teams
const history = useNavigate();
const search = queryString.parse(useLocation().search);
const navigateToTeam = (employeeTeamId) => {
history({
search: queryString.stringify({
...search,
employeeTeamId
})
});
};
const clearTeamSelection = () => {
const { employeeTeamId, ...nextSearch } = search;
void employeeTeamId;
history({
search: queryString.stringify(nextSearch)
});
};
const handleOnRowClick = (record) => {
if (record) {
search.employeeTeamId = record.id;
history({ search: queryString.stringify(search) });
navigateToTeam(record.id);
} else {
delete search.employeeTeamId;
history({ search: queryString.stringify(search) });
clearTeamSelection();
}
};
const columns = [
@@ -27,43 +44,38 @@ export default function ShopEmployeeTeamsListComponent({ loading, employee_teams
];
return (
<div>
<ResponsiveTable
title={() => {
return (
<Button
type="primary"
onClick={() => {
search.employeeTeamId = "new";
history({ search: queryString.stringify(search) });
}}
>
{t("employee_teams.actions.new")}
</Button>
);
}}
loading={loading}
pagination={{ placement: "top" }}
columns={columns}
mobileColumnKeys={["name"]}
rowKey="id"
dataSource={employee_teams}
rowSelection={{
onSelect: (props) => {
search.employeeTeamId = props.id;
history({ search: queryString.stringify(search) });
},
type: "radio",
selectedRowKeys: [search.employeeTeamId]
}}
onRow={(record) => {
return {
onClick: () => {
handleOnRowClick(record);
}
};
}}
/>
</div>
<LayoutFormRow
title={t("bodyshop.labels.employee_teams")}
actions={[
<Button key="new-team" type="primary" block onClick={() => navigateToTeam("new")}>
{t("employee_teams.actions.new")}
</Button>
]}
>
{employee_teams.length === 0 ? (
<ConfigListEmptyState actionLabel={t("employee_teams.actions.new")} />
) : (
<ResponsiveTable
loading={loading}
pagination={{ placement: "top" }}
columns={columns}
mobileColumnKeys={["name"]}
rowKey="id"
dataSource={employee_teams}
rowSelection={{
onSelect: (props) => navigateToTeam(props.id),
type: "radio",
selectedRowKeys: [search.employeeTeamId]
}}
onRow={(record) => {
return {
onClick: () => {
handleOnRowClick(record);
}
};
}}
/>
)}
</LayoutFormRow>
);
}