diff --git a/.changeset/tall-apricots-run.md b/.changeset/tall-apricots-run.md new file mode 100644 index 0000000000..d2589c78ef --- /dev/null +++ b/.changeset/tall-apricots-run.md @@ -0,0 +1,9 @@ +--- +"@medusajs/admin-ui": patch +"@medusajs/admin": patch +"@medusajs/medusa": patch +"@medusajs/medusa-js": patch +"medusa-react": patch +--- + +feat(admin-ui, medusa, medusa-react, medusa-js): Price List UI revamp diff --git a/packages/admin-ui/package.json b/packages/admin-ui/package.json index 2b59238bcd..b0a2b57313 100644 --- a/packages/admin-ui/package.json +++ b/packages/admin-ui/package.json @@ -35,8 +35,10 @@ "@babel/parser": "7.22.5", "@babel/traverse": "7.22.5", "@hookform/error-message": "^2.0.1", - "@medusajs/ui": "^1.0.0", - "@medusajs/ui-preset": "^1.0.0", + "@hookform/resolvers": "^3.3.1", + "@medusajs/icons": "1.1.0", + "@medusajs/ui": "^2.2.0", + "@medusajs/ui-preset": "1.0.2", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.10", "@radix-ui/react-accordion": "^1.0.1", "@radix-ui/react-avatar": "^1.0.1", @@ -46,7 +48,7 @@ "@radix-ui/react-popover": "^1.0.3", "@radix-ui/react-portal": "^1.0.2", "@radix-ui/react-radio-group": "^1.1.1", - "@radix-ui/react-select": "^1.2.0", + "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-switch": "^1.0.1", "@radix-ui/react-tooltip": "^1.0.3", "@segment/analytics-next": "^1.51.1", @@ -111,7 +113,8 @@ "type-fest": "^3.6.0", "webpack": "^5.84.1", "webpack-dev-server": "4.15.0", - "webpackbar": "^5.0.2" + "webpackbar": "^5.0.2", + "zod": "^3.22.2" }, "devDependencies": { "@babel/types": "7.22.5", diff --git a/packages/admin-ui/ui/public/locales/en/translation.json b/packages/admin-ui/ui/public/locales/en/translation.json index 39449177cf..d4a28e9e31 100644 --- a/packages/admin-ui/ui/public/locales/en/translation.json +++ b/packages/admin-ui/ui/public/locales/en/translation.json @@ -1,5 +1,13 @@ { "back-button-go-back": "Go back", + "filter-menu-trigger": "View", + "filter-menu-clear-button": "Clear", + "filter-menu-select-item-default-placeholder": "Select filter", + "filter-menu-select-item-clear-button": "Clear the selected options", + "filter-menu-select-item-selected": "Selected", + "filter-menu-date-item-before": "Before", + "filter-menu-date-item-after": "After", + "filter-menu-date-item-between": "Between", "sales-channels-display-available-count": "Available in <2>{{availableChannelsCount}} out of <6>{{totalChannelsCount}} Sales Channels", "activity-drawer-activity": "Activity", "activity-drawer-no-notifications-title": "It's quiet in here...", @@ -111,13 +119,13 @@ "login-card-password": "Password", "login-card-forgot-your-password": "Forgot your password?", "metadata-add-metadata": "Add Metadata", - "product-attributes-section-title": "Attributes", "product-attributes-section-edit-attributes": "Edit Attributes", "product-attributes-section-dimensions": "Dimensions", "product-attributes-section-configure-to-calculate-the-most-accurate-shipping-rates": "Configure to calculate the most accurate shipping rates", "product-attributes-section-customs": "Customs", "product-attributes-section-cancel": "Cancel", "product-attributes-section-save": "Save", + "product-attributes-section-title": "Attributes", "product-attributes-section-height": "Height", "product-attributes-section-width": "Width", "product-attributes-section-length": "Length", @@ -1380,89 +1388,161 @@ "form-use-new-order-form-must-be-used-within-new-order-form-provider": "useNewOrderForm must be used within NewOrderFormProvider", "new-order-created": "Order created", "new-create-draft-order": "Create Draft Order", - "batch-job-price-list-prices": "Price List prices", - "batch-job-upload-a-csv-file-with-variants": "Upload a CSV file with variants and prices to update your price list. Note that any existing prices will be deleted.", - "batch-job-unsure-about-how-to-arrange-your-list": "Unsure about how to arrange your list?", - "batch-job-download-the-template-file-below-and-update-your-prices": "Download the template file below and update your prices", - "details-back-to-pricing": "Back to Pricing", - "details-raw-price-list": "Raw price list", - "sections-customer-groups": "Customer groups", - "sections-last-edited": "Last edited", - "sections-price-overrides": "Price overrides", - "sections-more": "more", - "sections-delete-price-list-heading": "Delete Price list", - "sections-are-you-sure-you-want-to-delete-this-price-list": "Are you sure you want to delete this price list?", - "sections-success": "Success", - "sections-price-list-deleted-successfully": "Price list deleted successfully", - "sections-edit-price-list-details": "Edit price list details", - "sections-delete-price-list": "Delete price list", - "edit-prices-overrides-edit-price-overrides": "Edit price overrides", - "edit-prices-overrides-success": "Success", - "edit-prices-overrides-price-overrides-updated": "Price overrides updated", - "edit-prices-overrides-cancel": "Cancel", - "edit-prices-overrides-save": "Save", - "edit-prices-overrides-count_one": "{{count}}", - "edit-prices-overrides-count_other": "{{count}}", - "edit-prices-overrides-add-prices": "Add prices", - "prices-details-edit-prices": "Edit prices", - "prices-details-prices": "Prices", - "prices-details-you-will-be-able-to-override-the-prices-for-the-products-you-add-here": "You will be able to override the prices for the products you add here", - "prices-details-remove-from-list": "Remove from list", - "prices-details-edit-manually": "Edit manually", - "prices-details-import-price-list": "Import price list", - "prices-table-search-by-name-or-sku": "Search by name or SKU...", - "prices-table-edit-prices": "Edit prices", - "prices-table-remove-product": "Remove product", - "prices-table-success": "Success", - "prices-table-deleted-prices-of-product": "Deleted prices of product: {{title}}", - "prices-table-error": "Error", - "prices-table-name": "Name", - "prices-table-collection": "Collection", - "prices-table-no-collection": "No collection", - "prices-table-variants": "Variants", - "pricing-add-price-list": "Add price list", - "pricing-price-lists": "Price lists", - "form-header-error": "Error", - "form-header-success": "Success", - "form-header-successfully-updated-price-list": "Successfully updated price list", - "form-header-publish-price-list": "Publish price list", - "form-header-save-as-draft": "Save as draft", - "form-header-save-changes": "Save changes", - "form-header-cancel": "Cancel", - "pricing-form-create-new-price-list": "Create new price list", - "pricing-form-edit-price-list": "Edit price list", - "sections-configuration": "Configuration", - "sections-optional-configuration-for-the-price-list": "Optional configuration for the price list", - "sections-price-overrides-time-application": "The price overrides apply from the time you hit the publish button and forever if left untouched.", - "sections-price-overrides-has-a-start-date": "Price overrides has a start date?", - "sections-schedule-the-price-overrides-to-activate-in-the-future": "Schedule the price overrides to activate in the future.", - "sections-price-overrides-has-an-expiry-date": "Price overrides has an expiry date?", - "sections-schedule-the-price-overrides-to-deactivate-in-the-future": "Schedule the price overrides to deactivate in the future.", - "sections-end-date": "End date", - "sections-customer-availabilty": "Customer availabilty", - "sections-specifiy-which-customer-groups-the-price-overrides-should-apply-for": "Specifiy which customer groups the price overrides should apply for.", - "sections-customer-groups-label": "Customer Groups", - "sections-general": "General", - "sections-general-information-for-the-price-list": "General information for the price list.", - "sections-name": "Name", - "sections-b-2-b-black-friday": "B2B, Black Friday...", - "sections-for-our-business-partners": "For our business partners...", - "sections-tax-inclusive-prices": "Tax inclusive prices", - "sections-choose-to-make-all-prices-in-this-list-inclusive-of-tax": "Choose to make all prices in this list inclusive of tax.", - "sections-prices": "Prices", - "sections-you-will-be-able-to-override-the-prices-for-the-products-you-add-here": "You will be able to override the prices for the products you add here", - "sections-define-the-price-overrides-for-the-price-list": "Define the price overrides for the price list", - "sections-edit-prices-label": "Edit prices", - "sections-remove-from-list": "Remove from list", - "sections-search-by-name-or-sku": "Search by name or SKU...", - "sections-edit-prices": "Edit Prices", - "sections-price-list-type": "Price list type", - "sections-select-the-type-of-the-price-list": "Select the type of the price list", - "sections-sale-prices-compare-to-price-override": "Unlike with sale prices a price override will not communicate to the customer that the price is part of a sale.", - "sections-sale": "Sale", - "sections-use-this-if-you-are-creating-prices-for-a-sale": "Use this if you are creating prices for a sale.", - "sections-override": "Override", - "sections-use-this-to-override-prices": "Use this to override prices.", + "price-list-product-filter-created-at": "Created at", + "price-list-product-filter-updated-at": "Updated at", + "price-list-details-drawer-prompt-title": "Are you sure?", + "price-list-details-drawer-prompt-description": "You have unsaved changes, are you sure you want to exit?", + "price-list-details-notification-succes-title": "Price list updated", + "price-list-details-drawer-notification-success-message": "Successfully updated price list", + "price-list-details-drawer-notification-error-title": "An error occurred", + "price-list-details-drawer-title": "Edit Price List Details", + "price-list-details-drawer-cancel-button": "Cancel", + "price-list-details-drawer-save-button": "Save", + "price-list-details-section-prompt-confirm-text": "Delete", + "price-list-details-section-prompt-cancel-text": "Cancel", + "price-list-details-section-prompt-title": "Delete price list", + "price-list-details-section-prompt-description": "Are you sure you want to delete the price list \"{{name}}\"?", + "price-list-details-section-delete-notification-success-title": "Successfully deleted price list", + "price-list-details-section-delete-notification-success-message": "The price list \"{{name}}\" was successfully deleted", + "price-list-details-section-delete-notification-error-title": "Failed to delete price list", + "price-list-details-section-customer-groups": "Customer Groups", + "price-list-details-section-last-edited": "Last edited", + "price-list-details-section-number-of-prices": "Prices", + "price-list-details-section-status-menu-expired": "Expired", + "price-list-details-section-status-menu-draft": "Draft", + "price-list-details-section-status-menu-scheduled": "Scheduled", + "price-list-details-section-status-active": "Active", + "price-list-details-section-status-menu-notification-success-title": "Successfully updated price list status", + "price-list-details-section-status-menu-notification-success-message": "The price list status was successfully updated to {{status}}", + "price-list-details-section-status-menu-notification-error-title": "Failed to update price list status", + "price-list-details-section-status-menu-item-draft": "Draft", + "price-list-details-section-status-menu-item-activate": "Activate", + "price-list-details-menu-item-edit": "Edit details", + "price-list-details-menu-item-delete": "Delete", + "price-list-edit-error": "An error occurred while loading price list. Reload the page and try again. If the issue persists, try again later.", + "price-list-new-form-prompt-title": "Are you sure?", + "price-list-new-form-prompt-exit-description": "You have unsaved changes, are you sure you want to exit?", + "price-list-new-form-prompt-back-description": "You have unsaved changes, are you sure you want to go back?", + "price-list-add-products-modal-success-title": "New prices added", + "price-list-add-products-modal-success-message": "The new prices have been added to the price list.", + "price-list-add-products-modal-error-title": "An error occurred", + "price-list-add-products-modal-back-button-cancel": "Cancel", + "price-list-add-products-modal-back-button": "Back", + "price-list-add-products-modal-next-button-continue": "Continue", + "price-list-add-products-modal-next-button-submit-and-close": "Submit and Close", + "price-list-add-products-modal-next-button-continue-save-prices": "Save Prices", + "price-list-add-products-modal-products-tab": "Choose Products", + "price-list-add-products-modal-prices-tab": "Edit Prices", + "price-list-add-products-modal-error": "An error occurred while preparing the form. Reload the page and try again. If the issue persists, try again later.", + "price-list-edit-prices-modal-prompt-title": "Unsaved changes", + "price-list-edit-prices-modal-prompt-exit-description": "You have unsaved changes, are you sure you want to exit?", + "price-list-edit-prices-modal-prompt-back-description": "You have unsaved changes, are you sure you want to go back?", + "price-list-edit-prices-modal-notification-update-error": "An error occurred", + "price-list-edit-prices-modal-notification-remove-error-title": "An error occurred", + "price-list-edit-prices-modal-notification-remove-error-description": "Some prices were not updated correctly. Try again.", + "price-list-edit-prices-modal-notification-update-success-title": "Prices updated", + "price-list-edit-prices-modal-notification-update-success-description": "Successfully updated prices", + "price-list-edit-prices-modal-next-button-save-and-close": "Save and Close", + "price-list-edit-prices-modal-next-button-save": "Save Prices", + "price-list-edit-prices-modal-back-button-cancel": "Cancel", + "price-list-edit-prices-modal-back-button-back": "Back", + "price-list-edit-prices-modal-overview-tab": "Edit Prices", + "price-list-edit-prices-modal-error-loading": "An error occurred while preparing the form. Reload the page and try again. If the issue persists, try again later.", + "price-list-prices-section-prompt-title": "Are you sure?", + "price-list-prices-section-prompt-description": "This will permanently delete the product prices from the list", + "price-list-prices-secton-delete-success-title": "Prices deleted", + "price-list-prices-section-delete-success-description_one": "Successfully deleted prices for {{count}} products", + "price-list-prices-section-delete-success-description_other": "Successfully deleted prices for {{count}} products", + "price-list-prices-section-delete-error-title": "An error occurred", + "price-list-prices-section-heading": "Prices", + "price-list-prices-section-search-placeholder": "Search products", + "price-list-prices-section-prices-menu-edit": "Edit prices", + "price-list-prices-section-prices-menu-add": "Add products", + "price-list-prices-section-table-load-error": "An error occured while fetching the products. Try to reload the page, or if the issue persists, try again later.", + "price-list-prices-section-bar-count_one": "{{count}} selected", + "price-list-prices-section-bar-count_other": "{{count}} selected", + "price-list-prices-section-edit-command": "Edit", + "price-list-prices-section-delete-command": "Delete", + "price-list-prices-section-select-all-checkbox-label": "Select all products on the current page", + "price-list-prices-section-select-checkbox-label": "Select row", + "price-list-prices-section-table-product": "Product", + "price-list-prices-section-table-thumbnail-alt": "{{title}} thumbnail", + "price-list-prices-section-table-collection": "Collection", + "price-list-prices-section-table-variants": "Variants", + "price-list-details-form-type-heading": "Type", + "price-list-details-form-type-description": "Choose the type of price list you want to create.", + "price-list-details-form-type-label-sale": "Sale", + "price-list-details-form-type-hint-sale": "Use this if you are creating a sale.", + "price-list-details-form-type-label-override": "Override", + "price-list-details-form-type-hint-override": "Use this if you are overriding prices.", + "price-list-details-form-general-heading": "General", + "price-list-details-form-general-description": "Choose a title and description for the price list.", + "price-list-details-form-general-name-label": "Name", + "price-list-details-form-general-name-placeholder": "Black Friday Sale", + "price-list-details-form-general-description-label": "Description", + "price-list-details-form-general-description-placeholder": "Prices for the Black Friday sale...", + "price-list-details-form-tax-inclusive-label": "Tax inclusive prices", + "price-list-details-form-tax-inclusive-hint": "Choose to make all prices in this list inclusive of tax.", + "price-list-details-form-dates-starts-at-heading": "Price list has a start date?", + "price-list-details-form-dates-starts-at-description": "Schedule the price overrides to activate in the future.", + "price-list-details-form-dates-starts-at-label": "Start date", + "price-list-details-form-ends-at-heading": "Price list has an expiry date?", + "price-list-details-form-ends-at-description": "Schedule the price overrides to deactivate in the future.", + "price-list-details-form-ends-at-label": "End date", + "price-list-details-form-customer-groups-name": "Name", + "price-list-details-form-customer-groups-members": "Members", + "price-list-details-form-customer-groups-error": "An error occurred while loading customer groups. Reload the page and try again. If the issue persists, try again later.", + "price-list-details-form-customer-groups-no-groups": "No customer groups found.", + "price-list-details-form-customer-groups-heading": "Customer availability", + "price-list-details-form-customer-groups-description": "Specify which customer groups the price overrides should apply for.", + "price-list-details-form-customer-groups-content-heading": "Customer Groups", + "price-list-details-form-customer-groups-search-placeholder": "Search", + "price-list-prices-form-products-error": "An error occurred while preparing the form. Reload the page and try again. If the issue persists, try again later.", + "price-list-prices-form-heading": "Edit prices", + "price-list-prices-form-variant": "Variant", + "price-list-prices-form-sku": "SKU", + "price-list-prices-form-prices": "Prices", + "price-list-prices-form-prices-variant-count_one": "{{count}} variants", + "price-list-prices-form-prices-variant-count_other": "{{count}} variants", + "price-list-prices-form-add-prices-button": "Add prices", + "price-list-prices-form-prices-count_one": "{{count}} prices", + "price-list-prices-form-prices-count_other": "{{count}} prices", + "price-list-product-prices-form-invalid-data-title": "Invalid data", + "price-list-product-prices-form-invalid-data-body": "The data you pasted contains values that are not numbers.", + "price-list-product-prices-form-column-visibility-button": "View", + "price-list-product-prices-form-column-visibility-currencies-label": "Currencies", + "price-list-product-prices-form-column-visibility-regions-label": "Regions", + "price-list-product-prices-form-column-product-label": "Product", + "price-list-product-prices-form-column-currencies-price-label": "Price {{code}}", + "price-list-product-prices-form-column-regions-price-label": "Price {{code}}", + "price-list-products-form-select-all": "Select all products on the current page", + "price-list-products-form-select-row": "Select row", + "price-list-products-form-product-label": "Product", + "price-list-products-form-product-thumbnail": "{{title}} thumbnail", + "price-list-products-form-collection-label": "Collection", + "price-list-products-form-sales-channels-label": "Availability", + "price-list-products-form-sales-channels-value": "{{first}} + {{remaining}} more", + "price-list-products-form-status-label": "Status", + "price-list-products-form-inventory-label": "Inventory", + "price-list-products-form-inventory-value": "{{totalStock}} in stock across {{variants}} variants", + "price-list-products-form-loading": "Loading products", + "price-list-products-form-error": "An error occurred while loading products. Reload the page and try again. If the issue persists, try again later.", + "price-list-products-form-no-products": "No products found.", + "price-list-products-form-heading": "Choose products", + "price-list-products-form-search-placeholder": "Search", + "price-list-new-form-notification-success-title": "Price list created", + "price-list-new-form-notification-success-message": "Successfully created price list", + "price-list-new-form-notification-error-title": "An error occurred", + "price-list-new-form-next-button-save-and-publish": "Save and Publish", + "price-list-new-form-next-button-save": "Save Prices", + "price-list-new-form-next-button-continue": "Continue", + "price-list-new-form-back-button-cancel": "Cancel", + "price-list-new-form-back-button-back": "Back", + "price-list-new-form-details-tab": "Create Price List", + "price-list-new-form-products-tab": "Choose Products", + "price-list-new-form-prices-tab": "Edit Prices", + "price-list-new-form-save-as-draft": "Save as Draft", + "price-list-new-form-error-loading-products": "An error occurred while preparing the form. Reload the page and try again. If the issue persists, try again later.", "components-success": "Success", "components-successfully-updated-category-tree": "Successfully updated category tree", "components-error": "Error", @@ -1509,6 +1589,7 @@ "batch-job-failed-to-delete-the-csv-file": "Failed to delete the CSV file", "batch-job-failed-to-cancel-the-batch-job": "Failed to cancel the batch job", "batch-job-products-list": "products list", + "batch-job-unsure-about-how-to-arrange-your-list": "Unsure about how to arrange your list?", "batch-job-download-template": "Download the template below to ensure you are following the correct format.", "batch-job-imports-description": "Through imports you can add or update products. To update existing products/variants you must set an existing id in the Product/Variant id columns. If the value is unset a new record will be created. You will be asked for confirmation before we import products.", "products-filters": "Filters", diff --git a/packages/admin-ui/ui/src/components/helpers/form/form.tsx b/packages/admin-ui/ui/src/components/helpers/form/form.tsx new file mode 100644 index 0000000000..b4d47de3c1 --- /dev/null +++ b/packages/admin-ui/ui/src/components/helpers/form/form.tsx @@ -0,0 +1,187 @@ +import { + Hint as HintComponent, + Label as LabelComponent, + clx, +} from "@medusajs/ui" +import * as LabelPrimitives from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import * as React from "react" +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, + useFormState, +} from "react-hook-form" + +const Provider = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const Field = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState } = useFormContext() + + const formState = useFormState({ name: fieldContext.name }) + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within a FormField") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formErrorMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +const Item = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + +
+ + ) +}) +Item.displayName = "Form.Item" + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { formItemId } = useFormField() + + return ( + + ) +}) +Label.displayName = "Form.Label" + +const Control = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ ...props }, ref) => { + const { error, formItemId, formDescriptionId, formErrorMessageId } = + useFormField() + + return ( + + ) +}) +Control.displayName = "Form.Control" + +const Hint = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const { formDescriptionId } = useFormField() + + return ( + + ) +}) +Hint.displayName = "Form.Hint" + +const ErrorMessage = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, children, ...props }, ref) => { + const { error, formErrorMessageId } = useFormField() + const msg = error ? String(error?.message) : children + + if (!msg) { + return null + } + + return ( + + {msg} + + ) +}) +ErrorMessage.displayName = "Form.ErrorMessage" + +const Form = Object.assign(Provider, { + Item, + Label, + Control, + Hint, + ErrorMessage, + Field, +}) + +export { Form } diff --git a/packages/admin-ui/ui/src/components/helpers/form/index.ts b/packages/admin-ui/ui/src/components/helpers/form/index.ts new file mode 100644 index 0000000000..4b457f4748 --- /dev/null +++ b/packages/admin-ui/ui/src/components/helpers/form/index.ts @@ -0,0 +1 @@ +export * from "./form" diff --git a/packages/admin-ui/ui/src/components/molecules/filter-dropdown/container.tsx b/packages/admin-ui/ui/src/components/molecules/filter-dropdown/container.tsx index 7efd562fd5..978c6e75a3 100644 --- a/packages/admin-ui/ui/src/components/molecules/filter-dropdown/container.tsx +++ b/packages/admin-ui/ui/src/components/molecules/filter-dropdown/container.tsx @@ -8,8 +8,8 @@ import React, { useState, } from "react" -import Button from "../../fundamentals/button" import { useWindowDimensions } from "../../../hooks/use-window-dimensions" +import Button from "../../fundamentals/button" type FilterDropdownContainerProps = { submitFilters: () => void @@ -17,6 +17,9 @@ type FilterDropdownContainerProps = { triggerElement: ReactNode } +/** + * @deprecated Use `FilterMenu` instead + */ const FilterDropdownContainer = ({ submitFilters, clearFilters, diff --git a/packages/admin-ui/ui/src/components/molecules/filter-dropdown/item.tsx b/packages/admin-ui/ui/src/components/molecules/filter-dropdown/item.tsx index 43e3fbec4d..a3d376269c 100644 --- a/packages/admin-ui/ui/src/components/molecules/filter-dropdown/item.tsx +++ b/packages/admin-ui/ui/src/components/molecules/filter-dropdown/item.tsx @@ -1,21 +1,24 @@ import * as RadixCollapsible from "@radix-ui/react-collapsible" import * as RadixPopover from "@radix-ui/react-popover" -import { addHours, atMidnight, dateToUnixTimestamp } from "../../../utils/time" import { useEffect, useMemo, useState } from "react" +import { addHours, atMidnight, dateToUnixTimestamp } from "../../../utils/time" -import ArrowRightIcon from "../../fundamentals/icons/arrow-right-icon" -import { CalendarComponent } from "../../atoms/date-picker/date-picker" -import CheckIcon from "../../fundamentals/icons/check-icon" -import ChevronUpIcon from "../../fundamentals/icons/chevron-up" -import { DateFilters } from "../../../utils/filters" -import InputField from "../input" -import Spinner from "../../atoms/spinner" import clsx from "clsx" import moment from "moment" +import { DateFilters } from "../../../utils/filters" +import { CalendarComponent } from "../../atoms/date-picker/date-picker" +import Spinner from "../../atoms/spinner" +import ArrowRightIcon from "../../fundamentals/icons/arrow-right-icon" +import CheckIcon from "../../fundamentals/icons/check-icon" +import ChevronUpIcon from "../../fundamentals/icons/chevron-up" +import InputField from "../input" const DAY_IN_SECONDS = 86400 +/** + * @deprecated Use `FilterMenu` instead + */ const FilterDropdownItem = ({ filterTitle, options, diff --git a/packages/admin-ui/ui/src/components/molecules/filter-dropdown/save-field.tsx b/packages/admin-ui/ui/src/components/molecules/filter-dropdown/save-field.tsx index f141b2bd76..9cf46bc123 100644 --- a/packages/admin-ui/ui/src/components/molecules/filter-dropdown/save-field.tsx +++ b/packages/admin-ui/ui/src/components/molecules/filter-dropdown/save-field.tsx @@ -1,7 +1,7 @@ +import { trim } from "lodash" import React from "react" import Button from "../../fundamentals/button" import InputField from "../input" -import { trim } from "lodash" type SaveFilterItemProps = { saveFilter: () => void @@ -9,6 +9,9 @@ type SaveFilterItemProps = { setName: (name: string) => void } +/** + * @deprecated Use `FilterMenu` instead + */ const SaveFilterItem: React.FC = ({ saveFilter, setName, diff --git a/packages/admin-ui/ui/src/components/molecules/filter-menu/filter-menu.tsx b/packages/admin-ui/ui/src/components/molecules/filter-menu/filter-menu.tsx new file mode 100644 index 0000000000..695f9806e4 --- /dev/null +++ b/packages/admin-ui/ui/src/components/molecules/filter-menu/filter-menu.tsx @@ -0,0 +1,496 @@ +import { + Adjustments, + CheckMini, + ChevronUpDown, + XMarkMini, +} from "@medusajs/icons" +import { DateComparisonOperator } from "@medusajs/medusa" +import { + Popover, + Badge, + Button, + DatePicker, + DateRange, + Select, + Switch, + clx, +} from "@medusajs/ui" +import * as Collapsible from "@radix-ui/react-collapsible" +import * as React from "react" +import { useTranslation } from "react-i18next" + +interface FilterMenuProps + extends React.ComponentPropsWithoutRef { + onClearFilters: () => void +} + +interface FilterMenuContextValue { + onClearFilters: () => void +} + +const FilterMenuContext = React.createContext( + null +) + +const useFilterMenuContext = () => { + const context = React.useContext(FilterMenuContext) + + if (!context) { + throw new Error( + "FilterMenu compound components cannot be rendered outside the FilterMenu component" + ) + } + + return context +} + +const Root = ({ children, onClearFilters, ...props }: FilterMenuProps) => { + const { t } = useTranslation() + + return ( + + + + + ({ + onClearFilters, + }), + [onClearFilters] + )} + > + {children} + + + ) +} +Root.displayName = "FilterMenu" + +const Content = ({ + children, + sideOffset = 8, + side = "bottom", + alignOffset = -32, + align = "start", + className, + ...props +}: React.ComponentPropsWithoutRef) => { + const { onClearFilters } = useFilterMenuContext() + + const { t } = useTranslation() + + return ( + + {children} + +
+ +
+
+ ) +} +Content.displayName = "FilterMenu.Content" + +type SelectItemProps = { + name: string + placeholder?: string + options: { + value: string + label: string + }[] + value?: string[] + onChange: (value: string[]) => void +} + +type SelectCheckItemProps = { + checked?: boolean + onCheckedChange: (checked: boolean) => void + label: string +} + +const SelectCheckItem = ({ + checked, + onCheckedChange, + label, +}: SelectCheckItemProps) => { + const onClick = React.useCallback(() => { + onCheckedChange(!checked) + }, [checked, onCheckedChange]) + + return ( + + ) +} + +const SelectItem = ({ + name, + placeholder = "Select filter", + options, + value = [], + onChange, +}: SelectItemProps) => { + const menuRef = React.useRef(null) + const triggerRef = React.useRef(null) + + const [open, setOpen] = React.useState(false) + + const { t } = useTranslation() + + React.useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setOpen(false) + } + } + + document.addEventListener("mousedown", handleClickOutside) + + return () => { + document.removeEventListener("mousedown", handleClickOutside) + } + }, []) + + React.useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (open && e.key === "ArrowDown") { + e.preventDefault() + const next = e.target + ? (e.target as HTMLElement).nextElementSibling + : null + if (next) { + ;(next as HTMLElement).focus() + } else { + const first = menuRef.current?.firstElementChild + if (first) { + ;(first as HTMLElement).focus() + } + } + } + + if (open && e.key === "ArrowUp") { + e.preventDefault() + const prev = e.target + ? (e.target as HTMLElement).previousElementSibling + : null + if (prev) { + ;(prev as HTMLElement).focus() + } else { + const last = menuRef.current?.lastElementChild + if (last) { + ;(last as HTMLElement).focus() + } + } + } + + if (open && e.key === "Escape") { + e.preventDefault() + setOpen(false) + + /** + * Attempt to restore focus to the trigger element + */ + triggerRef.current?.focus() + } + } + + document.addEventListener("keydown", handleKeyDown) + + return () => { + document.removeEventListener("keydown", handleKeyDown) + } + }, [open]) + + const placeholderValue = placeholder + ? placeholder + : t("filter-menu-select-item-default-placeholder", "Select filter") + + return ( + +
+ {name} + 0, + } + )} + onClick={() => { + setOpen(!open) + }} + asChild + > + + +
+ + + {options.map((opt, index) => { + return ( + { + if (value.includes(opt.value)) { + const newValue = value.filter((v) => v !== opt.value) + onChange(newValue) + return + } + + const newValue = [...value, opt.value] + onChange(newValue) + }} + key={index} + label={opt.label} + /> + ) + })} + +
+ ) +} +SelectItem.displayName = "FilterMenu.SelectItem" + +type DateItemProps = { + name: string + value?: DateComparisonOperator + onChange: (value?: DateComparisonOperator) => void +} + +const DateItem = ({ name, value, onChange }: DateItemProps) => { + const [operator, setOperator] = React.useState("lt") + const [expanded, setExpanded] = React.useState(!!value) + + const { t } = useTranslation() + + const handleExpandChange = (expanded: boolean) => { + setExpanded(expanded) + + if (!expanded) { + onChange(undefined) + } + } + + const handleSingleDateChange = React.useCallback( + (date?: Date) => { + if (!date) { + onChange(undefined) + return + } + + const payload: DateComparisonOperator = { + lt: undefined, + gt: undefined, + gte: undefined, + lte: undefined, + } + + switch (operator) { + case "lt": + payload.lt = date + break + case "gt": + payload.gt = date + break + default: + break + } + + onChange(payload) + }, + [operator, onChange] + ) + + const handleOperatorChange = React.useCallback( + (value: string) => { + setOperator(value) + onChange(undefined) + }, + [onChange] + ) + + const handleRangeDateChange = React.useCallback( + (range?: DateRange) => { + if (!range) { + onChange(undefined) + return + } + + const payload: DateComparisonOperator = { + gte: range.from, + lte: range.to, + } + + payload.gte = range.from + payload.lte = range.to + + onChange(payload) + }, + [onChange] + ) + + return ( +
+
+ {name} + +
+ + +
+ + {["lt", "gt"].includes(operator) ? ( + + ) : ( + + )} +
+
+
+
+ ) +} +DateItem.displayName = "FilterMenu.DateItem" + +const Seperator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ) +}) +Seperator.displayName = "FilterMenu.Seperator" + +const FilterMenu = Object.assign(Root, { + Content, + SelectItem, + DateItem, + Seperator, +}) + +export { FilterMenu } diff --git a/packages/admin-ui/ui/src/components/molecules/filter-menu/index.ts b/packages/admin-ui/ui/src/components/molecules/filter-menu/index.ts new file mode 100644 index 0000000000..38cd40e2d2 --- /dev/null +++ b/packages/admin-ui/ui/src/components/molecules/filter-menu/index.ts @@ -0,0 +1 @@ +export * from "./filter-menu" diff --git a/packages/admin-ui/ui/src/components/organisms/product-attributes-section/index.tsx b/packages/admin-ui/ui/src/components/organisms/product-attributes-section/index.tsx index bece748ced..3ce0cd78c6 100644 --- a/packages/admin-ui/ui/src/components/organisms/product-attributes-section/index.tsx +++ b/packages/admin-ui/ui/src/components/organisms/product-attributes-section/index.tsx @@ -43,7 +43,7 @@ const ProductAttributesSection = ({ product }: Props) => { value={product.width} /> { const { @@ -29,7 +28,7 @@ const Topbar: React.FC = () => { ) return ( -
+
- } - title={priceList.name} - subtitle={priceList.description} - {...props} - /> - ) -} - -export default Header diff --git a/packages/admin-ui/ui/src/domain/pricing/details/sections/prices-details/edit-prices-overrides/index.tsx b/packages/admin-ui/ui/src/domain/pricing/details/sections/prices-details/edit-prices-overrides/index.tsx deleted file mode 100644 index b40aebc078..0000000000 --- a/packages/admin-ui/ui/src/domain/pricing/details/sections/prices-details/edit-prices-overrides/index.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { MoneyAmount, Product } from "@medusajs/medusa" -import { useAdminStore, useAdminUpdatePriceList } from "medusa-react" -import { useParams } from "react-router-dom" -import { useTranslation } from "react-i18next" -import Button from "../../../../../../components/fundamentals/button" -import { CollapsibleTree } from "../../../../../../components/molecules/collapsible-tree" -import Modal from "../../../../../../components/molecules/modal" -import LayeredModal, { - useLayeredModal, -} from "../../../../../../components/molecules/modal/layered-modal" -import PriceOverrides from "../../../../../../components/templates/price-overrides" -import useNotification from "../../../../../../hooks/use-notification" -import { mergeExistingWithDefault } from "../../../utils" -import { mapToPriceList } from "./mappers" -import ProductVariantLeaf from "./product-variant-leaf" - -type EditPricesOverridesModalProps = { - product: Product - close: () => void -} - -const EditPricesOverridesModal = ({ - close, - product, -}: EditPricesOverridesModalProps) => { - const { t } = useTranslation() - const context = useLayeredModal() - const { id: priceListId } = useParams() - const updatePriceList = useAdminUpdatePriceList(priceListId || "") - const { store } = useAdminStore() - - const defaultPrices = store?.currencies.map((curr) => ({ - currency_code: curr.code, - amount: 0, - })) as MoneyAmount[] - - const notification = useNotification() - - const getOnClick = (variant) => () => - context.push({ - title: t( - "edit-prices-overrides-edit-price-overrides", - "Edit price overrides" - ), - onBack: () => context.pop(), - view: ( - pr.price_list_id), - defaultPrices - )} - isEdit - defaultVariant={variant} - variants={product.variants} - onClose={close} - onSubmit={(values) => { - const updatedPrices = mapToPriceList(values, variant.id) - - updatePriceList.mutate( - { - prices: updatedPrices, - }, - { - onSuccess: () => { - context.pop() - close() - notification( - t("edit-prices-overrides-success", "Success"), - t( - "edit-prices-overrides-price-overrides-updated", - "Price overrides updated" - ), - "success" - ) - }, - } - ) - }} - /> - ), - }) - - return ( - - - -

- Price overrides{" "} - - ({product.title}) - -

-
- - - - -
- -
- {product.title} -
- - {product.variants.map((variant) => ( - - pr.price_list_id)} - /> - - ))} - -
-
- - -
- - -
-
-
-
- ) -} - -export default EditPricesOverridesModal diff --git a/packages/admin-ui/ui/src/domain/pricing/details/sections/prices-details/edit-prices-overrides/mappers.tsx b/packages/admin-ui/ui/src/domain/pricing/details/sections/prices-details/edit-prices-overrides/mappers.tsx deleted file mode 100644 index d34a7b5c87..0000000000 --- a/packages/admin-ui/ui/src/domain/pricing/details/sections/prices-details/edit-prices-overrides/mappers.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { AdminPostPriceListsPriceListPriceListReq } from "@medusajs/medusa" -import { PriceOverridesFormValues } from "../../../../../../components/templates/price-overrides" -import xorObjFields from "../../../../../../utils/xorObjFields" - -export const mapToPriceList = ( - values: PriceOverridesFormValues, - variantId: string -) => { - return values.prices - .map((price) => ({ - id: price.id, - ...xorObjFields(price, "currency_code", "region_id"), - amount: price.amount, - min_quantity: price.min_quantity, - max_quantity: price.max_quantity, - variant_id: variantId, - })) - .filter( - (pr) => pr.amount > 0 - ) as AdminPostPriceListsPriceListPriceListReq["prices"] -} diff --git a/packages/admin-ui/ui/src/domain/pricing/details/sections/prices-details/edit-prices-overrides/product-variant-leaf.tsx b/packages/admin-ui/ui/src/domain/pricing/details/sections/prices-details/edit-prices-overrides/product-variant-leaf.tsx deleted file mode 100644 index 84dbfe19ed..0000000000 --- a/packages/admin-ui/ui/src/domain/pricing/details/sections/prices-details/edit-prices-overrides/product-variant-leaf.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { MoneyAmount, ProductVariant } from "@medusajs/medusa" -import * as React from "react" -import { useTranslation } from "react-i18next" -import Button from "../../../../../../components/fundamentals/button" -import ChevronRightIcon from "../../../../../../components/fundamentals/icons/chevron-right-icon" - -type ProductVariantLeafProps = { - onClick: React.ButtonHTMLAttributes["onClick"] - variant: ProductVariant - prices: MoneyAmount[] -} - -const ProductVariantLeaf = ({ - variant, - prices, - onClick, -}: ProductVariantLeafProps) => { - const { t } = useTranslation() - const { title, sku } = variant - const hasPrices = prices.length > 0 - return ( -
-
- {title} - {sku && (SKU: {sku})} -
-
-
- {hasPrices ? ( - - {t("edit-prices-overrides-count", "{{count}}", { - count: prices.length, - })} - - ) : ( - - {t("edit-prices-overrides-add-prices", "Add prices")} - - )} -
- -
-
- ) -} - -export default ProductVariantLeaf diff --git a/packages/admin-ui/ui/src/domain/pricing/details/sections/prices-details/edit-prices.tsx b/packages/admin-ui/ui/src/domain/pricing/details/sections/prices-details/edit-prices.tsx deleted file mode 100644 index 29591241fa..0000000000 --- a/packages/admin-ui/ui/src/domain/pricing/details/sections/prices-details/edit-prices.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { Product } from "@medusajs/medusa" -import { debounce } from "lodash" -import { useAdminPriceListProducts } from "medusa-react" -import * as React from "react" -import { useTranslation } from "react-i18next" -import Tooltip from "../../../../../components/atoms/tooltip" -import EditIcon from "../../../../../components/fundamentals/icons/edit-icon" -import InfoIcon from "../../../../../components/fundamentals/icons/info-icon" -import TrashIcon from "../../../../../components/fundamentals/icons/trash-icon" -import FocusModal from "../../../../../components/molecules/modal/focus-modal" -import useQueryFilters from "../../../../../hooks/use-query-filters" -import FormHeader from "../../../pricing-form/form-header/" -import ProductPrices from "../../../pricing-form/sections/product-prices" -import { ViewType } from "../../../pricing-form/types" -import { merge } from "./utils" - -const defaultQueryFilters = { - limit: 50, - offset: 0, -} - -const EditPrices = ({ close, id }) => { - const { t } = useTranslation() - const params = useQueryFilters(defaultQueryFilters) - const [selectedProducts, setSelectedProducts] = React.useState([]) - const { products, isLoading } = useAdminPriceListProducts( - id, - params.queryObject - ) - const handleSearch = (query: string) => { - params.setQuery(query) - } - - React.useEffect(() => { - setSelectedProducts((state) => merge(products, state)) - }, [products, merge]) - - const debouncedSearch = React.useMemo(() => debounce(handleSearch, 300), []) - - return ( - - - - - -
-
-

- {t("prices-details-edit-prices", "Edit prices")} -

-
-
-
- {t("prices-details-prices", "Prices")} -
- - - -
- - {t( - "prices-details-you-will-be-able-to-override-the-prices-for-the-products-you-add-here", - "You will be able to override the prices for the products you add here" - )} - -
- -
-
-
-
- ) -} - -const VariantActions = (product: Product) => { - const { t } = useTranslation() - return [ - { - label: t("prices-details-edit-prices", "Edit prices"), - icon: , - onClick: () => { - // open grid ui - }, - }, - { - label: t("prices-details-remove-from-list", "Remove from list"), - icon: , - onClick: () => { - // missing core support - }, - variant: "danger" as const, - }, - ] -} - -export default EditPrices diff --git a/packages/admin-ui/ui/src/domain/pricing/details/sections/prices-details/index.tsx b/packages/admin-ui/ui/src/domain/pricing/details/sections/prices-details/index.tsx deleted file mode 100644 index 7b4ac91907..0000000000 --- a/packages/admin-ui/ui/src/domain/pricing/details/sections/prices-details/index.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Product } from "@medusajs/medusa" -import * as React from "react" -import { useTranslation } from "react-i18next" -import Fade from "../../../../../components/atoms/fade-wrapper" -import EditIcon from "../../../../../components/fundamentals/icons/edit-icon" -import UploadIcon from "../../../../../components/fundamentals/icons/upload-icon" -import BodyCard from "../../../../../components/organisms/body-card" -import useToggleState from "../../../../../hooks/use-toggle-state" -import EditPrices from "./edit-prices" -import EditPricesOverridesModal from "./edit-prices-overrides" -import ImportPrices from "../../../batch-job/import" -import PricesTable from "./prices-table" - -const Prices = ({ id }) => { - const { t } = useTranslation() - const { state: showEdit, open: openEdit, close: closeEdit } = useToggleState() - const [showUpload, openUpload, closeUpload] = useToggleState() - const [selectedProduct, setSelectedProduct] = React.useState( - null - ) - const actionables = [ - { - label: t("prices-details-edit-manually", "Edit manually"), - onClick: openEdit, - icon: , - }, - { - label: t("prices-details-import-price-list", "Import price list"), - onClick: openUpload, - icon: , - }, - ] - return ( - - - - {" "} - - {showUpload && ( - closeUpload()} /> - )} - {selectedProduct && ( - setSelectedProduct(null)} - /> - )} - - ) -} - -export default Prices diff --git a/packages/admin-ui/ui/src/domain/pricing/details/sections/prices-details/prices-table/index.tsx b/packages/admin-ui/ui/src/domain/pricing/details/sections/prices-details/prices-table/index.tsx deleted file mode 100644 index 1aba0d4b44..0000000000 --- a/packages/admin-ui/ui/src/domain/pricing/details/sections/prices-details/prices-table/index.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { Product } from "@medusajs/medusa" -import { - useAdminDeletePriceListProductPrices, - useAdminPriceListProducts, -} from "medusa-react" -import { HeaderGroup, Row } from "react-table" -import { useTranslation } from "react-i18next" -import CancelIcon from "../../../../../../components/fundamentals/icons/cancel-icon" -import EditIcon from "../../../../../../components/fundamentals/icons/edit-icon" -import Table from "../../../../../../components/molecules/table" -import { SelectableTable } from "../../../../../../components/templates/selectable-table" -import useNotification from "../../../../../../hooks/use-notification" -import useQueryFilters from "../../../../../../hooks/use-query-filters" -import { getErrorMessage } from "../../../../../../utils/error-messages" -import usePricesColumns from "./use-columns" - -const DEFAULT_PAGE_SIZE = 9 -const defaultQueryProps = { - offset: 0, - limit: DEFAULT_PAGE_SIZE, -} - -type PricesTableProps = { - id: string - selectProduct: (product: Product) => void -} - -const PricesTable = ({ id, selectProduct }: PricesTableProps) => { - const { t } = useTranslation() - const params = useQueryFilters(defaultQueryProps) - const { - products, - isLoading, - count = 0, - } = useAdminPriceListProducts(id, params.queryObject) - const columns = usePricesColumns() - - return ( -
- }) => { - const handleSelect = () => { - selectProduct(row.original) - } - - return ( - - {row.cells.map((cell) => { - return ( - - {cell.render("Cell")} - - ) - })} - - ) - }} - renderHeaderGroup={ProductHeader} - isLoading={isLoading} - totalCount={count} - options={{ - enableSearch: false, - searchPlaceholder: t( - "prices-table-search-by-name-or-sku", - "Search by name or SKU..." - ), - }} - {...params} - /> -
- ) -} - -const ProductHeader = ({ - headerGroup, -}: { - headerGroup: HeaderGroup -}) => { - return ( - - {headerGroup.headers.map((col) => ( - - {col.render("Header")} - - ))} - - ) -} - -const PricesTableRow = ({ - children, - priceListId, - product, - onClick, - ...props -}) => { - const { t } = useTranslation() - const notification = useNotification() - const deleteProductPrices = useAdminDeletePriceListProductPrices( - priceListId, - product.id - ) - - const actions = [ - { - label: t("prices-table-edit-prices", "Edit prices"), - icon: , - onClick: onClick, - }, - { - label: t("prices-table-remove-product", "Remove product"), - icon: , - variant: "danger" as const, - onClick: () => { - deleteProductPrices.mutate(undefined, { - onSuccess: () => { - notification( - t("prices-table-success", "Success"), - t( - "prices-table-deleted-prices-of-product", - "Deleted prices of product: {{title}}", - { title: product.title } - ), - "success" - ) - }, - onError: (err) => - notification( - t("prices-table-error", "Error"), - getErrorMessage(err), - "error" - ), - }) - }, - }, - ] - - return ( - - {children} - - ) -} - -export default PricesTable diff --git a/packages/admin-ui/ui/src/domain/pricing/details/sections/prices-details/prices-table/use-columns.tsx b/packages/admin-ui/ui/src/domain/pricing/details/sections/prices-details/prices-table/use-columns.tsx deleted file mode 100644 index 7888c2f5ef..0000000000 --- a/packages/admin-ui/ui/src/domain/pricing/details/sections/prices-details/prices-table/use-columns.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { Product } from "@medusajs/medusa" -import * as React from "react" -import { Column } from "react-table" -import { useTranslation } from "react-i18next" -import ImagePlaceholder from "../../../../../../components/fundamentals/image-placeholder" -import Table from "../../../../../../components/molecules/table" - -const usePricesColumns = () => { - const { t } = useTranslation() - const columns = React.useMemo[]>( - () => [ - { - Header:
{t("prices-table-name", "Name")}
, - accessor: "title", - Cell: ({ row: { original } }) => ( -
-
- {original.thumbnail ? ( - - ) : ( - - )} -
-
- {original.title} -
-
- ), - }, - { - Header: ( -
- {t("prices-table-collection", "Collection")} -
- ), - accessor: "collection", - Cell: ({ cell: { value } }) => ( - - {value?.title ? ( - value.title - ) : ( - - {t("prices-table-no-collection", "No collection")} - - )} - - ), - }, - { - Header: t("prices-table-variants", "Variants"), - Cell: ({ row: { original } }) => ( - {original.variants.length} - ), - }, - ], - [] - ) - - return columns -} -export default usePricesColumns diff --git a/packages/admin-ui/ui/src/domain/pricing/details/sections/prices-details/utils.ts b/packages/admin-ui/ui/src/domain/pricing/details/sections/prices-details/utils.ts deleted file mode 100644 index 3ac2d5c750..0000000000 --- a/packages/admin-ui/ui/src/domain/pricing/details/sections/prices-details/utils.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Idable } from "../../../../../types/shared" - -export const merge = ( - l1: T[] = [], - l2: U[] = [] -) => { - const normalizedListA = normalize(l1, "id") - const normalizedListB = normalize(l2, "id") - const merged = Object.values(normalizedListA) - Object.values(normalizedListB).forEach((element: any) => { - if (!normalizedListA[element.id]) { - merged.push(element) - } - }) - return merged -} - -const normalize = (arr: T[], key) => { - return arr.reduce( - (obj, curr) => ((obj[curr[key]] = { ...curr }), obj), - {} as T - ) -} diff --git a/packages/admin-ui/ui/src/domain/pricing/details/utils.ts b/packages/admin-ui/ui/src/domain/pricing/details/utils.ts deleted file mode 100644 index fe463a1fa8..0000000000 --- a/packages/admin-ui/ui/src/domain/pricing/details/utils.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const mergeExistingWithDefault = ( - variantPrices: any[] = [], - defaultPrices -) => { - return defaultPrices.map((pr) => { - const price = variantPrices.find( - (vpr) => vpr?.currency_code === pr.currency_code - ) - return price || pr - }) -} diff --git a/packages/admin-ui/ui/src/domain/pricing/edit/details/details-drawer.tsx b/packages/admin-ui/ui/src/domain/pricing/edit/details/details-drawer.tsx new file mode 100644 index 0000000000..dcb687fa15 --- /dev/null +++ b/packages/admin-ui/ui/src/domain/pricing/edit/details/details-drawer.tsx @@ -0,0 +1,184 @@ +import { PriceList } from "@medusajs/medusa" +import { Button, Drawer, usePrompt } from "@medusajs/ui" +import { useAdminUpdatePriceList } from "medusa-react" +import * as React from "react" +import { useForm } from "react-hook-form" + +import { useTranslation } from "react-i18next" +import { Form } from "../../../../components/helpers/form" +import useNotification from "../../../../hooks/use-notification" +import { useFeatureFlag } from "../../../../providers/feature-flag-provider" +import { getErrorMessage } from "../../../../utils/error-messages" +import { nestedForm } from "../../../../utils/nested-form" + +import { + PriceListDetailsForm, + type PriceListDetailsSchema, + type PriceListType, +} from "../../forms/price-list-details-form" + +type PriceListDetailsDrawerProps = { + open: boolean + onOpenChange: (open: boolean) => void + priceList: PriceList +} + +type PriceListDetailsFormValues = { + details: PriceListDetailsSchema +} + +const PriceListDetailsDrawer = ({ + open, + onOpenChange, + priceList, +}: PriceListDetailsDrawerProps) => { + const { mutateAsync, isLoading } = useAdminUpdatePriceList(priceList.id) + const { t } = useTranslation() + + const form = useForm({ + defaultValues: getDefaultValues(priceList), + }) + + const { isFeatureEnabled } = useFeatureFlag() + const isTaxInclPricesEnabled = isFeatureEnabled("tax_inclusive_pricing") + + const { + reset, + formState: { isDirty }, + handleSubmit, + } = form + + React.useEffect(() => { + if (open) { + reset(getDefaultValues(priceList)) + } + }, [priceList, open, reset]) + + const prompt = usePrompt() + const notification = useNotification() + + const onStateChange = React.useCallback( + async (open: boolean) => { + if (isDirty) { + const response = await prompt({ + title: t("price-list-details-drawer-prompt-title", "Are you sure?"), + description: t( + "price-list-details-drawer-prompt-description", + "You have unsaved changes, are you sure you want to exit?" + ), + }) + + if (!response) { + onOpenChange(true) + return + } + } + + reset() + onOpenChange(open) + }, + [isDirty, t, reset, prompt, onOpenChange] + ) + + const onSubmit = handleSubmit(async (values) => { + await mutateAsync( + { + type: values.details.type.value as PriceListType, + name: values.details.general.name, + description: values.details.general.description, + starts_at: values.details.dates.starts_at ?? null, + ends_at: values.details.dates.ends_at ?? null, + includes_tax: isTaxInclPricesEnabled + ? values.details.general.tax_inclusive + : undefined, + customer_groups: values.details.customer_groups.ids.map((id) => ({ + id, + })), + }, + { + onSuccess: () => { + notification( + t( + "price-list-details-notification-succes-title", + "Price list updated" + ), + t( + "price-list-details-drawer-notification-success-message", + "Successfully updated price list" + ), + "success" + ) + + onOpenChange(false) + }, + onError: (err) => { + notification( + t( + "price-list-details-drawer-notification-error-title", + "An error occurred" + ), + getErrorMessage(err), + "error" + ) + }, + } + ) + }) + + return ( + +
+ + + + {t("price-list-details-drawer-title", "Edit Price List Details")} + + + + + + + + + + + + +
+
+ ) +} + +const getDefaultValues = (priceList: PriceList): PriceListDetailsFormValues => { + return { + details: { + type: { + value: priceList.type, + }, + general: { + name: priceList.name, + description: priceList.description, + tax_inclusive: priceList.includes_tax, + }, + customer_groups: { + ids: priceList.customer_groups?.map((cg) => cg.id) ?? [], + }, + dates: { + starts_at: priceList.starts_at + ? new Date(priceList.starts_at) + : undefined, + ends_at: priceList.ends_at ? new Date(priceList.ends_at) : undefined, + }, + }, + } +} + +export { PriceListDetailsDrawer as EditDetailsDrawer } diff --git a/packages/admin-ui/ui/src/domain/pricing/edit/details/details-section.tsx b/packages/admin-ui/ui/src/domain/pricing/edit/details/details-section.tsx new file mode 100644 index 0000000000..2ef53a77f0 --- /dev/null +++ b/packages/admin-ui/ui/src/domain/pricing/edit/details/details-section.tsx @@ -0,0 +1,355 @@ +import { + EllipseGreenSolid, + EllipseGreySolid, + EllipseOrangeSolid, + EllipseRedSolid, + EllipsisHorizontal, + PencilSquare, + Trash, +} from "@medusajs/icons" +import type { PriceList } from "@medusajs/medusa" +import { + Badge, + Button, + Container, + DropdownMenu, + Heading, + IconButton, + Text, + Tooltip, + usePrompt, +} from "@medusajs/ui" +import { format } from "date-fns" +import { useAdminDeletePriceList, useAdminUpdatePriceList } from "medusa-react" +import * as React from "react" +import { useNavigate } from "react-router-dom" + +import { useTranslation } from "react-i18next" +import useNotification from "../../../../hooks/use-notification" +import { getErrorMessage } from "../../../../utils/error-messages" +import { PriceListStatus } from "../../forms/price-list-details-form" +import { EditDetailsDrawer } from "./details-drawer" + +type PriceListDetailsSectionProps = { + priceList: PriceList +} + +const PriceListDetailsSection = ({ + priceList, +}: PriceListDetailsSectionProps) => { + const [open, setOpen] = React.useState(false) + + const toggleDrawer = () => { + setOpen(!open) + } + + const { t } = useTranslation() + + const prompt = usePrompt() + const notification = useNotification() + + const navigate = useNavigate() + + const { mutateAsync } = useAdminDeletePriceList(priceList.id) + + const onDeletePriceList = async () => { + const name = priceList.name + + const confirmText = + t("price-list-details-section-prompt-confirm-text", "Delete") ?? undefined + const cancelText = + t("price-list-details-section-prompt-cancel-text", "Cancel") ?? undefined + + const res = await prompt({ + title: t("price-list-details-section-prompt-title", "Delete price list"), + description: t( + "price-list-details-section-prompt-description", + `Are you sure you want to delete the price list "{{name}}"?`, + { + name, + } + ), + confirmText, + cancelText, + }) + + if (!res) { + return + } + + await mutateAsync(undefined, { + onSuccess: () => { + notification( + t( + "price-list-details-section-delete-notification-success-title", + "Successfully deleted price list" + ), + t( + "price-list-details-section-delete-notification-success-message", + `The price list "{{name}}" was successfully deleted`, + { name } + ), + `success` + ) + + navigate(`/a/price-lists`, { replace: true }) + }, + onError: (err) => { + notification( + t( + "price-list-details-section-delete-notification-error-title", + "Failed to delete price list" + ), + getErrorMessage(err), + `error` + ) + }, + }) + } + + return ( +
+ +
+
+ {priceList.name} +
+ + +
+
+ {priceList?.description} +
+
+
+ + {t( + "price-list-details-section-customer-groups", + "Customer Groups" + )} + + {(priceList.customer_groups?.length ?? 0) > 0 ? ( +
+ + {priceList.customer_groups + .slice(0, 2) + .map((cg) => cg.name) + .join(", ")} + + {(priceList.customer_groups?.length || 0) > 2 && ( + + {priceList.customer_groups.slice(2).map((group) => { + return ( + + {group.name} + + ) + })} +
+ } + > + + +{priceList.customer_groups.length - 2} + + + )} +
+ ) : ( + + - + + )} +
+
+ + {t("price-list-details-section-last-edited", "Last edited")} + + + {format(new Date(priceList.updated_at), "EEE d, MMM yyyy")} + +
+
+ + {t("price-list-details-section-number-of-prices", "Prices")} + + {priceList.prices.length ?? 0} +
+
+ + +
+ ) +} + +type PriceListStatusMenuProps = { + status: PriceList["status"] + endsAt: PriceList["ends_at"] + startsAt: PriceList["starts_at"] + priceListId: string +} + +const PriceListStatusMenu = ({ + status, + endsAt, + startsAt, + priceListId, +}: PriceListStatusMenuProps) => { + const { t } = useTranslation() + const notification = useNotification() + + const { mutateAsync } = useAdminUpdatePriceList(priceListId) + + const isActive = status === "active" + const isExpired = endsAt ? new Date(endsAt) < new Date() : false + const isScheduled = startsAt ? new Date(startsAt) > new Date() : false + + const statusText = React.useMemo(() => { + if (isExpired) { + return t("price-list-details-section-status-menu-expired", "Expired") + } + + if (status === "draft") { + return t("price-list-details-section-status-menu-draft", "Draft") + } + + if (isScheduled) { + return t("price-list-details-section-status-menu-scheduled", "Scheduled") + } + + return t("price-list-details-section-status-active", "Active") + }, [status, t, isExpired, isScheduled]) + + const statusDot = React.useMemo(() => { + if (isExpired) { + return + } + + if (status === "draft") { + return + } + + if (isScheduled) { + return + } + + return + }, [status, isExpired, isScheduled]) + + const onUpdateStatus = async () => { + const newStatus = isActive ? PriceListStatus.DRAFT : PriceListStatus.ACTIVE + + mutateAsync( + { + status: newStatus, + }, + { + onSuccess: () => { + notification( + t( + "price-list-details-section-status-menu-notification-success-title", + "Successfully updated price list status" + ), + t( + "price-list-details-section-status-menu-notification-success-message", + `The price list status was successfully updated to {{status}}`, + { status: newStatus } + ), + `success` + ) + }, + onError: (err) => { + notification( + t( + "price-list-details-section-status-menu-notification-error-title", + "Failed to update price list status" + ), + getErrorMessage(err), + `error` + ) + }, + } + ) + } + + return ( + + + + + + + {isActive ? : } + + {isActive + ? t("price-list-details-section-status-menu-item-draft", "Draft") + : t( + "price-list-details-section-status-menu-item-activate", + "Activate" + )} + + + + + ) +} + +type PriceListMenuProps = { + onDelete: () => Promise + onOpenDrawer: () => void +} + +const PriceListMenu = ({ onDelete, onOpenDrawer }: PriceListMenuProps) => { + const { t } = useTranslation() + + return ( + + + + + + + + + + + {t("price-list-details-menu-item-edit", "Edit details")} + + + + + + + {t("price-list-details-menu-item-delete", "Delete")} + + + + + ) +} + +export { PriceListDetailsSection as PriceListGeneralSection } diff --git a/packages/admin-ui/ui/src/domain/pricing/edit/details/index.ts b/packages/admin-ui/ui/src/domain/pricing/edit/details/index.ts new file mode 100644 index 0000000000..fa19259540 --- /dev/null +++ b/packages/admin-ui/ui/src/domain/pricing/edit/details/index.ts @@ -0,0 +1 @@ +export * from "./details-section" diff --git a/packages/admin-ui/ui/src/domain/pricing/edit/edit.tsx b/packages/admin-ui/ui/src/domain/pricing/edit/edit.tsx new file mode 100644 index 0000000000..654c7b9ca7 --- /dev/null +++ b/packages/admin-ui/ui/src/domain/pricing/edit/edit.tsx @@ -0,0 +1,79 @@ +import { ExclamationCircle, Spinner } from "@medusajs/icons" +import { Container, Text } from "@medusajs/ui" +import { useAdminPriceList } from "medusa-react" +import { useTranslation } from "react-i18next" +import { useParams } from "react-router-dom" + +import Spacer from "../../../components/atoms/spacer" +import WidgetContainer from "../../../components/extensions/widget-container" +import { useWidgets } from "../../../providers/widget-provider" +import { PriceListGeneralSection } from "./details" +import { PriceListPricesSection } from "./prices" + +const PriceListEdit = () => { + const { id } = useParams<{ id: string }>() + + const { t } = useTranslation() + + const { getWidgets } = useWidgets() + + const { price_list, isLoading, isError } = useAdminPriceList(id!, { + enabled: !!id, + }) + + if (isLoading) { + return ( + + + + ) + } + + if (isError || !price_list) { + return ( + +
+ + + {t( + "price-list-edit-error", + "An error occurred while loading price list. Reload the page and try again. If the issue persists, try again later." + )} + +
+
+ ) + } + + return ( + <> +
+ {getWidgets("product.details.before").map((w, i) => { + return ( + + ) + })} + + + {getWidgets("product.details.after").map((w, i) => { + return ( + + ) + })} + +
+ + ) +} + +export { PriceListEdit } diff --git a/packages/admin-ui/ui/src/domain/pricing/edit/index.ts b/packages/admin-ui/ui/src/domain/pricing/edit/index.ts new file mode 100644 index 0000000000..5f25e607ff --- /dev/null +++ b/packages/admin-ui/ui/src/domain/pricing/edit/index.ts @@ -0,0 +1 @@ +export * from "./edit" diff --git a/packages/admin-ui/ui/src/domain/pricing/edit/prices/add-products-modal.tsx b/packages/admin-ui/ui/src/domain/pricing/edit/prices/add-products-modal.tsx new file mode 100644 index 0000000000..3d35c1c293 --- /dev/null +++ b/packages/admin-ui/ui/src/domain/pricing/edit/prices/add-products-modal.tsx @@ -0,0 +1,640 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { ExclamationCircle, Spinner } from "@medusajs/icons" +import type { PriceList, Product } from "@medusajs/medusa" +import { + Button, + FocusModal, + ProgressStatus, + ProgressTabs, + Text, + usePrompt, +} from "@medusajs/ui" +import * as React from "react" +import { useForm } from "react-hook-form" +import * as z from "zod" + +import { useAdminCreatePriceListPrices } from "medusa-react" +import { useTranslation } from "react-i18next" +import { Form } from "../../../../components/helpers/form" +import useNotification from "../../../../hooks/use-notification" +import { useFeatureFlag } from "../../../../providers/feature-flag-provider" +import { getErrorMessage } from "../../../../utils/error-messages" +import { nestedForm } from "../../../../utils/nested-form" +import { + PriceListPricesForm, + PricePayload, + getDbSafeAmount, + priceListPricesSchema, + usePricesFormData, +} from "../../forms/price-list-prices-form" +import { + PriceListProductPricesForm, + priceListProductPricesSchema, + type PriceListProductPricesSchema, +} from "../../forms/price-list-product-prices-form" +import { + PriceListProductsForm, + priceListProductsSchema, +} from "../../forms/price-list-products-form" + +type AddProductsModalProps = { + open: boolean + onOpenChange: (open: boolean) => void + priceList: PriceList + /** + * Products that are already in the price list + */ + productIds: string[] +} + +enum Tab { + PRODUCTS = "products", + PRICES = "prices", + EDIT = "edit", +} + +const addProductsSchema = z.object({ + products: priceListProductsSchema, + prices: priceListPricesSchema, +}) + +type AddProductsFormValue = z.infer + +type StepStatus = { + [key in Tab]: ProgressStatus +} + +const AddProductsModal = ({ + open, + onOpenChange, + priceList, + productIds, +}: AddProductsModalProps) => { + const [product, setProduct] = React.useState(null) + const [selectedIds, setSelectedIds] = React.useState([]) + + const [tab, setTab] = React.useState(Tab.PRODUCTS) + const [status, setStatus] = React.useState({ + [Tab.PRODUCTS]: "not-started", + [Tab.PRICES]: "not-started", + [Tab.EDIT]: "not-started", + }) + + const { t } = useTranslation() + + const promptTitle = t("price-list-new-form-prompt-title", "Are you sure?") + const promptExitDescription = t( + "price-list-new-form-prompt-exit-description", + "You have unsaved changes, are you sure you want to exit?" + ) + const promptBackDescription = t( + "price-list-new-form-prompt-back-description", + "You have unsaved changes, are you sure you want to go back?" + ) + + const prompt = usePrompt() + const notification = useNotification() + + const { isFeatureEnabled } = useFeatureFlag() + const isTaxInclPricesEnabled = isFeatureEnabled("tax_inclusive_pricing") + + const form = useForm({ + resolver: zodResolver(addProductsSchema), + defaultValues: { + products: { ids: [] }, + prices: { products: {} }, + }, + }) + + const { + trigger, + handleSubmit, + setValue, + getValues, + reset, + formState: { isDirty }, + } = form + + const { + control: editControl, + handleSubmit: handleEditSubmit, + reset: resetEdit, + setValue: setEditValue, + getValues: getEditValues, + + formState: { isDirty: isEditDirty }, + } = useForm({ + resolver: zodResolver(priceListProductPricesSchema), + }) + + const { mutateAsync, isLoading: isSubmitting } = + useAdminCreatePriceListPrices(priceList.id) + + const { isError, isLoading, isNotFound, regions, currencies } = + usePricesFormData({ + productIds: selectedIds, + }) + + const onCloseModal = React.useCallback(() => { + onOpenChange(false) + setTab(Tab.PRODUCTS) + setStatus({ + [Tab.PRODUCTS]: "not-started", + [Tab.PRICES]: "not-started", + [Tab.EDIT]: "not-started", + }) + + resetEdit() + reset({ + products: { ids: [] }, + prices: { products: {} }, + }) + }, [onOpenChange, reset, resetEdit]) + + const onModalStateChange = React.useCallback( + async (open: boolean) => { + if (!open && (isDirty || isEditDirty)) { + const response = await prompt({ + title: promptTitle, + description: promptExitDescription, + }) + + if (!response) { + onOpenChange(true) + return + } + + onCloseModal() + } + + onOpenChange(open) + }, + [ + isDirty, + isEditDirty, + promptTitle, + promptExitDescription, + prompt, + onCloseModal, + onOpenChange, + ] + ) + + const onSavePriceEdit = handleEditSubmit((data) => { + if (!product) { + return + } + + setValue(`prices.products.${product.id}`, data, { + shouldDirty: true, + shouldTouch: true, + }) + + setProduct(null) + resetEdit(undefined, { + keepDirty: false, + keepTouched: false, + }) + setStatus((prev) => ({ + ...prev, + [Tab.PRICES]: "in-progress", + })) + setTab(Tab.PRICES) + }) + + const onSubmit = handleSubmit(async (data) => { + const prices: PricePayload[] = [] + + for (const productId of Object.keys(data.prices.products)) { + const product = data.prices.products[productId] + + for (const variantId of Object.keys(product.variants)) { + const variant = product.variants[variantId] + + if (variant.currency) { + for (const currencyCode of Object.keys(variant.currency)) { + const { amount } = variant.currency[currencyCode] + + if (!amount) { + continue + } + + if (!currencyCode) { + continue + } + + const dbSafeAmount = getDbSafeAmount( + currencyCode, + parseFloat(amount) + ) + + if (!dbSafeAmount) { + continue + } + + const payload: PricePayload = { + amount: dbSafeAmount, + variant_id: variantId, + currency_code: currencyCode, + } + + prices.push(payload) + } + } + + if (variant.region) { + for (const regionId of Object.keys(variant.region)) { + const { amount } = variant.region[regionId] + + if (!amount) { + continue + } + + if (!regionId) { + continue + } + + const dbSafeAmount = getDbSafeAmount( + regions.find((r) => r.id === regionId)!.currency_code, + parseFloat(amount) + ) + + if (!dbSafeAmount) { + continue + } + + const payload: PricePayload = { + amount: dbSafeAmount, + variant_id: variantId, + region_id: regionId, + } + + prices.push(payload) + } + } + } + } + + await mutateAsync( + { + prices, + }, + { + onSuccess: () => { + notification( + t( + "price-list-add-products-modal-success-title", + "New prices added" + ), + t( + "price-list-add-products-modal-success-message", + "The new prices have been added to the price list." + ), + "success" + ) + + onCloseModal() + }, + onError: (err) => { + notification( + t("price-list-add-products-modal-error-title", "An error occurred"), + getErrorMessage(err), + "error" + ) + }, + } + ) + }) + + const onSetProduct = React.useCallback( + (product: Product | null) => { + if (!product) { + setProduct(null) + setTab(Tab.PRICES) + return + } + + const defaultValues = getValues(`prices.products.${product.id}`) + resetEdit(defaultValues) + setProduct(product) + setTab(Tab.EDIT) + }, + [resetEdit, getValues] + ) + + /** + * When exiting the "Edit" tab, we need to check + * if the user has unsaved changes. If they do, + * we need to prompt them whether they want to + * continue or not. + */ + const onExitProductPrices = React.useCallback( + async (tab = Tab.PRICES) => { + if (isEditDirty) { + const res = await prompt({ + title: promptTitle, + description: promptBackDescription, + }) + + if (!res) { + return + } + } + + const defaultValues = product + ? getValues(`prices.products.${product.id}`) + : undefined + + setTab(tab) + setProduct(null) + resetEdit(defaultValues, { + keepDirty: false, + keepTouched: false, + }) + }, + [ + prompt, + resetEdit, + getValues, + product, + isEditDirty, + promptTitle, + promptBackDescription, + ] + ) + + /** + * If the current tab is edit, we need to + * check if the user wants to exit the edit + * tab or if they want to save the changes + * before continuing. + */ + const onTabChange = React.useCallback( + async (value: Tab) => { + if (tab === Tab.EDIT) { + await onExitProductPrices(value) + return + } + + setTab(value) + }, + [tab, onExitProductPrices] + ) + + /** + * Callback for ensuring that we don't submit prices + * for products that the user has unselected. + */ + const onUpdateSelectedProductIds = React.useCallback( + (ids: string[]) => { + setSelectedIds((prev) => { + /** + * If the previous ids are the same as the new ids, + * we need to clear the values the old ids that are no + * longer selected. + */ + for (const id of prev) { + if (!ids.includes(id)) { + setValue(`prices.products.${id}`, { variants: {} }) + } + } + + return ids + }) + }, + [setValue] + ) + + const onCancelPriceEdit = React.useCallback(async () => { + if (!product) { + setTab(Tab.PRICES) + return + } + + if (isEditDirty) { + const res = await prompt({ + title: "Are you sure?", + description: "You have unsaved changes, are you sure you want to exit?", + }) + + if (!res) { + return + } + } + + const defaultValues = getValues(`prices.products.${product.id}`) + + setProduct(null) + resetEdit(defaultValues) + setTab(Tab.PRICES) + }, [getValues, resetEdit, prompt, isEditDirty, product]) + + /** + * Callback for validating the products form. + */ + const onValidateProducts = React.useCallback(async () => { + const result = await trigger("products") + + if (!result) { + setStatus((prev) => ({ + ...prev, + [Tab.PRODUCTS]: "in-progress", + })) + return + } + + const ids = getValues("products.ids") + + onUpdateSelectedProductIds(ids) + + setTab(Tab.PRICES) + setStatus((prev) => ({ + ...prev, + [Tab.PRODUCTS]: "completed", + })) + }, [trigger, getValues, onUpdateSelectedProductIds]) + + const onBack = React.useCallback(async () => { + switch (tab) { + case Tab.PRODUCTS: + onModalStateChange(false) + break + case Tab.PRICES: + setTab(Tab.PRODUCTS) + break + case Tab.EDIT: + await onCancelPriceEdit() + break + } + }, [tab, onCancelPriceEdit, onModalStateChange]) + + const backButtonText = React.useMemo(() => { + switch (tab) { + case Tab.PRODUCTS: + return t("price-list-add-products-modal-back-button-cancel", "Cancel") + default: + return t("price-list-add-products-modal-back-button", "Back") + } + }, [tab, t]) + + const onNext = React.useCallback(async () => { + switch (tab) { + case Tab.PRODUCTS: + onValidateProducts() + break + case Tab.PRICES: + await onSubmit() + break + case Tab.EDIT: + await onSavePriceEdit() + break + } + }, [onValidateProducts, onSavePriceEdit, onSubmit, tab]) + + const nextButtonText = React.useMemo(() => { + switch (tab) { + case Tab.PRODUCTS: + return t( + "price-list-add-products-modal-next-button-continue", + "Continue" + ) + case Tab.PRICES: + return t( + "price-list-add-products-modal-next-button-submit-and-close", + "Submit and Close" + ) + case Tab.EDIT: + return t( + "price-list-add-products-modal-next-button-continue-save-prices", + "Save Prices" + ) + } + }, [tab, t]) + + return ( + + onTabChange(tab as Tab)} + > + + + + + + {t( + "price-list-add-products-modal-products-tab", + "Choose Products" + )} + + + + + {t("price-list-add-products-modal-prices-tab", "Edit Prices")} + + + {product && ( + + + {product.title} + + + )} + +
+ + +
+
+ +
+ + + + {isLoading ? ( +
+ +
+ ) : isError || isNotFound ? ( +
+
+ + + {t( + "price-list-add-products-modal-error", + "An error occurred while preparing the form. Reload the page and try again. If the issue persists, try again later." + )} + +
+
+ ) : ( + + + + + {product && ( + + + + )} + + )} +
+
+
+
+
+ ) +} + +export { AddProductsModal } diff --git a/packages/admin-ui/ui/src/domain/pricing/edit/prices/edit-prices-modal.tsx b/packages/admin-ui/ui/src/domain/pricing/edit/prices/edit-prices-modal.tsx new file mode 100644 index 0000000000..b37145fd99 --- /dev/null +++ b/packages/admin-ui/ui/src/domain/pricing/edit/prices/edit-prices-modal.tsx @@ -0,0 +1,619 @@ +import { ExclamationCircle, Spinner } from "@medusajs/icons" +import type { PriceList, Product } from "@medusajs/medusa" +import { Button, FocusModal, ProgressTabs, Text, usePrompt } from "@medusajs/ui" +import { + useAdminDeletePriceListPrices, + useAdminUpdatePriceList, +} from "medusa-react" +import * as React from "react" +import { useForm } from "react-hook-form" + +import { zodResolver } from "@hookform/resolvers/zod" +import { useTranslation } from "react-i18next" +import useNotification from "../../../../hooks/use-notification" +import { useFeatureFlag } from "../../../../providers/feature-flag-provider" +import { getErrorMessage } from "../../../../utils/error-messages" +import { nestedForm } from "../../../../utils/nested-form" +import { + PriceListPricesForm, + getDbSafeAmount, + getDefaultAmount, + priceListPricesSchema, + usePricesFormData, + type PriceListPricesSchema, + type PricePayload, +} from "../../forms/price-list-prices-form" +import { + PriceListProductPricesForm, + priceListProductPricesSchema, + type PriceListProductPricesSchema, +} from "../../forms/price-list-product-prices-form" + +type EditPricesModalProps = { + open: boolean + onOpenChange: (open: boolean) => void + priceList: PriceList + productIds: string[] +} + +enum Tab { + PRICES = "prices", + EDIT = "edit", +} + +const EditPricesModal = ({ + open, + onOpenChange, + priceList, + productIds, +}: EditPricesModalProps) => { + const [tab, setTab] = React.useState(Tab.PRICES) + const [product, setProduct] = React.useState(null) + + const { t } = useTranslation() + + const promptTitle = t( + "price-list-edit-prices-modal-prompt-title", + "Are you sure?" + ) + const promptExitDescription = t( + "price-list-edit-prices-modal-prompt-exit-description", + "You have unsaved changes, are you sure you want to exit?" + ) + const promptBackDescription = t( + "price-list-edit-prices-modal-prompt-back-description", + "You have unsaved changes, are you sure you want to go back?" + ) + + const prompt = usePrompt() + const notification = useNotification() + + const { isFeatureEnabled } = useFeatureFlag() + const isTaxInclPricesEnabled = isFeatureEnabled("tax_inclusive_pricing") + + const pricesForm = useForm({ + resolver: zodResolver(priceListPricesSchema), + }) + + const { + handleSubmit: handlePricesSubmit, + reset: resetPrices, + formState: { isDirty: isPricesDirty }, + getValues, + setValue, + } = pricesForm + + const { + control: productControl, + handleSubmit: handleProductSubmit, + reset: resetProduct, + formState: { isDirty: isProductDirty }, + setValue: setProductValue, + getValues: getProductValues, + } = useForm({ + resolver: zodResolver(priceListProductPricesSchema), + }) + + const { isLoading, isError, isNotFound, regions, currencies, products } = + usePricesFormData({ + productIds, + priceList, + }) + + const { mutateAsync: updateAsync, isLoading: isSubmitting } = + useAdminUpdatePriceList(priceList.id) + + const { mutateAsync: deleteAsync, isLoading: isDeleting } = + useAdminDeletePriceListPrices(priceList.id) + + const onModalStateChange = React.useCallback( + async (open: boolean) => { + if (open) { + onOpenChange(open) + return + } + + if (isPricesDirty || isProductDirty) { + const res = await prompt({ + title: promptTitle, + description: promptExitDescription, + }) + + if (!res) { + return + } + } + + onOpenChange(false) + }, + [ + prompt, + onOpenChange, + isPricesDirty, + isProductDirty, + promptExitDescription, + promptTitle, + ] + ) + + /** + * Register default values for the price list prices form. + */ + React.useEffect(() => { + if (isLoading || isError || isNotFound || !open) { + return + } + + const productData: PriceListPricesSchema = { + products: {}, + } + + for (const product of products) { + const productPrices: PriceListProductPricesSchema = { + variants: {}, + } + + for (const variant of product.variants) { + productPrices.variants[variant.id!] = { + currency: {}, + region: {}, + } + + for (const region of regions) { + const existingPrice = priceList.prices.find( + (p) => p.variant_id === variant.id && p.region_id === region.id + ) + + const amount = existingPrice + ? getDefaultAmount(region.currency_code, existingPrice.amount) + : null + + productPrices.variants[variant.id!].region![region.id] = { + id: existingPrice ? existingPrice.id : "", + amount: amount ? `${amount}` : "", + } + } + + for (const currency of currencies) { + const existingPrice = priceList.prices.find( + (p) => + p.variant_id === variant.id && + p.currency_code === currency.code && + p.region_id === null + ) + + const amount = existingPrice + ? getDefaultAmount(currency.code, existingPrice.amount) + : null + + productPrices.variants[variant.id!].currency![currency.code] = { + id: existingPrice ? existingPrice.id : "", + amount: amount ? `${amount}` : "", + } + } + } + + productData.products[product.id!] = productPrices + } + + resetPrices(productData) + }, [ + isLoading, + isError, + isNotFound, + products, + regions, + currencies, + priceList, + resetPrices, + open, + ]) + + const onSetProduct = React.useCallback( + (product: Product | null) => { + if (!product) { + setProduct(null) + setTab(Tab.PRICES) + return + } + + const defaultValues = getValues(`products.${product.id}`) + resetProduct(defaultValues) + setProduct(product) + setTab(Tab.EDIT) + }, + [resetProduct, getValues] + ) + + /** + * When exiting the "Edit" tab, we need to check + * if the user has unsaved changes. If they do, + * we need to prompt them whether they want to + * continue or not. + */ + const onExitProductPrices = React.useCallback( + async (tab = Tab.PRICES) => { + if (isProductDirty) { + const res = await prompt({ + title: promptTitle, + description: promptBackDescription, + }) + + if (!res) { + return + } + } + + setTab(tab) + setProduct(null) + resetProduct(undefined, { + keepDirty: false, + keepTouched: false, + }) + }, + [isProductDirty, prompt, promptBackDescription, promptTitle, resetProduct] + ) + + /** + * If the current tab is edit, we need to + * check if the user wants to exit the edit + * tab or if they want to save the changes + * before continuing. + */ + const onTabChange = React.useCallback( + async (value: Tab) => { + if (tab === Tab.EDIT) { + await onExitProductPrices(value) + return + } + + setTab(value) + }, + [onExitProductPrices, tab] + ) + + const onSavePriceEdit = handleProductSubmit((data) => { + if (!product) { + return + } + + setValue(`products.${product.id}`, data, { + shouldDirty: true, + shouldTouch: true, + }) + + setProduct(null) + resetProduct(undefined, { + keepDirty: false, + keepTouched: false, + }) + setTab(Tab.PRICES) + }) + + const onSubmit = handlePricesSubmit(async (data) => { + const update: PricePayload[] = [] + const removed: string[] = [] + + for (const productId of Object.keys(data.products)) { + const product = data.products[productId] + + for (const variantId of Object.keys(product.variants)) { + const variant = product.variants[variantId] + + if (variant.currency) { + for (const currencyCode of Object.keys(variant.currency)) { + const { id, amount } = variant.currency[currencyCode] + + if (!amount) { + if (id) { + removed.push(id) + } + + continue + } + + if (!currencyCode) { + continue + } + + const dbSafeAmount = getDbSafeAmount( + currencyCode, + parseFloat(amount) + ) + + if (!dbSafeAmount) { + continue + } + + const payload: PricePayload = { + id: id ? id : undefined, + amount: dbSafeAmount, + variant_id: variantId, + currency_code: currencyCode, + } + + update.push(payload) + } + } + + if (variant.region) { + for (const regionId of Object.keys(variant.region)) { + const { id, amount } = variant.region[regionId] + + if (!amount) { + if (id) { + removed.push(id) + } + + continue + } + + if (!regionId) { + continue + } + + const dbSafeAmount = getDbSafeAmount( + regions.find((r) => r.id === regionId)!.currency_code, + parseFloat(amount) + ) + + if (!dbSafeAmount) { + continue + } + + const payload: PricePayload = { + id: id ? id : undefined, + amount: dbSafeAmount, + variant_id: variantId, + region_id: regionId, + } + + update.push(payload) + } + } + } + } + + let updateSuccess = false + + await updateAsync( + { + prices: update, + }, + { + onSuccess: () => { + updateSuccess = true + }, + onError: (err) => { + notification( + t( + "price-list-edit-prices-modal-notification-update-error", + "An error occurred" + ), + getErrorMessage(err), + "error" + ) + }, + } + ) + + /** + * If the first update failed, we don't want to + * continue with the delete request. + */ + if (!updateSuccess) { + return + } + + let removeSuccess = true + + if (removed.length > 0) { + await deleteAsync( + { + price_ids: removed, + }, + { + onSuccess: () => { + removeSuccess = true + }, + onError: () => { + removeSuccess = false + }, + } + ) + } + + /** + * If the delete request failed, we want to + * notify the user that some prices were not + * updated correctly. + */ + if (!removeSuccess) { + notification( + t( + "price-list-edit-prices-modal-notification-remove-error-title", + "An error occurred" + ), + t( + "price-list-edit-prices-modal-notification-remove-error-description", + "Some prices were not updated correctly. Try again." + ), + "warning" + ) + } else { + notification( + t( + "price-list-edit-prices-modal-notification-update-success-title", + "Prices updated" + ), + t( + "price-list-edit-prices-modal-notification-update-success-description", + "Successfully updated prices" + ), + "success" + ) + } + + onOpenChange(false) + }) + + /** + * Depending on the current tab, the next button + * will have different functionality. + */ + const onNext = React.useCallback(async () => { + switch (tab) { + case Tab.PRICES: + await onSubmit() + break + case Tab.EDIT: + await onSavePriceEdit() + break + } + }, [onSubmit, onSavePriceEdit, tab]) + + const nextButtonText = React.useMemo(() => { + switch (tab) { + case Tab.PRICES: + return t( + "price-list-edit-prices-modal-next-button-save-and-close", + "Save and Close" + ) + case Tab.EDIT: + return t("price-list-edit-prices-modal-next-button-save", "Save Prices") + } + }, [tab, t]) + + /** + * Depending on the current tab, the back button + * will have different functionality. + */ + const onBack = React.useCallback(async () => { + switch (tab) { + case Tab.PRICES: + onModalStateChange(false) + break + case Tab.EDIT: + await onExitProductPrices() + break + } + }, [onModalStateChange, onExitProductPrices, tab]) + + const backButtonText = React.useMemo(() => { + switch (tab) { + case Tab.PRICES: + return t("price-list-edit-prices-modal-back-button-cancel", "Cancel") + default: + return t("price-list-edit-prices-modal-back-button-back", "Back") + } + }, [tab, t]) + + return ( + + onTabChange(tab as Tab)} + > + + + + + + {t( + "price-list-edit-prices-modal-overview-tab", + "Edit Prices" + )} + + + {product && ( + + + {product?.title} + + + )} + +
+ + +
+
+ + {isLoading ? ( +
+ +
+ ) : isError || isNotFound ? ( +
+
+ + + {t( + "price-list-edit-prices-modal-error-loading", + "An error occurred while preparing the form. Reload the page and try again. If the issue persists, try again later." + )} + +
+
+ ) : ( + + + + + {product && ( + + + + )} + + )} +
+
+
+
+ ) +} + +export { EditPricesModal } diff --git a/packages/admin-ui/ui/src/domain/pricing/edit/prices/index.ts b/packages/admin-ui/ui/src/domain/pricing/edit/prices/index.ts new file mode 100644 index 0000000000..91698d676a --- /dev/null +++ b/packages/admin-ui/ui/src/domain/pricing/edit/prices/index.ts @@ -0,0 +1 @@ +export * from "./prices-section" diff --git a/packages/admin-ui/ui/src/domain/pricing/edit/prices/prices-section.tsx b/packages/admin-ui/ui/src/domain/pricing/edit/prices/prices-section.tsx new file mode 100644 index 0000000000..bf15546742 --- /dev/null +++ b/packages/admin-ui/ui/src/domain/pricing/edit/prices/prices-section.tsx @@ -0,0 +1,648 @@ +import { + CurrencyDollar, + EllipsisHorizontal, + ExclamationCircle, + PencilSquare, + PhotoSolid, + Spinner, + Tag, + Trash, +} from "@medusajs/icons" +import type { PriceList, Product } from "@medusajs/medusa" +import { + Checkbox, + CommandBar, + Container, + DropdownMenu, + Heading, + IconButton, + Input, + Table, + Text, + clx, + usePrompt, +} from "@medusajs/ui" +import { + PaginationState, + RowSelectionState, + createColumnHelper, + flexRender, + getCoreRowModel, + getPaginationRowModel, + useReactTable, + type Row, +} from "@tanstack/react-table" +import { + useAdminDeletePriceListProductPrices, + useAdminDeletePriceListProductsPrices, + useAdminPriceListProducts, +} from "medusa-react" +import * as React from "react" + +import { useTranslation } from "react-i18next" +import { useNavigate, useSearchParams } from "react-router-dom" +import { useDebouncedSearchParam } from "../../../../hooks/use-debounced-search-param" +import useNotification from "../../../../hooks/use-notification" +import { getErrorMessage } from "../../../../utils/error-messages" +import { + getDateComparisonOperatorFromSearchParams, + getStringFromSearchParams, +} from "../../../../utils/search-param-utils" +import { ProductFilter, ProductFilterMenu } from "../../components" +import { AddProductsModal } from "./add-products-modal" +import { EditPricesModal } from "./edit-prices-modal" + +type PriceListPricesSectionProps = { + priceList: PriceList +} + +const PAGE_SIZE = 10 +const TABLE_HEIGHT = (PAGE_SIZE + 1) * 48 + +const PriceListPricesSection = ({ priceList }: PriceListPricesSectionProps) => { + const [searchParams] = useSearchParams() + const navigate = useNavigate() + + const { t } = useTranslation() + + const [showAddProductsModal, setShowAddProductsModal] = React.useState(false) + const [showEditPricesModal, setShowEditPricesModal] = React.useState(false) + + const [productIdsToEdit, setProductIdsToEdit] = React.useState< + string[] | null + >(null) + + /** + * Table state. + */ + const [rowSelection, setRowSelection] = React.useState({}) + const [pagination, setPagination] = React.useState({ + pageIndex: 0, + pageSize: PAGE_SIZE, + }) + + /** + * Calculate the offset based on the pagination state. + */ + const offset = React.useMemo( + () => pagination.pageIndex * pagination.pageSize, + [pagination.pageIndex, pagination.pageSize] + ) + + const { query, setQuery } = useDebouncedSearchParam() + + const onFiltersChange = (filters: ProductFilter) => { + const current = new URLSearchParams(searchParams) + + if (filters.created_at) { + current.set("created_at", JSON.stringify(filters.created_at)) + } else { + current.delete("created_at") + } + + if (filters.updated_at) { + current.set("updated_at", JSON.stringify(filters.updated_at)) + } else { + current.delete("updated_at") + } + + navigate({ search: current.toString() }, { replace: true }) + } + + const onClearFilters = () => { + const current = new URLSearchParams(searchParams) + + current.delete("created_at") + current.delete("updated_at") + + navigate({ search: current.toString() }, { replace: true }) + } + + const prompt = usePrompt() + const notification = useNotification() + + const { mutateAsync, isLoading: isDeletingProductPrices } = + useAdminDeletePriceListProductsPrices(priceList.id) + + const handleDeleteProductPrices = async () => { + const res = await prompt({ + title: t("price-list-prices-section-prompt-title", "Are you sure?"), + description: t( + "price-list-prices-section-prompt-description", + "This will permanently delete the product prices from the list" + ), + }) + + if (!res) { + return + } + + await mutateAsync( + { + product_ids: Object.keys(rowSelection), + }, + { + onSuccess: () => { + notification( + t( + "price-list-prices-secton-delete-success-title", + "Prices deleted" + ), + t( + "price-list-prices-section-delete-success-description", + `Successfully deleted prices for {{count}} products`, + { + count: Object.keys(rowSelection).length, + } + ), + "success" + ) + setRowSelection({}) + }, + onError: (err) => { + notification( + t( + "price-list-prices-section-delete-error-title", + "An error occurred" + ), + getErrorMessage(err), + "error" + ) + }, + } + ) + } + + const { products, count, isLoading, isError } = useAdminPriceListProducts( + priceList.id, + { + limit: PAGE_SIZE, + offset, + expand: "variants,collection", + created_at: getDateComparisonOperatorFromSearchParams( + "created_at", + searchParams + ), + updated_at: getDateComparisonOperatorFromSearchParams( + "updated_at", + searchParams + ), + q: getStringFromSearchParams("q", searchParams), + }, + { + keepPreviousData: true, + } + ) + + const { products: allProducts } = useAdminPriceListProducts( + priceList.id, + { + limit: count, + fields: "id", + }, + { + enabled: !!count, + } + ) + + const onEditPricesModalOpenChange = React.useCallback((open: boolean) => { + switch (open) { + case true: + setShowEditPricesModal(true) + break + case false: + setShowEditPricesModal(false) + setProductIdsToEdit(null) + setRowSelection({}) + break + } + }, []) + + const onEditAllProductPrices = React.useCallback(() => { + setProductIdsToEdit(allProducts?.map((p) => p.id) as string[]) + setShowEditPricesModal(true) + }, [allProducts]) + + const onEditSelectedProductPrices = React.useCallback(() => { + setProductIdsToEdit(Object.keys(rowSelection)) + setShowEditPricesModal(true) + }, [rowSelection]) + + const onEditSingleProductPrices = (id: string) => { + setProductIdsToEdit([id]) + setShowEditPricesModal(true) + } + + const pageCount = React.useMemo(() => { + return count ? Math.ceil(count / PAGE_SIZE) : 0 + }, [count]) + + const { columns } = usePriceListProudctColumns({ + onEditProductPrices: onEditSingleProductPrices, + }) + + const table = useReactTable({ + columns, + data: (products as Product[] | undefined) ?? [], + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getRowId: (row) => row.id, + state: { + rowSelection, + pagination, + }, + meta: { + priceListId: priceList.id, + }, + pageCount, + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + manualPagination: true, + onPaginationChange: setPagination, + }) + + return ( + +
+ {t("price-list-prices-section-heading", "Prices")} +
+ + setQuery(e.target.value)} + /> + + + + + + + + + + + {t( + "price-list-prices-section-prices-menu-edit", + "Edit prices" + )} + + + setShowAddProductsModal(true)}> + + + {t( + "price-list-prices-section-prices-menu-add", + "Add products" + )} + + + + +
+
+
+ {isLoading && ( +
+ +
+ )} + {isError && ( +
+ + + {t( + "price-list-prices-section-table-load-error", + "An error occured while fetching the products. Try to reload the page, or if the issue persists, try again later." + )} + +
+ )} + + + {table.getHeaderGroups().map((headerGroup) => { + return ( + + {headerGroup.headers.map((header) => { + return ( + + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ) + })} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} + +
+
+ + p.id) as string[]) ?? []} + priceList={priceList} + open={showAddProductsModal} + onOpenChange={setShowAddProductsModal} + /> + {productIdsToEdit && ( + + )} + 0}> + + + {t("price-list-prices-section-bar-count", "{{count}} selected", { + count: Object.keys(rowSelection).length, + })} + + + + + + + +
+ ) +} + +const columnHelper = createColumnHelper() + +type UsePriceListProudctColumnsProps = { + onEditProductPrices: (id: string) => void +} + +const usePriceListProudctColumns = ({ + onEditProductPrices, +}: UsePriceListProudctColumnsProps) => { + const { t } = useTranslation() + + const columns = React.useMemo( + () => [ + columnHelper.display({ + id: "select", + header: ({ table }) => { + return ( + + table.toggleAllPageRowsSelected(!!value) + } + aria-label={ + t( + "price-list-prices-section-select-all-checkbox-label", + "Select all products on the current page" + ) ?? undefined + } + /> + ) + }, + cell: ({ row }) => { + return ( + row.toggleSelected(!!value)} + aria-label={ + t( + "price-list-prices-section-select-checkbox-label", + "Select row" + ) ?? undefined + } + /> + ) + }, + }), + columnHelper.accessor("title", { + header: () => t("price-list-prices-section-table-product", "Product"), + cell: (info) => { + const title = info.getValue() + const thumbnail = info.row.original.thumbnail + + return ( +
+
+ {thumbnail ? ( + { + ) : ( + + )} +
+ + {title} + +
+ ) + }, + }), + columnHelper.accessor("collection", { + header: () => + t("price-list-prices-section-table-collection", "Collection"), + cell: (info) => info.getValue()?.title ?? "-", + }), + columnHelper.accessor("variants", { + header: () => t("price-list-prices-section-table-variants", "Variants"), + cell: (info) => { + const variants = info.getValue() + return variants?.length ?? "-" + }, + }), + columnHelper.display({ + id: "actions", + cell: ({ table, row }) => { + const { priceListId } = table.options.meta as { + priceListId: string | undefined + } + + return ( + + ) + }, + }), + ], + [t, onEditProductPrices] + ) + + return { columns } +} + +type PriceListProductRowActionsProps = { + row: Row + priceListId?: string + onEditProductPrices: (id: string) => void +} + +const PriceListProductRowActions = ({ + row, + priceListId, + onEditProductPrices, +}: PriceListProductRowActionsProps) => { + const { mutateAsync } = useAdminDeletePriceListProductPrices( + priceListId!, + row.original.id + ) + + const prompt = usePrompt() + const notification = useNotification() + + const onDelete = async () => { + const response = await prompt({ + title: "Are you sure?", + description: + "This will permanently delete the product prices from the list", + }) + + if (!response) { + return + } + + return mutateAsync(undefined, { + onSuccess: ({ deleted }) => { + if (deleted) { + notification( + "Prices deleted", + `Successfully deleted prices for ${row.original.title}`, + "success" + ) + } + + if (!deleted) { + notification( + "Failed to delete prices", + `No prices were deleted for ${row.original.title}`, + "error" + ) + } + }, + onError: (err) => { + notification("An error occurred", getErrorMessage(err), "error") + }, + }) + } + + const onEdit = () => { + onEditProductPrices(row.original.id) + } + + return ( + + + + + + + + + + Edit prices + + + + + Delete prices + + + + ) +} + +export { PriceListPricesSection } diff --git a/packages/admin-ui/ui/src/domain/pricing/forms/price-list-details-form/index.ts b/packages/admin-ui/ui/src/domain/pricing/forms/price-list-details-form/index.ts new file mode 100644 index 0000000000..0095a31f0d --- /dev/null +++ b/packages/admin-ui/ui/src/domain/pricing/forms/price-list-details-form/index.ts @@ -0,0 +1,3 @@ +export * from "./price-list-details-form" +export * from "./schema" +export * from "./types" diff --git a/packages/admin-ui/ui/src/domain/pricing/forms/price-list-details-form/price-list-details-form.tsx b/packages/admin-ui/ui/src/domain/pricing/forms/price-list-details-form/price-list-details-form.tsx new file mode 100644 index 0000000000..d8133a4cc4 --- /dev/null +++ b/packages/admin-ui/ui/src/domain/pricing/forms/price-list-details-form/price-list-details-form.tsx @@ -0,0 +1,750 @@ +import { ExclamationCircle, Spinner } from "@medusajs/icons" +import type { CustomerGroup } from "@medusajs/medusa" +import { + Checkbox, + Container, + DatePicker, + Heading, + Input, + RadioGroup, + Switch, + Table, + Text, + Textarea, + clx, +} from "@medusajs/ui" +import * as Collapsible from "@radix-ui/react-collapsible" +import { + createColumnHelper, + flexRender, + getCoreRowModel, + getPaginationRowModel, + useReactTable, + type PaginationState, + type RowSelectionState, +} from "@tanstack/react-table" +import { useAdminCustomerGroups } from "medusa-react" +import * as React from "react" + +import { DateComparisonOperator } from "@medusajs/types" +import { useTranslation } from "react-i18next" +import { Form } from "../../../../components/helpers/form" +import { FilterMenu } from "../../../../components/molecules/filter-menu" +import { useDebounce } from "../../../../hooks/use-debounce" +import { type NestedForm } from "../../../../utils/nested-form" +import { PriceListDetailsSchema } from "./types" + +interface PriceListDetailsFormProps { + form: NestedForm + layout: "drawer" | "focus" + enableTaxToggle?: boolean +} + +const PriceListDetailsForm = ({ + form, + layout, + enableTaxToggle, +}: PriceListDetailsFormProps) => { + return ( +
+ + + + +
+ ) +} + +/** Type */ + +const PriceListType = ({ form, layout }: PriceListDetailsFormProps) => { + const { t } = useTranslation() + + return ( +
+
+ + {t("price-list-details-form-type-heading", "Type")} + + + {t( + "price-list-details-form-type-description", + "Choose the type of price list you want to create." + )} + +
+ { + return ( + + + + + + + + + + ) + }} + /> +
+ ) +} + +/** General */ + +const PriceListGeneral = ({ + form, + layout, + enableTaxToggle, +}: PriceListDetailsFormProps) => { + const { t } = useTranslation() + + return ( +
+
+ + {t("price-list-details-form-general-heading", "General")} + + + {t( + "price-list-details-form-general-description", + "Choose a title and description for the price list." + )} + +
+
+ { + return ( + + + {t("price-list-details-form-general-name-label", "Name")} + + + + + + + ) + }} + /> +
+ { + return ( + + + {t( + "price-list-details-form-general-description-label", + "Description" + )} + + +