diff --git a/src/components/mui/FormItemTable/__tests__/FormItemTable.test.js b/src/components/mui/FormItemTable/__tests__/FormItemTable.test.js index 67dbfb264..6816e36c1 100644 --- a/src/components/mui/FormItemTable/__tests__/FormItemTable.test.js +++ b/src/components/mui/FormItemTable/__tests__/FormItemTable.test.js @@ -19,18 +19,7 @@ import FormItemTable from "../index"; import ItemTableField from "../components/ItemTableField"; /* eslint-enable import/first */ -const EARLY_BIRD_DATE = 1751035704; -const STANDARD_DATE = 1851035704; -const ONSITE_DATE = 1951035704; -const MOCK_TIME_BEFORE_EARLY_BIRD = 1650000000000; -const MILLISECONDS_MULTIPLIER = 1000; -const TIME_OFFSET = 100; const TWO_ITEMS = 4; -const MOCK_RATE_DATES = { - early_bird_end_date: EARLY_BIRD_DATE, - standard_price_end_date: STANDARD_DATE, - onsite_end_date: ONSITE_DATE -}; // Mock form data const MOCK_FORM_A = { @@ -135,6 +124,34 @@ const MOCK_FORM_A = { ] }; +const MOCK_ITEMS_WITH_NULL_RATES = [ + { + form_item_id: 5, + code: "ITEM-NA", + name: "Item With N/A Rates", + rates: { + early_bird: null, + standard: null, + onsite: null + }, + meta_fields: [] + } +]; + +const MOCK_ITEMS_WITH_MIXED_RATES = [ + { + form_item_id: 6, + code: "ITEM-PARTIAL", + name: "Item Partial Rates", + rates: { + early_bird: 10000, + standard: null, + onsite: null + }, + meta_fields: [] + } +]; + jest.mock("../../formik-inputs/mui-formik-textfield", () => { const { useField } = require("formik"); return { @@ -188,7 +205,7 @@ jest.mock("../../formik-inputs/mui-formik-datepicker", () => ({ jest.mock("../../formik-inputs/mui-formik-select", () => ({ __esModule: true, - default: ({ name, label, options }) => ( + default: ({ name, label, options, children }) => ( ) })); @@ -247,12 +265,56 @@ jest.mock("../../formik-inputs/mui-formik-dropdown-radio", () => ({ ) })); +jest.mock("../../formik-inputs/mui-formik-pricefield", () => { + const { useField } = require("formik"); + return { + __esModule: true, + default: ({ name, label, ...props }) => { + const [field] = useField(name); + return ( + + ); + } + }; +}); + +jest.mock("../../formik-inputs/mui-formik-discountfield", () => { + const { useField } = require("formik"); + return { + __esModule: true, + default: ({ name, label, discountType, ...props }) => { + const [field] = useField(name); + return ( + + ); + } + }; +}); + // ---- Helpers ---- // Wrapper component with Formik const FormItemTableWrapper = ({ data, - rateDates, + currentApplicableRate, timeZone, initialValues, onNotesClick, @@ -273,7 +335,7 @@ const FormItemTableWrapper = ({ { beforeEach(() => { jest.clearAllMocks(); - jest - .spyOn(Date, "now") - .mockImplementation(() => MOCK_TIME_BEFORE_EARLY_BIRD); }); afterEach(() => { @@ -318,7 +378,7 @@ describe("FormItemTable Component", () => { render( { render( { render( { render( { render( { }); }); + describe("N/A Rates", () => { + it("displays N/A for items with null rates", () => { + render( + + ); + + expect(screen.getAllByText("general.n_a").length).toBeGreaterThan(0); + }); + + it("displays N/A for null rates and dollar amounts for non-null rates on the same item", () => { + render( + + ); + + expect(screen.getByText("$100.00")).toBeInTheDocument(); + expect(screen.getAllByText("general.n_a").length).toBe(2); + }); + + it("shows $0.00 total for items with null applicable rate", () => { + render( + + ); + + // Row total and grand total should both be $0.00 when rate is null + expect(screen.getAllByText("$0.00").length).toBeGreaterThan(0); + }); + + it("renders item with N/A rates without crashing", () => { + expect(() => { + render( + + ); + }).not.toThrow(); + }); + }); + describe("ITEM Class Fields", () => { it("shows warning icon for items with ITEM class fields", () => { render( { const { container } = render( { const { container } = render( { render( { render( { render( { render( { render( { describe("Total Calculation", () => { it("calculates item total correctly based on quantity and rate", () => { + // qty item1 = 2*4 = 8, standard rate = 18800 → 8 * 18800 = 150400 → $1504.00 + // qty item2 = 1*2 = 2, standard rate = 18800 → 2 * 18800 = 37600 → $376.00 const initialValues = { "i-1-c-Form-f-1": 2, "i-1-c-Form-f-2": 4, @@ -616,7 +739,7 @@ describe("FormItemTable Component", () => { render( { }); it("calculates total amount for all items", () => { + // $1504.00 + $376.00 = $1880.00 const initialValues = { "i-1-c-Form-f-1": 2, "i-1-c-Form-f-2": 4, @@ -642,7 +766,7 @@ describe("FormItemTable Component", () => { render( { render( { }); describe("Rate Highlighting", () => { - it("highlights early_bird rate when current time is before early_bird_rate", () => { - jest - .spyOn(Date, "now") - .mockImplementation(() => MOCK_TIME_BEFORE_EARLY_BIRD); - + it("highlights early_bird rate when currentApplicableRate is early_bird", () => { render( { expect(screen.getAllByText("$150.00").length).toBeGreaterThan(0); }); - it("highlights standard rate when current time is between early_bird and standard", () => { - jest - .spyOn(Date, "now") - .mockImplementation( - () => - (MOCK_RATE_DATES.early_bird_rate + TIME_OFFSET) * - MILLISECONDS_MULTIPLIER - ); - + it("highlights standard rate when currentApplicableRate is standard", () => { render( { expect(screen.getAllByText("$188.00").length).toBeGreaterThan(0); }); - it("highlights onsite rate when current time is after standard_rate", () => { - jest - .spyOn(Date, "now") - .mockImplementation( - () => - (MOCK_RATE_DATES.standard_rate + TIME_OFFSET) * - MILLISECONDS_MULTIPLIER - ); - + it("highlights onsite rate when currentApplicableRate is onsite", () => { render( { const { container } = render( { const { container } = render( { const { container } = render( { +const GlobalQuantityField = ({ + row, + extraColumns, + value, + disabled = false +}) => { const name = `i-${row.form_item_id}-c-global-f-quantity`; // eslint-disable-next-line const [field, meta, helpers] = useField(name); @@ -35,6 +40,7 @@ const GlobalQuantityField = ({ row, extraColumns, value }) => { fullWidth size="small" type="number" + disabled={disabled} slotProps={{ htmlInput: { readOnly: isReadOnly, diff --git a/src/components/mui/FormItemTable/components/ItemTableField.js b/src/components/mui/FormItemTable/components/ItemTableField.js index 2c668044c..2f5cdea12 100644 --- a/src/components/mui/FormItemTable/components/ItemTableField.js +++ b/src/components/mui/FormItemTable/components/ItemTableField.js @@ -21,17 +21,23 @@ import MuiFormikTimepicker from "../../formik-inputs/mui-formik-timepicker"; import MuiFormikTextField from "../../formik-inputs/mui-formik-textfield"; import MuiFormikSelect from "../../formik-inputs/mui-formik-select"; -const ItemTableField = ({ rowId, field, timeZone, label = "" }) => { +const ItemTableField = ({ + rowId, + field, + timeZone, + label = "", + disabled = false +}) => { const name = `i-${rowId}-c-${field.class_field}-f-${field.type_id}`; + const commonProps = { name, label, disabled }; switch (field.type) { case "CheckBox": - return ; + return ; case "CheckBoxList": return ( ({ value: v.id, label: v.value }))} /> @@ -39,24 +45,20 @@ const ItemTableField = ({ rowId, field, timeZone, label = "" }) => { case "RadioButtonList": return ( ({ value: v.id, label: v.value }))} /> ); case "DateTime": - return ; + return ; case "Time": - return ( - - ); + return ; case "Quantity": return ( { ); case "ComboBox": return ( - + {field.values.map((v) => ( {v.value} @@ -80,14 +82,11 @@ const ItemTableField = ({ rowId, field, timeZone, label = "" }) => { ); case "Text": - return ( - - ); + return ; case "TextArea": return ( { + if (!showAdditionalItems) return null; + + return ( + + {" "} + {T.translate("edit_sponsor.cart_tab.edit_form.additional_info")} + + ); +}; + +export default UnderlyingAlertNote; diff --git a/src/components/mui/FormItemTable/helpers.js b/src/components/mui/FormItemTable/helpers.js new file mode 100644 index 000000000..1e3b83cc2 --- /dev/null +++ b/src/components/mui/FormItemTable/helpers.js @@ -0,0 +1,30 @@ +import { epochToMomentTimeZone } from "openstack-uicore-foundation/lib/utils/methods"; +import { MILLISECONDS_IN_SECOND } from "../../../utils/constants"; + +export const getCurrentApplicableRate = (timeZone, rateDates) => { + const now = epochToMomentTimeZone( + Math.floor(new Date() / MILLISECONDS_IN_SECOND), + timeZone + ); + + const earlyBirdEnd = epochToMomentTimeZone( + rateDates.early_bird_end_date, + timeZone + )?.endOf("day"); + const onsiteStart = epochToMomentTimeZone( + rateDates.onsite_price_start_date, + timeZone + )?.startOf("day"); + const onsiteEnd = epochToMomentTimeZone( + rateDates.onsite_price_end_date, + timeZone + )?.endOf("day"); + + if (earlyBirdEnd && now.isSameOrBefore(earlyBirdEnd)) return "early_bird"; + if (onsiteStart && now.isSameOrBefore(onsiteStart)) return "standard"; + if (!onsiteEnd || now.isSameOrBefore(onsiteEnd)) return "onsite"; + return "expired"; +}; + +export const isItemAvailable = (item, currentApplicableRate) => + item.rates?.[currentApplicableRate] != null; diff --git a/src/components/mui/FormItemTable/index.js b/src/components/mui/FormItemTable/index.js index db3ae210f..af126d93a 100644 --- a/src/components/mui/FormItemTable/index.js +++ b/src/components/mui/FormItemTable/index.js @@ -21,29 +21,23 @@ import { TableCell, TableContainer, TableHead, - TableRow, - Typography + TableRow } from "@mui/material"; import EditIcon from "@mui/icons-material/Edit"; import SettingsIcon from "@mui/icons-material/Settings"; -import ErrorIcon from "@mui/icons-material/Error"; import T from "i18n-react/dist/i18n-react"; import { currencyAmountFromCents } from "openstack-uicore-foundation/lib/utils/money"; -import { epochToMomentTimeZone } from "openstack-uicore-foundation/lib/utils/methods"; -import { - DISCOUNT_TYPES, - MILLISECONDS_IN_SECOND, - ONE_HUNDRED -} from "../../../utils/constants"; +import { DISCOUNT_TYPES, ONE_HUNDRED } from "../../../utils/constants"; import GlobalQuantityField from "./components/GlobalQuantityField"; import ItemTableField from "./components/ItemTableField"; import MuiFormikSelect from "../formik-inputs/mui-formik-select"; import MuiFormikPriceField from "../formik-inputs/mui-formik-pricefield"; import MuiFormikDiscountField from "../formik-inputs/mui-formik-discountfield"; +import UnderlyingAlertNote from "./components/UnderlyingAlertNote"; const FormItemTable = ({ data, - rateDates, + currentApplicableRate, timeZone, values, onNotesClick, @@ -55,33 +49,6 @@ const FormItemTable = ({ const fixedColumns = 10; const totalColumns = extraColumns.length + fixedColumns; - const currentApplicableRate = useMemo(() => { - const now = epochToMomentTimeZone( - Math.floor(new Date() / MILLISECONDS_IN_SECOND), - timeZone - ); - - const earlyBirdEndOfDay = epochToMomentTimeZone( - rateDates.early_bird_end_date, - timeZone - )?.endOf("day"); - const standardEndOfDay = epochToMomentTimeZone( - rateDates.standard_price_end_date, - timeZone - )?.endOf("day"); - const onsiteEndOfDay = epochToMomentTimeZone( - rateDates.onsite_price_end_date, - timeZone - )?.endOf("day"); - - if (earlyBirdEndOfDay && now.isSameOrBefore(earlyBirdEndOfDay)) - return "early_bird"; - if (standardEndOfDay && now.isSameOrBefore(standardEndOfDay)) - return "standard"; - if (!onsiteEndOfDay || now.isSameOrBefore(onsiteEndOfDay)) return "onsite"; - return "expired"; - }, [rateDates, timeZone]); - const calculateQuantity = useCallback( (row) => { const qtyEXC = extraColumns.filter((exc) => exc.type === "Quantity"); @@ -105,10 +72,14 @@ const FormItemTable = ({ const qty = values[`i-${row.form_item_id}-c-global-f-quantity`] || calculateQuantity(row); + if (currentApplicableRate === "expired") return 0; + const customRate = values[`i-${row.form_item_id}-c-global-f-custom_rate`]; const rate = customRate || row.rates[currentApplicableRate]; + if (rate == null || qty == null) return 0; + return qty * rate; }; @@ -127,6 +98,11 @@ const FormItemTable = ({ return requiredFields.length > 0 && hasMissingFields; }; + const formatRate = (rate) => { + if (rate == null) return T.translate("general.n_a"); + return currencyAmountFromCents(rate); + }; + const totalAmount = useMemo(() => { const subtotal = data.reduce((acc, row) => acc + calculateRowTotal(row), 0); const discount = @@ -135,7 +111,7 @@ const FormItemTable = ({ : subtotal * (values.discount_amount / ONE_HUNDRED / ONE_HUNDRED); // bps to fraction return subtotal - Math.round(discount); - }, [data, valuesStr]); + }, [data, valuesStr, currentApplicableRate]); const handleEdit = (row) => { onNotesClick(row); @@ -190,29 +166,11 @@ const FormItemTable = ({ {row.code}
{row.name}
- {hasItemFields(row) && itemFieldsIncomplete(row) && ( - - {" "} - {T.translate( - "edit_sponsor.cart_tab.edit_form.additional_info" - )} - - )} +
- {currencyAmountFromCents(row.rates.early_bird)} + {formatRate(row.rates.early_bird)} - {currencyAmountFromCents(row.rates.standard)} + {formatRate(row.rates.standard)} - {currencyAmountFromCents(row.rates.onsite)} + {formatRate(row.rates.onsite)} {extraColumns.map((exc) => ( diff --git a/src/components/mui/formik-inputs/mui-formik-datepicker.js b/src/components/mui/formik-inputs/mui-formik-datepicker.js index 591ba6b24..b096cdddb 100644 --- a/src/components/mui/formik-inputs/mui-formik-datepicker.js +++ b/src/components/mui/formik-inputs/mui-formik-datepicker.js @@ -5,7 +5,13 @@ import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; import { AdapterMoment } from "@mui/x-date-pickers/AdapterMoment"; import { useField } from "formik"; -const MuiFormikDatepicker = ({ name, label, required, ...props }) => { +const MuiFormikDatepicker = ({ + name, + label, + required, + disabled = false, + ...props +}) => { const [field, meta, helpers] = useField(name); const requiredLabel = `${label} *`; const handleBlur = () => { @@ -25,7 +31,8 @@ const MuiFormikDatepicker = ({ name, label, required, ...props }) => { onBlur: handleBlur, error: meta.touched && Boolean(meta.error), helperText: meta.touched && meta.error, - fullWidth: true + fullWidth: true, + disabled }, day: { sx: { @@ -52,7 +59,8 @@ const MuiFormikDatepicker = ({ name, label, required, ...props }) => { MuiFormikDatepicker.propTypes = { name: PropTypes.string.isRequired, label: PropTypes.string, - required: PropTypes.bool + required: PropTypes.bool, + disabled: PropTypes.bool }; export default MuiFormikDatepicker; diff --git a/src/components/mui/formik-inputs/mui-formik-discountfield.js b/src/components/mui/formik-inputs/mui-formik-discountfield.js index 4dc10e62e..81bde06c5 100644 --- a/src/components/mui/formik-inputs/mui-formik-discountfield.js +++ b/src/components/mui/formik-inputs/mui-formik-discountfield.js @@ -25,6 +25,7 @@ const MuiFormikDiscountField = ({ label, discountType, inCents = false, + disabled = false, ...props }) => { // eslint-disable-next-line no-unused-vars @@ -84,6 +85,7 @@ const MuiFormikDiscountField = ({ value={getDisplayValue()} onChange={handleChange} type="number" + disabled={disabled} slotProps={{ input: { ...adornment diff --git a/src/components/mui/formik-inputs/mui-formik-timepicker.js b/src/components/mui/formik-inputs/mui-formik-timepicker.js index bfc4af55e..5b98355b5 100644 --- a/src/components/mui/formik-inputs/mui-formik-timepicker.js +++ b/src/components/mui/formik-inputs/mui-formik-timepicker.js @@ -19,7 +19,13 @@ import { TimePicker } from "@mui/x-date-pickers/TimePicker"; import { useField } from "formik"; -const MuiFormikTimepicker = ({ name, minTime, maxTime, timeZone }) => { +const MuiFormikTimepicker = ({ + name, + minTime, + maxTime, + timeZone, + disabled = false +}) => { const [field, meta, helpers] = useField(name); return ( @@ -39,6 +45,7 @@ const MuiFormikTimepicker = ({ name, minTime, maxTime, timeZone }) => { helperText: meta.touched && meta.error, size: "small", fullWidth: true, + disabled, sx: { "& .MuiPickersSectionList-root": { width: "100%" diff --git a/src/i18n/en.json b/src/i18n/en.json index 0e8938082..ea516ecc1 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -80,6 +80,7 @@ "sort_by": "Sort By", "sort_asc_label": "A-Z", "sort_desc_label": "Z-A", + "n_a": "N/A", "placeholders": { "search_speakers": "Search Speakers by Name, Email, Speaker Id or Member Id", "select_acceptance_criteria": "Select acceptance criteria", diff --git a/src/pages/sponsors/sponsor-cart-tab/components/cart-view.js b/src/pages/sponsors/sponsor-cart-tab/components/cart-view.js index 4d6924b8d..baca1c195 100644 --- a/src/pages/sponsors/sponsor-cart-tab/components/cart-view.js +++ b/src/pages/sponsors/sponsor-cart-tab/components/cart-view.js @@ -205,6 +205,7 @@ const CartView = ({ variant="contained" color="primary" style={{ minWidth: 250 }} + disabled={cart?.net_amount === 0} onClick={handlePayCreditCard} > {T.translate("edit_sponsor.cart_tab.pay_cc")} diff --git a/src/pages/sponsors/sponsor-cart-tab/components/edit-form/index.js b/src/pages/sponsors/sponsor-cart-tab/components/edit-form/index.js index 13463473d..67331dfa6 100644 --- a/src/pages/sponsors/sponsor-cart-tab/components/edit-form/index.js +++ b/src/pages/sponsors/sponsor-cart-tab/components/edit-form/index.js @@ -21,12 +21,12 @@ import Box from "@mui/material/Box"; import moment from "moment-timezone"; import { epochToMomentTimeZone } from "openstack-uicore-foundation/lib/utils/methods"; import FormItemTable from "../../../../../components/mui/FormItemTable"; -import { - DISCOUNT_TYPES, - MILLISECONDS_IN_SECOND -} from "../../../../../utils/constants"; +import { DISCOUNT_TYPES } from "../../../../../utils/constants"; import NotesModal from "../../../../../components/mui/NotesModal"; import ItemSettingsModal from "../../../../../components/mui/ItemSettingsModal"; +import { + getCurrentApplicableRate +} from "../../../../../components/mui/FormItemTable/helpers"; const parseValue = (item, timeZone) => { switch (item.type) { @@ -204,18 +204,11 @@ const EditForm = ({ }) => { const [notesItem, setNotesItem] = useState(null); const [settingsItem, setSettingsItem] = useState(null); - const hasRateExpired = useMemo(() => { - const now = epochToMomentTimeZone( - Math.floor(new Date() / MILLISECONDS_IN_SECOND), - showTimeZone - ); - const onsiteEndOfDay = epochToMomentTimeZone( - showMetadata.onsite_price_end_date, - showTimeZone - )?.endOf("day"); - if (!onsiteEndOfDay || now.isSameOrBefore(onsiteEndOfDay)) return false; - return true; - }, [showMetadata, showTimeZone]); + + const currentApplicableRate = useMemo( + () => getCurrentApplicableRate(showTimeZone, showMetadata), + [showMetadata, showTimeZone] + ); const handleCancel = () => { onCancel(); @@ -295,7 +288,7 @@ const EditForm = ({ diff --git a/src/reducers/sponsors/sponsor-page-cart-list-reducer.js b/src/reducers/sponsors/sponsor-page-cart-list-reducer.js index a984a68ed..c6199b3f0 100644 --- a/src/reducers/sponsors/sponsor-page-cart-list-reducer.js +++ b/src/reducers/sponsors/sponsor-page-cart-list-reducer.js @@ -175,6 +175,7 @@ const sponsorPageCartListReducer = (state = DEFAULT_STATE, action) => { } case RECEIVE_CART_FORM: { const cartForm = payload.response; + return { ...state, cartForm: {