diff --git a/packages/admin-next/dashboard/public/locales/en-US/translation.json b/packages/admin-next/dashboard/public/locales/en-US/translation.json index 2471a5cb64b3..ed5d2824e3c1 100644 --- a/packages/admin-next/dashboard/public/locales/en-US/translation.json +++ b/packages/admin-next/dashboard/public/locales/en-US/translation.json @@ -69,7 +69,10 @@ "remove": "Remove", "revoke": "Revoke", "cancel": "Cancel", + "enable": "Enable", + "disable": "Disable", "complete": "Complete", + "viewDetails": "View details", "back": "Back", "close": "Close", "continue": "Continue", @@ -154,6 +157,9 @@ "required": "New owner is required." } }, + "sales_channels": { + "availableIn": "Available in <0>{{x}} of <1>{{y}} sales channels" + }, "products": { "domain": "Products", "createProductTitle": "Create Product", @@ -184,7 +190,6 @@ "noMediaLabel": "The product has no associated media." }, "discountableHint": "When unchecked discounts will not be applied to this product.", - "availableInSalesChannels": "Available in <0>{{x}} of <1>{{y}} sales channels", "noSalesChannels": "Not available in any sales channels", "variantCount_one": "{{count}} variant", "variantCount_other": "{{count}} variants", @@ -623,15 +628,23 @@ } }, "shipping": { - "title": "Shipping & Delivery", - "domain": "Shipping & Delivery", + "title": "Location & Shipping", + "domain": "Location & Shipping", "description": "Choose where you ship and how much you charge for shipping at checkout. Define shipping options specific for your locations.", + "createLocation": "Create location", + "createLocationDetailsHint": "Specify the details of the location.", + "deleteLocation": "Delete location", "from": "Shipping from", "add": "Add shipping", "connectProvider": "Connect provider", "addZone": "Add shipping zone", "enablePickup": "Enable pickup", "enableDelivery": "Enable delivery", + "deleteLocation": { + "label": "Delete Location", + "confirm": "Are you sure you want to delete {{name}} location", + "success": "{{name}} location successfully deleted" + }, "noRecords": { "action": "Add Location", "title": "No inventory locations", @@ -645,18 +658,25 @@ }, "fulfillmentSet": { "placeholder": "Not covered by any shipping zones.", + "salesChannels": "Connected Sales Channels", "delete": "Delete shipping", + "disableWarning": "Are you sure that you wnat to disable \"{{name}}\"? This will delete all assocciated service zones and shipping options.", "create": { "title": "Add service zone for {{fulfillmentSet}}" }, + "toast": { + "disable": "\"{{name}}\" disabled" + }, "addZone": "Add service zone", "pickup": { - "title": "Pickup in", - "enable": "Enable pickup" + "title": "Pick up", + "enable": "Enable pickup", + "offers": "Offers pick up in" }, "delivery": { - "title": "Shipping to", - "enable": "Enable delivery" + "title": "Shipping", + "enable": "Enable delivery", + "offers": "Offers shippping to" } }, "serviceZone": { @@ -666,12 +686,21 @@ "description": "A service zone is a geographical region that can be shipped to from a specific location. You can later on add any number of shipping options to this zone. ", "zoneName": "Zone name" }, + "edit":{ + "title": "Edit Service Zone" + }, + "deleteWarning": "Are you sure you want to delete \"{{name}}\". This will also delete all assocciated shipping options.", + "toast": { + "delete": "Zone \"{{name}}\" deleted successfully." + }, "editPrices": "Edit prices", "editOption": "Edit option", "optionsLength_one": "shipping option", "optionsLength_other": "shipping options", + "returnOptionsLength_one": "return option", + "returnOptionsLength_other": "return options", "shippingOptionsPlaceholder": "Not covered by any shipping options.", - "addShippingOptions": "Add shipping options", + "addOption": "Add option", "shippingOptions": "Shipping options", "returnOptions": "Return options", "areas": { @@ -683,7 +712,7 @@ }, "shippingOptions": { "create": { - "title": "Create shipping options for {{zone}}", + "title": "Create a shipping option for {{zone}}", "subtitle": "General information", "description": "To start selling, all you need is a name and a price", "details": "Details", @@ -696,7 +725,26 @@ "calculated": "Calculated", "calculatedDescription": "Shipping option's price is calculated by the fulfillment provider.", "profile": "Shipping profile" + }, + "deleteWarning": "Are you sure you want to delete \"{{name}}\"?", + "toast": { + "delete": "Shipping option \"{{name}}\" deleted successfully." + }, + "inStore": "Store", + "edit": { + "title": "Edit Shipping Option", + "provider": "Fulfillment provider" + } + }, + "returnOptions" : { + "create": { + "title": "Create a return option for {{zone}}" } + }, + "salesChannels": { + "title": "Connected Sales Channels", + "placeholder": "No connected channels yet.", + "connectChannels": "Connect Channels" } }, "shippingProfile": { @@ -1195,10 +1243,8 @@ }, "locations": { "domain": "Locations", - "createLocation": "Create location", "editLocation": "Edit location", "addSalesChannels": "Add sales channels", - "detailsHint": "Specify the details of the location.", "noLocationsFound": "No locations found", "selectLocations": "Select locations that stock the item.", "deleteLocationWarning": "You are about to delete the location {{name}}. This action cannot be undone.", @@ -1388,6 +1434,7 @@ "scheduled": "Scheduled", "expired": "Expired", "active": "Active", + "enabled": "Enabled", "disabled": "Disabled" }, "fields": { diff --git a/packages/admin-next/dashboard/src/components/layout/main-layout/main-layout.tsx b/packages/admin-next/dashboard/src/components/layout/main-layout/main-layout.tsx index 6f52f7528328..2eabd55802f4 100644 --- a/packages/admin-next/dashboard/src/components/layout/main-layout/main-layout.tsx +++ b/packages/admin-next/dashboard/src/components/layout/main-layout/main-layout.tsx @@ -144,11 +144,6 @@ const useCoreRoutes = (): Omit[] => { label: t("pricing.domain"), to: "/pricing", }, - { - icon: , - label: t("shipping.domain"), - to: "/shipping", - }, ] } diff --git a/packages/admin-next/dashboard/src/components/layout/nav-item/nav-item.tsx b/packages/admin-next/dashboard/src/components/layout/nav-item/nav-item.tsx index 16b4e6e5f08d..ff48bafdeefb 100644 --- a/packages/admin-next/dashboard/src/components/layout/nav-item/nav-item.tsx +++ b/packages/admin-next/dashboard/src/components/layout/nav-item/nav-item.tsx @@ -58,7 +58,8 @@ export const NavItem = ({ "text-ui-fg-subtle hover:text-ui-fg-base transition-fg hover:bg-ui-bg-subtle-hover flex items-center gap-x-2 rounded-md px-2 py-2.5 outline-none md:py-1.5", { "bg-ui-bg-base hover:bg-ui-bg-base-hover shadow-elevation-card-rest": - location.pathname.startsWith(to), + location.pathname === to || + location.pathname.startsWith(to + "/"), // TODO: utilise `NavLink` and `end` prop instead of this manual check "max-md:hidden": items && items.length > 0, } )} diff --git a/packages/admin-next/dashboard/src/components/layout/settings-layout/settings-layout.tsx b/packages/admin-next/dashboard/src/components/layout/settings-layout/settings-layout.tsx index 8fe858e5e73a..b8dc416c1f8c 100644 --- a/packages/admin-next/dashboard/src/components/layout/settings-layout/settings-layout.tsx +++ b/packages/admin-next/dashboard/src/components/layout/settings-layout/settings-layout.tsx @@ -57,6 +57,10 @@ const useSettingRoutes = (): NavItemProps[] => { label: t("shippingProfile.domain"), to: "/settings/shipping-profiles", }, + { + label: t("shipping.domain"), + to: "/settings/shipping", + }, ], [t] ) diff --git a/packages/admin-next/dashboard/src/hooks/api/fulfillment-providers.tsx b/packages/admin-next/dashboard/src/hooks/api/fulfillment-providers.tsx new file mode 100644 index 000000000000..4cb2060595ec --- /dev/null +++ b/packages/admin-next/dashboard/src/hooks/api/fulfillment-providers.tsx @@ -0,0 +1,24 @@ +import { QueryKey, useQuery, UseQueryOptions } from "@tanstack/react-query" +import { client } from "../../lib/client" +import { queryKeysFactory } from "../../lib/query-key-factory" + +const FULFILLMENT_PROVIDERS_QUERY_KEY = "f_providers" as const +export const fulfillmentProvidersQueryKeys = queryKeysFactory( + FULFILLMENT_PROVIDERS_QUERY_KEY +) + +export const useFulfillmentProviders = ( + query?: Record, + options?: Omit< + UseQueryOptions, + "queryFn" | "queryKey" + > +) => { + const { data, ...rest } = useQuery({ + queryFn: () => client.fulfillmentProviders.list(query), + queryKey: fulfillmentProvidersQueryKeys.list(query), + ...options, + }) + + return { ...data, ...rest } +} diff --git a/packages/admin-next/dashboard/src/hooks/api/shipping-options.ts b/packages/admin-next/dashboard/src/hooks/api/shipping-options.ts index 31639b95e87b..67c29267c8e9 100644 --- a/packages/admin-next/dashboard/src/hooks/api/shipping-options.ts +++ b/packages/admin-next/dashboard/src/hooks/api/shipping-options.ts @@ -1,14 +1,39 @@ -import { useMutation, UseMutationOptions } from "@tanstack/react-query" +import { + QueryKey, + useMutation, + UseMutationOptions, + useQuery, + UseQueryOptions, +} from "@tanstack/react-query" import { ShippingOptionDeleteRes, ShippingOptionRes, } from "../../types/api-responses" -import { CreateShippingOptionReq } from "../../types/api-payloads" +import { + CreateShippingOptionReq, + UpdateShippingOptionReq, +} from "../../types/api-payloads" import { stockLocationsQueryKeys } from "./stock-locations" import { queryClient } from "../../lib/medusa" import { client } from "../../lib/client" +export const useShippingOptions = ( + query?: Record, + options?: Omit< + UseQueryOptions, + "queryFn" | "queryKey" + > +) => { + const { data, ...rest } = useQuery({ + queryFn: () => client.shippingOptions.list(query), + queryKey: stockLocationsQueryKeys.all, + ...options, + }) + + return { ...data, ...rest } +} + export const useCreateShippingOptions = ( options?: UseMutationOptions< ShippingOptionRes, @@ -20,9 +45,28 @@ export const useCreateShippingOptions = ( mutationFn: (payload) => client.shippingOptions.create(payload), onSuccess: (data, variables, context) => { queryClient.invalidateQueries({ - queryKey: stockLocationsQueryKeys.lists(), + queryKey: stockLocationsQueryKeys.all, }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} +export const useUpdateShippingOptions = ( + id: string, + options?: UseMutationOptions< + ShippingOptionRes, + Error, + UpdateShippingOptionReq + > +) => { + return useMutation({ + mutationFn: (payload) => client.shippingOptions.update(id, payload), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ + queryKey: stockLocationsQueryKeys.all, + }) options?.onSuccess?.(data, variables, context) }, ...options, @@ -37,7 +81,7 @@ export const useDeleteShippingOption = ( mutationFn: () => client.shippingOptions.delete(optionId), onSuccess: (data: any, variables: any, context: any) => { queryClient.invalidateQueries({ - queryKey: stockLocationsQueryKeys.lists(), + queryKey: stockLocationsQueryKeys.all, }) options?.onSuccess?.(data, variables, context) diff --git a/packages/admin-next/dashboard/src/hooks/api/stock-locations.tsx b/packages/admin-next/dashboard/src/hooks/api/stock-locations.tsx index 177923ef282b..555f94b40b27 100644 --- a/packages/admin-next/dashboard/src/hooks/api/stock-locations.tsx +++ b/packages/admin-next/dashboard/src/hooks/api/stock-locations.tsx @@ -13,7 +13,9 @@ import { CreateFulfillmentSetReq, CreateServiceZoneReq, CreateStockLocationReq, + UpdateServiceZoneReq, UpdateStockLocationReq, + UpdateStockLocationSalesChannelsReq, } from "../../types/api-payloads" import { FulfillmentSetDeleteRes, @@ -22,6 +24,7 @@ import { StockLocationListRes, StockLocationRes, } from "../../types/api-responses" +import { salesChannelsQueryKeys } from "./sales-channels" const STOCK_LOCATIONS_QUERY_KEY = "stock_locations" as const export const stockLocationsQueryKeys = queryKeysFactory( @@ -38,7 +41,7 @@ export const useStockLocation = ( ) => { const { data, ...rest } = useQuery({ queryFn: () => client.stockLocations.retrieve(id, query), - queryKey: stockLocationsQueryKeys.detail(id), + queryKey: stockLocationsQueryKeys.detail(id, query), ...options, }) @@ -90,7 +93,7 @@ export const useUpdateStockLocation = ( mutationFn: (payload) => client.stockLocations.update(id, payload), onSuccess: (data, variables, context) => { queryClient.invalidateQueries({ - queryKey: stockLocationsQueryKeys.detail(id), + queryKey: stockLocationsQueryKeys.details(), }) queryClient.invalidateQueries({ queryKey: stockLocationsQueryKeys.lists(), @@ -102,6 +105,30 @@ export const useUpdateStockLocation = ( }) } +export const useUpdateStockLocationSalesChannels = ( + id: string, + options?: UseMutationOptions< + StockLocationRes, + Error, + UpdateStockLocationSalesChannelsReq + > +) => { + return useMutation({ + mutationFn: (payload) => + client.stockLocations.updateSalesChannels(id, payload), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ + queryKey: stockLocationsQueryKeys.details(), + }) + queryClient.invalidateQueries({ + queryKey: stockLocationsQueryKeys.lists(), + }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + export const useDeleteStockLocation = ( id: string, options?: UseMutationOptions @@ -134,7 +161,7 @@ export const useCreateFulfillmentSet = ( queryKey: stockLocationsQueryKeys.lists(), }) queryClient.invalidateQueries({ - queryKey: stockLocationsQueryKeys.detail(locationId), + queryKey: stockLocationsQueryKeys.details(), }) options?.onSuccess?.(data, variables, context) }, @@ -152,7 +179,33 @@ export const useCreateServiceZone = ( client.stockLocations.createServiceZone(fulfillmentSetId, payload), onSuccess: (data, variables, context) => { queryClient.invalidateQueries({ - queryKey: stockLocationsQueryKeys.detail(locationId), + queryKey: stockLocationsQueryKeys.details(), + }) + queryClient.invalidateQueries({ + queryKey: stockLocationsQueryKeys.lists(), + }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useUpdateServiceZone = ( + fulfillmentSetId: string, + serviceZoneId: string, + locationId: string, + options?: UseMutationOptions +) => { + return useMutation({ + mutationFn: (payload) => + client.stockLocations.updateServiceZone( + fulfillmentSetId, + serviceZoneId, + payload + ), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ + queryKey: stockLocationsQueryKeys.details(), }) queryClient.invalidateQueries({ queryKey: stockLocationsQueryKeys.lists(), @@ -173,6 +226,9 @@ export const useDeleteFulfillmentSet = ( queryClient.invalidateQueries({ queryKey: stockLocationsQueryKeys.lists(), }) + queryClient.invalidateQueries({ + queryKey: stockLocationsQueryKeys.details(), + }) options?.onSuccess?.(data, variables, context) }, @@ -191,6 +247,9 @@ export const useDeleteServiceZone = ( queryClient.invalidateQueries({ queryKey: stockLocationsQueryKeys.lists(), }) + queryClient.invalidateQueries({ + queryKey: stockLocationsQueryKeys.details(), + }) options?.onSuccess?.(data, variables, context) }, diff --git a/packages/admin-next/dashboard/src/hooks/api/store.tsx b/packages/admin-next/dashboard/src/hooks/api/store.tsx index 973ac0a3ee37..db703065da18 100644 --- a/packages/admin-next/dashboard/src/hooks/api/store.tsx +++ b/packages/admin-next/dashboard/src/hooks/api/store.tsx @@ -6,11 +6,11 @@ import { useQuery, } from "@tanstack/react-query" -import { queryKeysFactory } from "medusa-react" import { client } from "../../lib/client" import { queryClient } from "../../lib/medusa" import { UpdateStoreReq } from "../../types/api-payloads" import { StoreRes } from "../../types/api-responses" +import { queryKeysFactory } from "../../lib/query-key-factory" const STORE_QUERY_KEY = "store" as const const storeQueryKeys = queryKeysFactory(STORE_QUERY_KEY) diff --git a/packages/admin-next/dashboard/src/lib/client/client.ts b/packages/admin-next/dashboard/src/lib/client/client.ts index ed6cbda3d61c..19d2cd7c8a83 100644 --- a/packages/admin-next/dashboard/src/lib/client/client.ts +++ b/packages/admin-next/dashboard/src/lib/client/client.ts @@ -14,6 +14,7 @@ import { productTypes } from "./product-types" import { products } from "./products" import { promotions } from "./promotions" import { regions } from "./regions" +import { fulfillmentProviders } from "./fulfillment-providers" import { reservations } from "./reservations" import { salesChannels } from "./sales-channels" import { shippingOptions } from "./shipping-options" @@ -47,6 +48,7 @@ export const client = { invites: invites, inventoryItems: inventoryItems, reservations: reservations, + fulfillmentProviders: fulfillmentProviders, products: products, productTypes: productTypes, priceLists: priceLists, diff --git a/packages/admin-next/dashboard/src/lib/client/fulfillment-providers.ts b/packages/admin-next/dashboard/src/lib/client/fulfillment-providers.ts new file mode 100644 index 000000000000..8853a44d24ca --- /dev/null +++ b/packages/admin-next/dashboard/src/lib/client/fulfillment-providers.ts @@ -0,0 +1,13 @@ +import { getRequest } from "./common" +import { FulfillmentProvidersListRes } from "../../types/api-responses" + +async function listFulfillmentProviders(query?: Record) { + return getRequest( + `/admin/fulfillment-providers`, + query + ) +} + +export const fulfillmentProviders = { + list: listFulfillmentProviders, +} diff --git a/packages/admin-next/dashboard/src/lib/client/shipping-options.ts b/packages/admin-next/dashboard/src/lib/client/shipping-options.ts index 401d8a50166b..75cbbac1aa21 100644 --- a/packages/admin-next/dashboard/src/lib/client/shipping-options.ts +++ b/packages/admin-next/dashboard/src/lib/client/shipping-options.ts @@ -1,14 +1,32 @@ -import { deleteRequest, postRequest } from "./common" +import { deleteRequest, getRequest, postRequest } from "./common" import { ShippingOptionDeleteRes, + ShippingOptionListRes, ShippingOptionRes, } from "../../types/api-responses" -import { CreateShippingOptionReq } from "../../types/api-payloads" +import { + CreateShippingOptionReq, + UpdateShippingOptionReq, +} from "../../types/api-payloads" async function createShippingOptions(payload: CreateShippingOptionReq) { return postRequest(`/admin/shipping-options`, payload) } +async function updateShippingOptions( + id: string, + payload: UpdateShippingOptionReq +) { + return postRequest( + `/admin/shipping-options/${id}`, + payload + ) +} + +async function listShippingOptions(query?: Record) { + return getRequest(`/admin/shipping-options`, query) +} + async function deleteShippingOption(optionId: string) { return deleteRequest( `/admin/shipping-options/${optionId}` @@ -17,5 +35,7 @@ async function deleteShippingOption(optionId: string) { export const shippingOptions = { create: createShippingOptions, + update: updateShippingOptions, delete: deleteShippingOption, + list: listShippingOptions, } diff --git a/packages/admin-next/dashboard/src/lib/client/stock-locations.ts b/packages/admin-next/dashboard/src/lib/client/stock-locations.ts index 818cc1e6f9bb..dc57dd9f2a4a 100644 --- a/packages/admin-next/dashboard/src/lib/client/stock-locations.ts +++ b/packages/admin-next/dashboard/src/lib/client/stock-locations.ts @@ -2,7 +2,9 @@ import { CreateFulfillmentSetReq, CreateServiceZoneReq, CreateStockLocationReq, + UpdateServiceZoneReq, UpdateStockLocationReq, + UpdateStockLocationSalesChannelsReq, } from "../../types/api-payloads" import { FulfillmentSetDeleteRes, @@ -45,6 +47,17 @@ async function createServiceZone( ) } +async function updateServiceZone( + fulfillmentSetId: string, + serviceZoneId: string, + payload: UpdateServiceZoneReq +) { + return postRequest( + `/admin/fulfillment-sets/${fulfillmentSetId}/service-zones/${serviceZoneId}`, + payload + ) +} + async function updateStockLocation( id: string, payload: UpdateStockLocationReq @@ -52,6 +65,16 @@ async function updateStockLocation( return postRequest(`/admin/stock-locations/${id}`, payload) } +async function updateStockLocationSalesChannels( + id: string, + payload: UpdateStockLocationSalesChannelsReq +) { + return postRequest( + `/admin/stock-locations/${id}/sales-channels`, + payload + ) +} + async function deleteStockLocation(id: string) { return deleteRequest(`/admin/stock-locations/${id}`) } @@ -74,8 +97,10 @@ export const stockLocations = { create: createStockLocation, update: updateStockLocation, delete: deleteStockLocation, + updateSalesChannels: updateStockLocationSalesChannels, createFulfillmentSet, deleteFulfillmentSet, createServiceZone, deleteServiceZone, + updateServiceZone, } diff --git a/packages/admin-next/dashboard/src/lib/shipping-options.ts b/packages/admin-next/dashboard/src/lib/shipping-options.ts new file mode 100644 index 000000000000..6aaf4fb91a5e --- /dev/null +++ b/packages/admin-next/dashboard/src/lib/shipping-options.ts @@ -0,0 +1,17 @@ +import { ShippingOptionDTO } from "@medusajs/types" + +export function isReturnOption(shippingOption: ShippingOptionDTO) { + return !!shippingOption.rules?.find( + (r) => + r.attribute === "is_return" && r.value === "true" && r.operator === "eq" + ) +} + +export function isOptionEnabledInStore(shippingOption: ShippingOptionDTO) { + return !!shippingOption.rules?.find( + (r) => + r.attribute === "enabled_in_store" && + r.value === "true" && + r.operator === "eq" + ) +} diff --git a/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx b/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx index b3d85d358f99..919a2cd83d5a 100644 --- a/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx +++ b/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx @@ -10,7 +10,6 @@ import { import { AdminCollectionsRes, AdminProductsRes, - AdminPromotionRes, AdminRegionsRes, } from "@medusajs/medusa" import { InventoryItemRes, PriceListRes } from "../../types/api-responses" @@ -297,44 +296,6 @@ export const v2Routes: RouteObject[] = [ }, ], }, - { - path: "shipping", - lazy: () => import("../../v2-routes/shipping/locations-list"), - handle: { - crumb: () => "Shipping", - }, - children: [ - { - path: "location/:location_id", - children: [ - { - path: "fulfillment-set/:fset_id", - children: [ - { - path: "service-zones/create", - lazy: () => - import( - "../../v2-routes/shipping/service-zone-create" - ), - }, - { - path: "service-zone/:zone_id", - children: [ - { - path: "shipping-options/create", - lazy: () => - import( - "../../v2-routes/shipping/shipping-options-create" - ), - }, - ], - }, - ], - }, - ], - }, - ], - }, { path: "/customers", handle: { @@ -642,37 +603,47 @@ export const v2Routes: RouteObject[] = [ ], }, { - path: "locations", + path: "sales-channels", element: , handle: { - crumb: () => "Locations", + crumb: () => "Sales Channels", }, children: [ { path: "", - lazy: () => import("../../v2-routes/locations/location-list"), + lazy: () => + import("../../v2-routes/sales-channels/sales-channel-list"), children: [ { path: "create", lazy: () => - import("../../v2-routes/locations/location-create"), + import( + "../../v2-routes/sales-channels/sales-channel-create" + ), }, ], }, { path: ":id", - lazy: () => import("../../v2-routes/locations/location-detail"), + lazy: () => + import("../../v2-routes/sales-channels/sales-channel-detail"), + handle: { + crumb: (data: { sales_channel: SalesChannelDTO }) => + data.sales_channel.name, + }, children: [ { path: "edit", lazy: () => - import("../../v2-routes/locations/location-edit"), + import( + "../../v2-routes/sales-channels/sales-channel-edit" + ), }, { - path: "add-sales-channels", + path: "add-products", lazy: () => import( - "../../v2-routes/locations/location-add-sales-channels" + "../../v2-routes/sales-channels/sales-channel-add-products" ), }, ], @@ -680,49 +651,84 @@ export const v2Routes: RouteObject[] = [ ], }, { - path: "sales-channels", + path: "shipping", element: , handle: { - crumb: () => "Sales Channels", + crumb: () => "Location & Shipping", }, children: [ { path: "", - lazy: () => - import("../../v2-routes/sales-channels/sales-channel-list"), - children: [ - { - path: "create", - lazy: () => - import( - "../../v2-routes/sales-channels/sales-channel-create" - ), - }, - ], + lazy: () => import("../../v2-routes/shipping/location-list"), }, { - path: ":id", - lazy: () => - import("../../v2-routes/sales-channels/sales-channel-detail"), - handle: { - crumb: (data: { sales_channel: SalesChannelDTO }) => - data.sales_channel.name, - }, + path: "create", + lazy: () => import("../../v2-routes/shipping/location-create"), + }, + { + path: ":location_id", + lazy: () => import("../../v2-routes/shipping/location-details"), children: [ { path: "edit", lazy: () => - import( - "../../v2-routes/sales-channels/sales-channel-edit" - ), + import("../../v2-routes/shipping/location-edit"), }, { - path: "add-products", + path: "sales-channels/edit", lazy: () => import( - "../../v2-routes/sales-channels/sales-channel-add-products" + "../../v2-routes/shipping/location-add-sales-channels" ), }, + { + path: "fulfillment-set/:fset_id", + children: [ + { + path: "service-zones/create", + lazy: () => + import( + "../../v2-routes/shipping/service-zone-create" + ), + }, + { + path: "service-zone/:zone_id", + children: [ + { + path: "edit", + lazy: () => + import( + "../../v2-routes/shipping/service-zone-edit" + ), + }, + { + path: "shipping-option", + children: [ + { + path: "create", + lazy: () => + import( + "../../v2-routes/shipping/shipping-options-create" + ), + }, + { + path: ":so_id", + children: [ + { + path: "edit", + lazy: () => + import( + "../../v2-routes/shipping/shipping-option-edit" + ), + }, + ], + }, + ], + }, + ], + }, + ], + }, ], }, ], diff --git a/packages/admin-next/dashboard/src/types/api-payloads.ts b/packages/admin-next/dashboard/src/types/api-payloads.ts index 590374a26ed5..b7a8f5953bcc 100644 --- a/packages/admin-next/dashboard/src/types/api-payloads.ts +++ b/packages/admin-next/dashboard/src/types/api-payloads.ts @@ -19,6 +19,7 @@ import { CreateShippingProfileDTO, CreateStockLocationInput, InventoryNext, + ShippingOptionDTO, UpdateApiKeyDTO, UpdateCampaignDTO, UpdateCustomerDTO, @@ -28,6 +29,8 @@ import { UpdatePromotionRuleDTO, UpdateRegionDTO, UpdateSalesChannelDTO, + UpdateServiceZoneDTO, + UpdateShippingOptionDTO, UpdateStockLocationInput, UpdateStoreDTO, UpdateUserDTO, @@ -66,11 +69,17 @@ export type CreateInviteReq = CreateInviteDTO // Stock Locations export type CreateStockLocationReq = CreateStockLocationInput export type UpdateStockLocationReq = UpdateStockLocationInput +export type UpdateStockLocationSalesChannelsReq = { + add: string[] + remove: string[] +} export type CreateFulfillmentSetReq = CreateFulfillmentSetDTO export type CreateServiceZoneReq = CreateServiceZoneDTO +export type UpdateServiceZoneReq = UpdateServiceZoneDTO // Shipping Options export type CreateShippingOptionReq = CreateShippingOptionDTO +export type UpdateShippingOptionReq = UpdateShippingOptionDTO // Shipping Profile export type CreateShippingProfileReq = CreateShippingProfileDTO diff --git a/packages/admin-next/dashboard/src/types/api-responses.ts b/packages/admin-next/dashboard/src/types/api-responses.ts index e7237c389259..125da3e91276 100644 --- a/packages/admin-next/dashboard/src/types/api-responses.ts +++ b/packages/admin-next/dashboard/src/types/api-responses.ts @@ -6,6 +6,7 @@ import { CampaignDTO, CurrencyDTO, CustomerGroupDTO, + FulfillmentProviderDTO, InventoryNext, InviteDTO, PaymentProviderDTO, @@ -140,9 +141,17 @@ export type StockLocationDeleteRes = DeleteRes export type FulfillmentSetDeleteRes = DeleteRes export type ServiceZoneDeleteRes = DeleteRes +// Fulfillment providers +export type FulfillmentProvidersListRes = { + fulfillment_providers: FulfillmentProviderDTO +} & ListRes + // Shipping options export type ShippingOptionRes = { shipping_option: ShippingOptionDTO } export type ShippingOptionDeleteRes = DeleteRes +export type ShippingOptionListRes = { + shipping_options: ShippingOptionDTO[] +} & ListRes // Shipping profile export type ShippingProfileRes = { shipping_profile: ShippingProfileDTO } diff --git a/packages/admin-next/dashboard/src/v2-routes/locations/location-add-sales-channels/location-add-sales-channels.tsx b/packages/admin-next/dashboard/src/v2-routes/locations/location-add-sales-channels/location-add-sales-channels.tsx deleted file mode 100644 index 519e724336ca..000000000000 --- a/packages/admin-next/dashboard/src/v2-routes/locations/location-add-sales-channels/location-add-sales-channels.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { RouteFocusModal } from "../../../components/route-modal" - -export const LocationAddSalesChannels = () => { - // We need a batch add sales channels endpoint - - return -} diff --git a/packages/admin-next/dashboard/src/v2-routes/locations/location-detail/components/location-general-section/location-general-section.tsx b/packages/admin-next/dashboard/src/v2-routes/locations/location-detail/components/location-general-section/location-general-section.tsx deleted file mode 100644 index 3a3b4e917a46..000000000000 --- a/packages/admin-next/dashboard/src/v2-routes/locations/location-detail/components/location-general-section/location-general-section.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { PencilSquare } from "@medusajs/icons" -import type { StockLocationAddressDTO } from "@medusajs/types" -import { Container, Heading, Text, clx } from "@medusajs/ui" -import { useTranslation } from "react-i18next" -import { ActionMenu } from "../../../../../components/common/action-menu" -import { ExtendedStockLocationDTO } from "../../../../../types/api-responses" - -type LocationGeneralSectionProps = { - location: ExtendedStockLocationDTO -} - -export const LocationGeneralSection = ({ - location, -}: LocationGeneralSectionProps) => { - const { t } = useTranslation() - - return ( - -
- {location.name} - , - label: t("actions.edit"), - to: `edit`, - }, - ], - }, - ]} - /> -
-
- - {t("fields.address")} - - -
-
- - {t("fields.company")} - - - {location.address?.company || "-"} - -
-
- - {t("fields.phone")} - - - {location.address?.phone || "-"} - -
-
- ) -} - -const AddressDisplay = ({ - address, -}: { - address: StockLocationAddressDTO | undefined -}) => { - if (!address) { - return ( - - - - - ) - } - - const { address_1, address_2, city, province, postal_code, country_code } = - address - - const addressParts = [ - address_1, - address_2, - `${city ? city + " " : ""}${province ? province + " " : ""}${postal_code}`, - country_code.toUpperCase(), - ] - - const addressString = addressParts - .filter((part) => part !== null && part !== undefined && part.trim() !== "") - .join(", ") - - return {addressString} -} diff --git a/packages/admin-next/dashboard/src/v2-routes/locations/location-detail/components/location-sales-channel-section/index.ts b/packages/admin-next/dashboard/src/v2-routes/locations/location-detail/components/location-sales-channel-section/index.ts deleted file mode 100644 index 6c16c11a32a2..000000000000 --- a/packages/admin-next/dashboard/src/v2-routes/locations/location-detail/components/location-sales-channel-section/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./location-sales-channel-section" diff --git a/packages/admin-next/dashboard/src/v2-routes/locations/location-detail/components/location-sales-channel-section/location-sales-channel-section.tsx b/packages/admin-next/dashboard/src/v2-routes/locations/location-detail/components/location-sales-channel-section/location-sales-channel-section.tsx deleted file mode 100644 index dc46cd1308f1..000000000000 --- a/packages/admin-next/dashboard/src/v2-routes/locations/location-detail/components/location-sales-channel-section/location-sales-channel-section.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { PencilSquare, Trash } from "@medusajs/icons" -import { SalesChannelDTO } from "@medusajs/types" -import { Button, Container, Heading, toast, usePrompt } from "@medusajs/ui" -import { createColumnHelper } from "@tanstack/react-table" -import { useAdminRemoveLocationFromSalesChannel } from "medusa-react" -import { useMemo } from "react" -import { useTranslation } from "react-i18next" -import { Link } from "react-router-dom" -import { ActionMenu } from "../../../../../components/common/action-menu" -import { DataTable } from "../../../../../components/table/data-table" -import { useSalesChannelTableColumns } from "../../../../../hooks/table/columns/use-sales-channel-table-columns" -import { useDataTable } from "../../../../../hooks/use-data-table" -import { ExtendedStockLocationDTO } from "../../../../../types/api-responses" - -type LocationSalesChannelSectionProps = { - location: ExtendedStockLocationDTO -} - -const PAGE_SIZE = 10 - -export const LocationSalesChannelSection = ({ - location, -}: LocationSalesChannelSectionProps) => { - const { t } = useTranslation() - - const salesChannels = location.sales_channels - const count = location.sales_channels?.length || 0 - const columns = useColumns() - - const { table } = useDataTable({ - data: salesChannels ?? [], - columns, - count, - pageSize: PAGE_SIZE, - enablePagination: true, - getRowId: (row) => row.id, - }) - - return ( - -
- {t("salesChannels.domain")} - - - -
- row.id} - pagination - /> -
- ) -} - -const SalesChannelActions = ({ - salesChannel, - locationId, -}: { - salesChannel: SalesChannelDTO - locationId: string -}) => { - const { t } = useTranslation() - const prompt = usePrompt() - - const { mutateAsync } = useAdminRemoveLocationFromSalesChannel() - - const handleDelete = async () => { - const res = await prompt({ - title: t("general.areYouSure"), - description: t("locations.removeSalesChannelsWarning", { count: 1 }), - confirmText: t("actions.delete"), - cancelText: t("actions.cancel"), - }) - - if (!res) { - return - } - - try { - await mutateAsync({ - location_id: locationId, - sales_channel_id: salesChannel.id, - }) - - toast.success(t("general.success"), { - description: t("locations.toast.removeChannel"), - dismissLabel: t("actions.close"), - }) - } catch (e) { - toast.error(t("general.error"), { - description: e.message, - dismissLabel: t("actions.close"), - }) - } - } - - return ( - , - label: t("actions.edit"), - to: `/settings/sales-channels/${salesChannel.id}/edit`, - }, - { - icon: , - label: t("actions.delete"), - onClick: handleDelete, - }, - ], - }, - ]} - /> - ) -} - -const columnHelper = createColumnHelper() - -const useColumns = () => { - const base = useSalesChannelTableColumns() - - return useMemo( - () => [ - ...base, - columnHelper.display({ - id: "actions", - cell: ({ row, table }) => { - const { locationId } = table.options.meta as { - locationId: string - } - - return ( - - ) - }, - }), - ], - [base] - ) -} diff --git a/packages/admin-next/dashboard/src/v2-routes/locations/location-detail/index.ts b/packages/admin-next/dashboard/src/v2-routes/locations/location-detail/index.ts deleted file mode 100644 index 086ccf707af3..000000000000 --- a/packages/admin-next/dashboard/src/v2-routes/locations/location-detail/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { LocationDetail as Component } from "./location-detail" diff --git a/packages/admin-next/dashboard/src/v2-routes/locations/location-detail/location-detail.tsx b/packages/admin-next/dashboard/src/v2-routes/locations/location-detail/location-detail.tsx deleted file mode 100644 index 7722b82265db..000000000000 --- a/packages/admin-next/dashboard/src/v2-routes/locations/location-detail/location-detail.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Outlet, useParams } from "react-router-dom" - -import { JsonViewSection } from "../../../components/common/json-view-section" -import { useStockLocation } from "../../../hooks/api/stock-locations" -import { LocationGeneralSection } from "./components/location-general-section" -import { LocationSalesChannelSection } from "./components/location-sales-channel-section" - -export const LocationDetail = () => { - const { id } = useParams() - const { - stock_location, - isPending: isLoading, - isError, - error, - } = useStockLocation(id!, { - fields: "*address,*sales_channels", - }) - - if (isLoading || !stock_location) { - return
Loading...
- } - - if (isError) { - throw error - } - - return ( -
- - - - -
- ) -} diff --git a/packages/admin-next/dashboard/src/v2-routes/locations/location-list/components/locations-list-table/index.ts b/packages/admin-next/dashboard/src/v2-routes/locations/location-list/components/locations-list-table/index.ts deleted file mode 100644 index f1308b3734cf..000000000000 --- a/packages/admin-next/dashboard/src/v2-routes/locations/location-list/components/locations-list-table/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./locations-list-table" diff --git a/packages/admin-next/dashboard/src/v2-routes/locations/location-list/components/locations-list-table/location-row-actions.tsx b/packages/admin-next/dashboard/src/v2-routes/locations/location-list/components/locations-list-table/location-row-actions.tsx deleted file mode 100644 index 5507a545a23f..000000000000 --- a/packages/admin-next/dashboard/src/v2-routes/locations/location-list/components/locations-list-table/location-row-actions.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { PencilSquare, Trash } from "@medusajs/icons" -import { usePrompt } from "@medusajs/ui" -import { useTranslation } from "react-i18next" - -import { ActionMenu } from "../../../../../components/common/action-menu" -import { useDeleteStockLocation } from "../../../../../hooks/api/stock-locations" -import { ExtendedStockLocationDTO } from "../../../../../types/api-responses" - -export const LocationRowActions = ({ - location, -}: { - location: ExtendedStockLocationDTO -}) => { - const { t } = useTranslation() - const prompt = usePrompt() - const { mutateAsync } = useDeleteStockLocation(location.id) - - const handleDelete = async () => { - const res = await prompt({ - title: t("general.areYouSure"), - description: t("locations.deleteLocationWarning", { - name: location.name, - }), - verificationText: location.name, - verificationInstruction: t("general.typeToConfirm"), - confirmText: t("actions.delete"), - cancelText: t("actions.cancel"), - }) - - if (!res) { - return - } - - await mutateAsync() - } - - return ( - , - label: t("actions.edit"), - to: `/settings/locations/${location.id}/edit`, - }, - { - icon: , - label: t("actions.delete"), - onClick: handleDelete, - }, - ], - }, - ]} - /> - ) -} diff --git a/packages/admin-next/dashboard/src/v2-routes/locations/location-list/components/locations-list-table/locations-list-table.tsx b/packages/admin-next/dashboard/src/v2-routes/locations/location-list/components/locations-list-table/locations-list-table.tsx deleted file mode 100644 index 58d89e6b6299..000000000000 --- a/packages/admin-next/dashboard/src/v2-routes/locations/location-list/components/locations-list-table/locations-list-table.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { Button, Container, Heading } from "@medusajs/ui" -import { Link } from "react-router-dom" - -import { useTranslation } from "react-i18next" -import { DataTable } from "../../../../../components/table/data-table" -import { useStockLocations } from "../../../../../hooks/api/stock-locations" -import { useDataTable } from "../../../../../hooks/use-data-table" -import { useLocationTableColumns } from "./use-location-table-columns" -import { useLocationTableQuery } from "./use-location-table-query" - -const PAGE_SIZE = 20 - -export const LocationsListTable = () => { - const { t } = useTranslation() - - const { raw, searchParams } = useLocationTableQuery({ pageSize: PAGE_SIZE }) - - /** - * Note: The endpoint is bugged and does not return count, causing the table to not render - * any rows. - */ - const { - stock_locations = [], - count, - isLoading, - isError, - error, - } = useStockLocations({ - ...searchParams, - fields: "*address", - }) - - const columns = useLocationTableColumns() - - const { table } = useDataTable({ - data: stock_locations, - columns, - count, - enablePagination: true, - getRowId: (row) => row.id, - pageSize: PAGE_SIZE, - }) - - if (isError) { - throw error - } - - return ( - -
- {t("locations.domain")} -
- -
-
- row.id} - // TODO: revisit loader - on query change this will cause unmounting of the table, rendering loader briefly and again rendering table which will make search input unfocused - // isLoading={isLoading} - queryObject={raw} - pagination - search - /> -
- ) -} diff --git a/packages/admin-next/dashboard/src/v2-routes/locations/location-list/components/locations-list-table/use-location-table-columns.tsx b/packages/admin-next/dashboard/src/v2-routes/locations/location-list/components/locations-list-table/use-location-table-columns.tsx deleted file mode 100644 index d28f29c08c54..000000000000 --- a/packages/admin-next/dashboard/src/v2-routes/locations/location-list/components/locations-list-table/use-location-table-columns.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { createColumnHelper } from "@tanstack/react-table" -import { useMemo } from "react" -import { useTranslation } from "react-i18next" -import { ExtendedStockLocationDTO } from "../../../../../types/api-responses" -import { LocationRowActions } from "./location-row-actions" - -const columnHelper = createColumnHelper() - -export const useLocationTableColumns = () => { - const { t } = useTranslation() - - return useMemo( - () => [ - columnHelper.accessor("name", { - header: t("fields.name"), - cell: (cell) => cell.getValue(), - }), - columnHelper.accessor("address", { - header: t("fields.address"), - cell: (cell) => { - const value = cell.getValue() - - if (!value) { - return "-" - } - - return `${value.address_1}${value.city ? `, ${value.city}` : ""}` - }, - }), - columnHelper.display({ - id: "actions", - cell: ({ row }) => , - }), - ], - [t] - ) -} diff --git a/packages/admin-next/dashboard/src/v2-routes/locations/location-list/components/locations-list-table/use-location-table-query.tsx b/packages/admin-next/dashboard/src/v2-routes/locations/location-list/components/locations-list-table/use-location-table-query.tsx deleted file mode 100644 index 8ff0d519dfef..000000000000 --- a/packages/admin-next/dashboard/src/v2-routes/locations/location-list/components/locations-list-table/use-location-table-query.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { useQueryParams } from "../../../../../hooks/use-query-params" - -export const useLocationTableQuery = ({ - pageSize = 20, - prefix, -}: { - pageSize?: number - prefix?: string -}) => { - const raw = useQueryParams(["q", "offset"], prefix) - - const searchParams = { - limit: pageSize, - offset: raw.offset, - q: raw.q, - } - - return { - searchParams, - raw, - } -} diff --git a/packages/admin-next/dashboard/src/v2-routes/locations/location-list/index.ts b/packages/admin-next/dashboard/src/v2-routes/locations/location-list/index.ts deleted file mode 100644 index 883c2ba632d5..000000000000 --- a/packages/admin-next/dashboard/src/v2-routes/locations/location-list/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { LocationList as Component } from "./location-list" diff --git a/packages/admin-next/dashboard/src/v2-routes/locations/location-list/location-list.tsx b/packages/admin-next/dashboard/src/v2-routes/locations/location-list/location-list.tsx deleted file mode 100644 index 02363aba29b1..000000000000 --- a/packages/admin-next/dashboard/src/v2-routes/locations/location-list/location-list.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { Outlet } from "react-router-dom" -import { LocationsListTable } from "./components/locations-list-table" - -export const LocationList = () => { - return ( -
- - -
- ) -} diff --git a/packages/admin-next/dashboard/src/v2-routes/products/product-detail/components/product-sales-channel-section/product-sales-channel-section.tsx b/packages/admin-next/dashboard/src/v2-routes/products/product-detail/components/product-sales-channel-section/product-sales-channel-section.tsx index e9252f1d9fad..dea79f72dee1 100644 --- a/packages/admin-next/dashboard/src/v2-routes/products/product-detail/components/product-sales-channel-section/product-sales-channel-section.tsx +++ b/packages/admin-next/dashboard/src/v2-routes/products/product-detail/components/product-sales-channel-section/product-sales-channel-section.tsx @@ -83,7 +83,7 @@ export const ProductSalesChannelSection = ({
{ + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + + const form = useForm>({ + defaultValues: { + sales_channels: location.sales_channels?.map((sc) => sc.id) ?? [], + }, + resolver: zodResolver(EditSalesChannelsSchema), + }) + + const { setValue } = form + + const initialState = + location.sales_channels?.reduce((acc, curr) => { + acc[curr.id] = true + return acc + }, {} as RowSelectionState) ?? {} + + const [rowSelection, setRowSelection] = + useState(initialState) + + useEffect(() => { + const ids = Object.keys(rowSelection) + setValue("sales_channels", ids, { + shouldDirty: true, + shouldTouch: true, + }) + }, [rowSelection, setValue]) + + const { searchParams, raw } = useSalesChannelTableQuery({ + pageSize: PAGE_SIZE, + }) + + const { sales_channels, count, isLoading, isError, error } = useSalesChannels( + { + ...searchParams, + }, + { + placeholderData: keepPreviousData, + } + ) + + const filters = useSalesChannelTableFilters() + const columns = useColumns() + + const { table } = useDataTable({ + data: sales_channels ?? [], + columns, + count, + enablePagination: true, + enableRowSelection: true, + rowSelection: { + state: rowSelection, + updater: setRowSelection, + }, + getRowId: (row) => row.id, + pageSize: PAGE_SIZE, + }) + + const { mutateAsync, isPending: isMutating } = + useUpdateStockLocationSalesChannels(location.id) + + const handleSubmit = form.handleSubmit(async (data) => { + const originalIds = location.sales_channels.map((sc) => sc.id) + + const arr = data.sales_channels ?? [] + + await mutateAsync( + { + add: arr.filter((i) => !originalIds.includes(i)), + remove: originalIds.filter((i) => !arr.includes(i)), + }, + { + onSuccess: () => { + handleSuccess() + }, + } + ) + }) + + if (isError) { + throw error + } + + return ( + +
+ +
+ + + + +
+
+ + + +
+
+ ) +} + +const columnHelper = createColumnHelper() + +const useColumns = () => { + const columns = useSalesChannelTableColumns() + + return useMemo( + () => [ + columnHelper.display({ + id: "select", + header: ({ table }) => { + return ( + + table.toggleAllPageRowsSelected(!!value) + } + /> + ) + }, + cell: ({ row }) => { + return ( + row.toggleSelected(!!value)} + onClick={(e) => { + e.stopPropagation() + }} + /> + ) + }, + }), + ...columns, + ], + [columns] + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/location-add-sales-channels/components/edit-sales-channels-form/index.ts b/packages/admin-next/dashboard/src/v2-routes/shipping/location-add-sales-channels/components/edit-sales-channels-form/index.ts new file mode 100644 index 000000000000..812e2284320e --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/location-add-sales-channels/components/edit-sales-channels-form/index.ts @@ -0,0 +1 @@ +export * from "./edit-sales-channels-form" diff --git a/packages/admin-next/dashboard/src/v2-routes/locations/location-add-sales-channels/index.ts b/packages/admin-next/dashboard/src/v2-routes/shipping/location-add-sales-channels/index.ts similarity index 100% rename from packages/admin-next/dashboard/src/v2-routes/locations/location-add-sales-channels/index.ts rename to packages/admin-next/dashboard/src/v2-routes/shipping/location-add-sales-channels/index.ts diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/location-add-sales-channels/location-add-sales-channels.tsx b/packages/admin-next/dashboard/src/v2-routes/shipping/location-add-sales-channels/location-add-sales-channels.tsx new file mode 100644 index 000000000000..950393de4a7f --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/location-add-sales-channels/location-add-sales-channels.tsx @@ -0,0 +1,30 @@ +import { useParams } from "react-router-dom" + +import { RouteFocusModal } from "../../../components/route-modal" +import { LocationEditSalesChannelsForm } from "./components/edit-sales-channels-form" +import { useStockLocation } from "../../../hooks/api/stock-locations" + +export const LocationAddSalesChannels = () => { + const { location_id } = useParams() + const { + stock_location = {}, + isPending: isLocationLoading, + isError, + error, + } = useStockLocation(location_id!, { + fields: + "name,*sales_channels,address.city,address.country_code,fulfillment_sets.type,fulfillment_sets.name,*fulfillment_sets.service_zones.geo_zones,*fulfillment_sets.service_zones,*fulfillment_sets.service_zones.shipping_options,*fulfillment_sets.service_zones.shipping_options.shipping_profile", + }) + + if (isError) { + throw error + } + + return ( + + {!isLocationLoading && stock_location && ( + + )} + + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/locations/location-create/components/create-location-form/create-location-form.tsx b/packages/admin-next/dashboard/src/v2-routes/shipping/location-create/components/create-location-form/create-location-form.tsx similarity index 98% rename from packages/admin-next/dashboard/src/v2-routes/locations/location-create/components/create-location-form/create-location-form.tsx rename to packages/admin-next/dashboard/src/v2-routes/shipping/location-create/components/create-location-form/create-location-form.tsx index 739461ff5c4e..a686106984b9 100644 --- a/packages/admin-next/dashboard/src/v2-routes/locations/location-create/components/create-location-form/create-location-form.tsx +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/location-create/components/create-location-form/create-location-form.tsx @@ -55,7 +55,7 @@ export const CreateLocationForm = () => { address: values.address, }) - handleSuccess("/settings/locations") + handleSuccess("/settings/shipping") toast.success(t("general.success"), { description: t("locations.toast.create"), @@ -92,10 +92,10 @@ export const CreateLocationForm = () => {
- {t("locations.createLocation")} + {t("shipping.createLocation")} - {t("locations.detailsHint")} + {t("shipping.createLocationDetailsHint")}
diff --git a/packages/admin-next/dashboard/src/v2-routes/locations/location-create/components/create-location-form/index.ts b/packages/admin-next/dashboard/src/v2-routes/shipping/location-create/components/create-location-form/index.ts similarity index 100% rename from packages/admin-next/dashboard/src/v2-routes/locations/location-create/components/create-location-form/index.ts rename to packages/admin-next/dashboard/src/v2-routes/shipping/location-create/components/create-location-form/index.ts diff --git a/packages/admin-next/dashboard/src/v2-routes/locations/location-create/index.ts b/packages/admin-next/dashboard/src/v2-routes/shipping/location-create/index.ts similarity index 100% rename from packages/admin-next/dashboard/src/v2-routes/locations/location-create/index.ts rename to packages/admin-next/dashboard/src/v2-routes/shipping/location-create/index.ts diff --git a/packages/admin-next/dashboard/src/v2-routes/locations/location-create/location-create.tsx b/packages/admin-next/dashboard/src/v2-routes/shipping/location-create/location-create.tsx similarity index 100% rename from packages/admin-next/dashboard/src/v2-routes/locations/location-create/location-create.tsx rename to packages/admin-next/dashboard/src/v2-routes/shipping/location-create/location-create.tsx diff --git a/packages/admin-next/dashboard/src/v2-routes/locations/location-detail/components/location-general-section/index.ts b/packages/admin-next/dashboard/src/v2-routes/shipping/location-details/components/location-general-section/index.ts similarity index 100% rename from packages/admin-next/dashboard/src/v2-routes/locations/location-detail/components/location-general-section/index.ts rename to packages/admin-next/dashboard/src/v2-routes/shipping/location-details/components/location-general-section/index.ts diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/location-details/components/location-general-section/location-general-section.tsx b/packages/admin-next/dashboard/src/v2-routes/shipping/location-details/components/location-general-section/location-general-section.tsx new file mode 100644 index 000000000000..10ad9d22cd88 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/location-details/components/location-general-section/location-general-section.tsx @@ -0,0 +1,640 @@ +import { useNavigate } from "react-router-dom" +import { useTranslation } from "react-i18next" +import { useMemo, useState } from "react" +import { + FulfillmentSetDTO, + ServiceZoneDTO, + ShippingOptionDTO, + StockLocationDTO, +} from "@medusajs/types" +import { + ChevronDownMini, + CurrencyDollar, + Map, + PencilSquare, + Plus, + Trash, +} from "@medusajs/icons" +import { + Badge, + Button, + Container, + Heading, + StatusBadge, + Text, + toast, + usePrompt, +} from "@medusajs/ui" + +import { ActionMenu } from "../../../../../components/common/action-menu" +import { countries as staticCountries } from "../../../../../lib/countries" +import { + useCreateFulfillmentSet, + useDeleteFulfillmentSet, + useDeleteServiceZone, + useDeleteStockLocation, +} from "../../../../../hooks/api/stock-locations" +import { useDeleteShippingOption } from "../../../../../hooks/api/shipping-options" +import { formatProvider } from "../../../../../lib/format-provider" +import { NoRecords } from "../../../../../components/common/empty-table-content" +import { ListSummary } from "../../../../../components/common/list-summary" +import { + isOptionEnabledInStore, + isReturnOption, +} from "../../../../../lib/shipping-options" + +type LocationGeneralSectionProps = { + location: StockLocationDTO +} + +export const LocationGeneralSection = ({ + location, +}: LocationGeneralSectionProps) => { + return ( + <> + +
+ {location.name} + +
+
+ + f.type === FulfillmentSetType.Pickup + )} + /> + + f.type === FulfillmentSetType.Delivery + )} + /> + + ) +} + +type ShippingOptionProps = { + option: ShippingOptionDTO + fulfillmentSetId: string + locationId: string + isReturn?: boolean +} + +function ShippingOption({ + option, + isReturn, + fulfillmentSetId, + locationId, +}: ShippingOptionProps) { + const prompt = usePrompt() + const { t } = useTranslation() + + const isInStore = isOptionEnabledInStore(option) + + const { mutateAsync: deleteOption } = useDeleteShippingOption(option.id) + + const handleDelete = async () => { + const res = await prompt({ + title: t("general.areYouSure"), + description: t("shipping.shippingOptions.deleteWarning", { + name: option.name, + }), + confirmText: t("actions.delete"), + cancelText: t("actions.cancel"), + }) + + if (!res) { + return + } + + try { + await deleteOption() + + toast.success(t("general.success"), { + description: t("shipping.shippingOptions.toast.delete", { + name: option.name, + }), + dismissLabel: t("actions.close"), + }) + } catch (e) { + toast.error(t("general.error"), { + description: e.message, + dismissLabel: t("actions.close"), + }) + } + } + + return ( +
+
+ + {option.name} - {option.shipping_profile.name} ( + {formatProvider(option.provider_id)}) + +
+ {isInStore && ( + + {t("shipping.shippingOptions.inStore")} + + )} + , + label: t("shipping.serviceZone.editOption"), + to: `/settings/shipping/${locationId}/fulfillment-set/${fulfillmentSetId}/service-zone/${option.service_zone_id}/shipping-option/${option.id}/edit`, + }, + { + label: t("shipping.serviceZone.editPrices"), + icon: , + disabled: true, + }, + { + label: t("actions.delete"), + icon: , + onClick: handleDelete, + }, + ], + }, + ]} + /> +
+ ) +} + +type ServiceZoneOptionsProps = { + zone: ServiceZoneDTO + locationId: string + fulfillmentSetId: string +} + +function ServiceZoneOptions({ + zone, + locationId, + fulfillmentSetId, +}: ServiceZoneOptionsProps) { + const { t } = useTranslation() + const navigate = useNavigate() + + const shippingOptions = zone.shipping_options.filter( + (o) => !isReturnOption(o) + ) + + const returnOptions = zone.shipping_options.filter((o) => isReturnOption(o)) + + return ( + <> +
+
+ + {t("shipping.serviceZone.shippingOptions")} + + +
+ + {!!shippingOptions.length && ( +
+ {shippingOptions.map((o) => ( + + ))} +
+ )} +
+ +
+
+ + {t("shipping.serviceZone.returnOptions")} + + +
+ + {!!returnOptions.length && ( +
+ {returnOptions.map((o) => ( + + ))} +
+ )} +
+ + ) +} + +type ServiceZoneProps = { + zone: ServiceZoneDTO + locationId: string + fulfillmentSetId: string +} + +function ServiceZone({ zone, locationId, fulfillmentSetId }: ServiceZoneProps) { + const { t } = useTranslation() + const prompt = usePrompt() + const [open, setOpen] = useState(false) + + const { mutateAsync: deleteZone } = useDeleteServiceZone( + fulfillmentSetId, + zone.id + ) + + const handleDelete = async () => { + const res = await prompt({ + title: t("general.areYouSure"), + description: t("shipping.serviceZone.deleteWarning", { + name: zone.name, + }), + confirmText: t("actions.delete"), + cancelText: t("actions.cancel"), + }) + + if (!res) { + return + } + + try { + await deleteZone() + + toast.success(t("general.success"), { + description: t("shipping.serviceZone.toast.delete", { + name: zone.name, + }), + dismissLabel: t("actions.close"), + }) + } catch (e) { + toast.error(t("general.error"), { + description: e.message, + dismissLabel: t("actions.close"), + }) + } + } + + const countries = useMemo(() => { + return zone.geo_zones + .filter((g) => g.type === "country") + .map((g) => g.country_code) + .map((code) => staticCountries.find((c) => c.iso_2 === code)) + }, zone.geo_zones) + + const [shippingOptionsCount, returnOptionsCount] = useMemo(() => { + const optionsCount = zone.shipping_options.filter( + (o) => !isReturnOption(o) + ).length + + const returnOptionsCount = zone.shipping_options.filter((o) => + isReturnOption(o) + ).length + + return [optionsCount, returnOptionsCount] + }, [zone.shipping_options]) + + return ( +
+
+ {/*ICON*/} +
+
+ +
+
+ + {/*INFO*/} +
+ {zone.name} +
+ c.display_name)} + inline + n={1} + /> + · + + {shippingOptionsCount}{" "} + {t("shipping.serviceZone.optionsLength", { + count: shippingOptionsCount, + })} + + · + + {returnOptionsCount}{" "} + {t("shipping.serviceZone.returnOptionsLength", { + count: returnOptionsCount, + })} + +
+
+ + {/*ACTION*/} +
+ + , + // to: `/settings/shipping/${locationId}/fulfillment-set/${fulfillmentSetId}/service-zone/${zone.id}/shipping-option/create`, + // }, + { + label: t("actions.edit"), + icon: , + to: `/settings/shipping/${locationId}/fulfillment-set/${fulfillmentSetId}/service-zone/${zone.id}/edit`, + }, + { + label: t("actions.delete"), + icon: , + onClick: handleDelete, + }, + ], + }, + ]} + /> +
+
+ {open && ( +
+ +
+ )} +
+ ) +} + +enum FulfillmentSetType { + Delivery = "delivery", + Pickup = "pickup", +} + +type FulfillmentSetProps = { + fulfillmentSet?: FulfillmentSetDTO + locationName: string + locationId: string + type: FulfillmentSetType +} + +function FulfillmentSet(props: FulfillmentSetProps) { + const { t } = useTranslation() + const prompt = usePrompt() + const navigate = useNavigate() + + const { fulfillmentSet, locationName, locationId, type } = props + + const fulfillmentSetExists = !!fulfillmentSet + + const hasServiceZones = !!fulfillmentSet?.service_zones.length + + const { mutateAsync: createFulfillmentSet, isPending: isLoading } = + useCreateFulfillmentSet(locationId) + + const { mutateAsync: deleteFulfillmentSet } = useDeleteFulfillmentSet( + fulfillmentSet?.id + ) + + const handleCreate = async () => { + try { + await createFulfillmentSet({ + name: `${locationName} ${ + type === FulfillmentSetType.Pickup ? "pick up" : type + }`, + type, + }) + } catch (e) { + toast.error(t("general.error"), { + description: e.message, + dismissLabel: t("actions.close"), + }) + } + } + + const handleDelete = async () => { + const res = await prompt({ + title: t("general.areYouSure"), + description: t("shipping.fulfillmentSet.disableWarning", { + name: fulfillmentSet?.name, + }), + confirmText: t("actions.delete"), + cancelText: t("actions.cancel"), + }) + + if (!res) { + return + } + + try { + await deleteFulfillmentSet() + + toast.success(t("general.success"), { + description: t("shipping.fulfillmentSet.toast.disable", { + name: fulfillmentSet?.name, + }), + dismissLabel: t("actions.close"), + }) + } catch (e) { + toast.error(t("general.error"), { + description: e.message, + dismissLabel: t("actions.close"), + }) + } + } + + return ( + +
+
+ + {t(`shipping.fulfillmentSet.${type}.offers`)} + +
+ + {t( + fulfillmentSetExists ? "statuses.enabled" : "statuses.disabled" + )} + + + , + label: t("shipping.fulfillmentSet.addZone"), + onClick: () => + navigate( + `/settings/shipping/${locationId}/fulfillment-set/${fulfillmentSet.id}/service-zones/create` + ), + disabled: !fulfillmentSetExists, + }, + { + icon: , + label: fulfillmentSetExists + ? t("actions.disable") + : t("actions.enable"), + onClick: fulfillmentSetExists + ? handleDelete + : handleCreate, + }, + ], + }, + ]} + /> +
+
+ + {fulfillmentSetExists && !hasServiceZones && ( +
+ + + +
+ )} + + {hasServiceZones && ( +
+ {fulfillmentSet?.service_zones.map((zone) => ( + + ))} +
+ )} +
+
+ ) +} + +const Actions = ({ location }: { location: StockLocationDTO }) => { + const navigate = useNavigate() + const { t } = useTranslation() + const { mutateAsync } = useDeleteStockLocation(location.id) + const prompt = usePrompt() + + const handleDelete = async () => { + const res = await prompt({ + title: t("general.areYouSure"), + description: t("shipping.deleteLocationWarning", { + name: location.name, + }), + verificationText: location.name, + verificationInstruction: t("general.typeToConfirm"), + confirmText: t("actions.delete"), + cancelText: t("actions.cancel"), + }) + + if (!res) { + return + } + + try { + await mutateAsync(undefined) + toast.success(t("general.success"), { + description: t("shipping.toast.delete"), + dismissLabel: t("actions.close"), + }) + } catch (e) { + toast.error(t("general.error"), { + description: e.message, + dismissLabel: t("actions.close"), + }) + } + navigate("/settings/shipping", { replace: true }) + } + + return ( + , + label: t("actions.edit"), + to: `edit`, + }, + ], + }, + { + actions: [ + { + icon: , + label: t("actions.delete"), + onClick: handleDelete, + }, + ], + }, + ]} + /> + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/location-details/components/location-sales-channels-section/index.ts b/packages/admin-next/dashboard/src/v2-routes/shipping/location-details/components/location-sales-channels-section/index.ts new file mode 100644 index 000000000000..63fdc9e67ab9 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/location-details/components/location-sales-channels-section/index.ts @@ -0,0 +1 @@ +export * from "./locations-sales-channels-section" diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/location-details/components/location-sales-channels-section/locations-sales-channels-section.tsx b/packages/admin-next/dashboard/src/v2-routes/shipping/location-details/components/location-sales-channels-section/locations-sales-channels-section.tsx new file mode 100644 index 000000000000..2104efec980d --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/location-details/components/location-sales-channels-section/locations-sales-channels-section.tsx @@ -0,0 +1,79 @@ +import { Heading, Text } from "@medusajs/ui" +import { Trans, useTranslation } from "react-i18next" +import { StockLocationDTO } from "@medusajs/types" +import { Channels, PencilSquare } from "@medusajs/icons" + +import { useSalesChannels } from "../../../../../hooks/api/sales-channels" +import { ActionMenu } from "../../../../../components/common/action-menu" +import { ListSummary } from "../../../../../components/common/list-summary" + +type Props = { + location: StockLocationDTO +} + +function LocationsSalesChannelsSection({ location }: Props) { + const { t } = useTranslation() + const { count } = useSalesChannels() + + const noChannels = !location.sales_channels?.length + + return ( +
+
+ {t("shipping.salesChannels.title")} + , + }, + ], + }, + ]} + /> +
+
+
+
+ +
+
+ {noChannels ? ( + + {t("shipping.salesChannels.placeholder")} + + ) : ( + sc.name)} + /> + )} +
+ + , + , + ]} + /> + +
+ ) +} + +export default LocationsSalesChannelsSection diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/location-details/index.ts b/packages/admin-next/dashboard/src/v2-routes/shipping/location-details/index.ts new file mode 100644 index 000000000000..83965180c191 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/location-details/index.ts @@ -0,0 +1,2 @@ +export { locationLoader as loader } from "./loader" +export { LocationDetails as Component } from "./location-details" diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/location-details/loader.ts b/packages/admin-next/dashboard/src/v2-routes/shipping/location-details/loader.ts new file mode 100644 index 000000000000..d7c4f9e570c5 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/location-details/loader.ts @@ -0,0 +1,27 @@ +import { Response } from "@medusajs/medusa-js" +import { AdminStockLocationResponse } from "@medusajs/types" +import { LoaderFunctionArgs } from "react-router-dom" + +import { queryClient } from "../../../lib/medusa" +import { stockLocationsQueryKeys } from "../../../hooks/api/stock-locations" +import { client } from "../../../lib/client" + +const locationQuery = (id: string) => ({ + queryKey: stockLocationsQueryKeys.detail(id), + queryFn: async () => + client.stockLocations.retrieve(id, { + fields: + "name,*sales_channels,address.city,address.country_code,fulfillment_sets.type,fulfillment_sets.name,*fulfillment_sets.service_zones.geo_zones,*fulfillment_sets.service_zones,*fulfillment_sets.service_zones.shipping_options,*fulfillment_sets.service_zones.shipping_options.rules,*fulfillment_sets.service_zones.shipping_options.shipping_profile", + }), +}) + +export const locationLoader = async ({ params }: LoaderFunctionArgs) => { + const id = params.location_id + const query = locationQuery(id!) + + return ( + queryClient.getQueryData>( + query.queryKey + ) ?? (await queryClient.fetchQuery(query)) + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/location-details/location-details.tsx b/packages/admin-next/dashboard/src/v2-routes/shipping/location-details/location-details.tsx new file mode 100644 index 000000000000..236461a74470 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/location-details/location-details.tsx @@ -0,0 +1,55 @@ +import { Outlet, useLoaderData, useParams } from "react-router-dom" + +import { JsonViewSection } from "../../../components/common/json-view-section" +import { LocationGeneralSection } from "./components/location-general-section" +import { useStockLocation } from "../../../hooks/api/stock-locations" +import LocationsSalesChannelsSection from "./components/location-sales-channels-section/locations-sales-channels-section" +import { locationLoader } from "./loader" + +export const LocationDetails = () => { + const initialData = useLoaderData() as Awaited< + ReturnType + > + + const { location_id } = useParams() + const { + stock_location: location, + isPending: isLoading, + isError, + error, + } = useStockLocation( + location_id!, + { + fields: + "name,*sales_channels,address.city,address.country_code,fulfillment_sets.type,fulfillment_sets.name,*fulfillment_sets.service_zones.geo_zones,*fulfillment_sets.service_zones,*fulfillment_sets.service_zones.shipping_options,*fulfillment_sets.service_zones.shipping_options.rules,*fulfillment_sets.service_zones.shipping_options.shipping_profile", + }, + { + initialData, + } + ) + + // TODO: Move to loading.tsx and set as Suspense fallback for the route + if (isLoading || !location) { + return
Loading...
+ } + + if (isError) { + throw error + } + + return ( +
+
+ +
+ +
+ +
+
+ +
+ +
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/locations/location-edit/components/edit-location-form/edit-location-form.tsx b/packages/admin-next/dashboard/src/v2-routes/shipping/location-edit/components/edit-location-form/edit-location-form.tsx similarity index 98% rename from packages/admin-next/dashboard/src/v2-routes/locations/location-edit/components/edit-location-form/edit-location-form.tsx rename to packages/admin-next/dashboard/src/v2-routes/shipping/location-edit/components/edit-location-form/edit-location-form.tsx index a5dd5771d5d2..3e3559829579 100644 --- a/packages/admin-next/dashboard/src/v2-routes/locations/location-edit/components/edit-location-form/edit-location-form.tsx +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/location-edit/components/edit-location-form/edit-location-form.tsx @@ -81,7 +81,7 @@ export const EditLocationForm = ({ location }: EditLocationFormProps) => { className="flex flex-1 flex-col overflow-hidden" > -
+
{ ) }} /> -
-
{ - const { id } = useParams() + const { location_id } = useParams() const { stock_location, isPending: isLoading, isError, error, - } = useStockLocation(id, { + } = useStockLocation(location_id, { fields: "*address", }) diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/locations-list/components/location/index.ts b/packages/admin-next/dashboard/src/v2-routes/shipping/location-list/components/location/index.ts similarity index 100% rename from packages/admin-next/dashboard/src/v2-routes/shipping/locations-list/components/location/index.ts rename to packages/admin-next/dashboard/src/v2-routes/shipping/location-list/components/location/index.ts diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/location-list/components/location/location.tsx b/packages/admin-next/dashboard/src/v2-routes/shipping/location-list/components/location/location.tsx new file mode 100644 index 000000000000..7569882c131d --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/location-list/components/location/location.tsx @@ -0,0 +1,212 @@ +import { + Button, + Container, + StatusBadge, + Text, + toast, + usePrompt, +} from "@medusajs/ui" +import { + FulfillmentSetDTO, + SalesChannelDTO, + StockLocationDTO, +} from "@medusajs/types" +import { useTranslation } from "react-i18next" +import { Buildings, PencilSquare, Trash } from "@medusajs/icons" +import { useNavigate } from "react-router-dom" + +import { countries } from "../../../../../lib/countries" +import { ActionMenu } from "../../../../../components/common/action-menu" +import { useDeleteStockLocation } from "../../../../../hooks/api/stock-locations" +import { BadgeListSummary } from "../../../../../components/common/badge-list-summary" + +type SalesChannelsProps = { + salesChannels?: SalesChannelDTO[] +} + +function SalesChannels(props: SalesChannelsProps) { + const { t } = useTranslation() + const { salesChannels } = props + + return ( +
+
+ + {t(`shipping.fulfillmentSet.salesChannels`)} + +
+ {salesChannels?.length ? ( + s.name)} + /> + ) : ( + "-" + )} +
+
+
+ ) +} + +enum FulfillmentSetType { + Delivery = "delivery", + Pickup = "pickup", +} + +type FulfillmentSetProps = { + fulfillmentSet?: FulfillmentSetDTO + type: FulfillmentSetType +} + +function FulfillmentSet(props: FulfillmentSetProps) { + const { t } = useTranslation() + const { fulfillmentSet, type } = props + + const fulfillmentSetExists = !!fulfillmentSet + + return ( +
+
+ + {t(`shipping.fulfillmentSet.${type}.title`)} + +
+ + {t(fulfillmentSetExists ? "statuses.enabled" : "statuses.disabled")} + +
+
+
+ ) +} + +type LocationProps = { + location: StockLocationDTO +} + +function Location(props: LocationProps) { + const { location } = props + const { t } = useTranslation() + const navigate = useNavigate() + const prompt = usePrompt() + + const { mutateAsync: deleteLocation } = useDeleteStockLocation(location.id) + + const handleDelete = async () => { + const result = await prompt({ + title: t("general.areYouSure"), + description: t("shipping.deleteLocation.confirm", { + name: location.name, + }), + confirmText: t("actions.remove"), + cancelText: t("actions.cancel"), + }) + + if (!result) { + return + } + + try { + await deleteLocation() + + toast.success(t("general.success"), { + description: t("shipping.deleteLocation.success", { + name: location.name, + }), + dismissLabel: t("general.close"), + }) + } catch (e) { + toast.error(t("general.error"), { + description: e.message, + dismissLabel: t("actions.close"), + }) + } + } + + return ( + +
+
+ {/*ICON*/} +
+
+ +
+
+ + {/*LOCATION INFO*/} +
+ {location.name} + + {location.address?.city},{" "} + { + countries.find( + (c) => + location.address?.country_code.toLowerCase() === c.iso_2 + )?.display_name + } + +
+ + {/*ACTION*/} +
+ , + to: `/settings/shipping/${location.id}/edit`, + }, + { + label: t("shipping.deleteLocation.label"), + icon: , + onClick: handleDelete, + }, + ], + }, + ]} + /> + +
+
+
+ + + + f.type === FulfillmentSetType.Pickup + )} + /> + f.type === FulfillmentSetType.Delivery + )} + /> +
+ ) +} + +export default Location diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/location-list/const.ts b/packages/admin-next/dashboard/src/v2-routes/shipping/location-list/const.ts new file mode 100644 index 000000000000..70caf9e463c1 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/location-list/const.ts @@ -0,0 +1,3 @@ +// TODO: change this when RQ is fixed (address is not joined when *address) +export const locationListFields = + "name,*sales_channels,address.city,address.country_code,*fulfillment_sets,*fulfillment_sets.service_zones,*fulfillment_sets.service_zones.shipping_options,*fulfillment_sets.service_zones.shipping_options.shipping_profile" diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/locations-list/index.ts b/packages/admin-next/dashboard/src/v2-routes/shipping/location-list/index.ts similarity index 100% rename from packages/admin-next/dashboard/src/v2-routes/shipping/locations-list/index.ts rename to packages/admin-next/dashboard/src/v2-routes/shipping/location-list/index.ts diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/locations-list/loader.ts b/packages/admin-next/dashboard/src/v2-routes/shipping/location-list/loader.ts similarity index 52% rename from packages/admin-next/dashboard/src/v2-routes/shipping/locations-list/loader.ts rename to packages/admin-next/dashboard/src/v2-routes/shipping/location-list/loader.ts index ce6236e68df2..92ff34670006 100644 --- a/packages/admin-next/dashboard/src/v2-routes/shipping/locations-list/loader.ts +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/location-list/loader.ts @@ -1,18 +1,17 @@ import { LoaderFunctionArgs } from "react-router-dom" -import { adminStockLocationsKeys } from "medusa-react" import { client } from "../../../lib/client" import { queryClient } from "../../../lib/medusa" import { StockLocationListRes } from "../../../types/api-responses" +import { stockLocationsQueryKeys } from "../../../hooks/api/stock-locations" +import { locationListFields } from "./const" const shippingListQuery = () => ({ - queryKey: adminStockLocationsKeys.lists(), + queryKey: stockLocationsQueryKeys.lists(), queryFn: async () => client.stockLocations.list({ - // fields: "*fulfillment_sets,*fulfillment_sets.service_zones", - // TODO: change this when RQ is fixed to work with the upper fields definition - fields: - "name,address.city,address.country_code,fulfillment_sets.type,fulfillment_sets.name,*fulfillment_sets.service_zones,*fulfillment_sets.service_zones.shipping_options,*fulfillment_sets.service_zones.shipping_options.shipping_profile", + // TODO: change this when RQ is fixed + fields: locationListFields, }), }) diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/location-list/location-list.tsx b/packages/admin-next/dashboard/src/v2-routes/shipping/location-list/location-list.tsx new file mode 100644 index 000000000000..c1be4db4a551 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/location-list/location-list.tsx @@ -0,0 +1,47 @@ +import { Button, Container, Heading, Text } from "@medusajs/ui" +import { useTranslation } from "react-i18next" +import { Link, Outlet, useLoaderData } from "react-router-dom" + +import { shippingListLoader } from "./loader" +import { useStockLocations } from "../../../hooks/api/stock-locations" +import Location from "./components/location/location" +import { locationListFields } from "./const" + +export function LocationList() { + const { t } = useTranslation() + + const initialData = useLoaderData() as Awaited< + ReturnType + > + + let { stock_locations: stockLocations = [], isPending } = useStockLocations( + { + fields: locationListFields, + }, + { initialData } + ) + + return ( + <> +
+ +
+ {t("shipping.title")} + + {t("shipping.description")} + +
+ +
+
+ {stockLocations.map((location) => ( + + ))} +
+
+ + + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/locations-list/components/location/location.tsx b/packages/admin-next/dashboard/src/v2-routes/shipping/locations-list/components/location/location.tsx deleted file mode 100644 index 1d1b42ba7364..000000000000 --- a/packages/admin-next/dashboard/src/v2-routes/shipping/locations-list/components/location/location.tsx +++ /dev/null @@ -1,392 +0,0 @@ -import { Button, Container, Text } from "@medusajs/ui" -import { - FulfillmentSetDTO, - ServiceZoneDTO, - ShippingOptionDTO, - StockLocationDTO, -} from "@medusajs/types" -import { useTranslation } from "react-i18next" -import { - Buildings, - ChevronDown, - CurrencyDollar, - Map, - PencilSquare, - Plus, - Trash, -} from "@medusajs/icons" -import { useNavigate } from "react-router-dom" -import { useState } from "react" - -import { countries } from "../../../../../lib/countries" -import { - useCreateFulfillmentSet, - useDeleteFulfillmentSet, - useDeleteServiceZone, -} from "../../../../../hooks/api/stock-locations" -import { ActionMenu } from "../../../../../components/common/action-menu" -import { formatProvider } from "../../../../../lib/format-provider" -import { useDeleteShippingOption } from "../../../../../hooks/api/shipping-options.ts" - -type ShippingOptionProps = { - option: ShippingOptionDTO -} - -function ShippingOption({ option }: ShippingOptionProps) { - const { t } = useTranslation() - - const { mutateAsync: deleteOption } = useDeleteShippingOption(option.id) - - const handleDelete = async () => { - await deleteOption() - } - - return ( -
-
- - {option.name} - {option.shipping_profile.name} ( - {formatProvider(option.provider_id)}) - -
- , - disabled: true, - }, - { - label: t("shipping.serviceZone.editPrices"), - icon: , - disabled: true, - }, - { - label: t("actions.delete"), - icon: , - onClick: handleDelete, - }, - ], - }, - ]} - /> -
- ) -} - -type ServiceZoneOptionsProps = { - zone: ServiceZoneDTO - locationId: string - fulfillmentSetId: string -} - -function ServiceZoneOptions({ - zone, - locationId, - fulfillmentSetId, -}: ServiceZoneOptionsProps) { - const { t } = useTranslation() - const navigate = useNavigate() - - const shippingOptions = zone.shipping_options - - return ( - <> -
- - {t("shipping.serviceZone.shippingOptions")} - - {!shippingOptions.length && ( -
-
{t("shipping.serviceZone.shippingOptionsPlaceholder")}
- -
- )} - - {!!shippingOptions.length && ( -
- {shippingOptions.map((o) => ( - - ))} -
- )} -
- {/*TODO implement return options*/} - {/*
*/} - {/* */} - {/* {t("shipping.serviceZone.returnOptions")}*/} - {/* */} - {/*
*/} - - ) -} - -type ServiceZoneProps = { - zone: ServiceZoneDTO - locationId: string - fulfillmentSetId: string -} - -function ServiceZone({ zone, locationId, fulfillmentSetId }: ServiceZoneProps) { - const { t } = useTranslation() - const [open, setOpen] = useState(false) - - const { mutateAsync: deleteZone } = useDeleteServiceZone( - fulfillmentSetId, - zone.id - ) - - const handleDelete = async () => { - await deleteZone() - } - - return ( - <> -
- {/*ICON*/} -
-
- -
-
- - {/*INFO*/} -
- {zone.name} - - {zone.shipping_options.length}{" "} - {t("shipping.serviceZone.optionsLength", { - count: zone.shipping_options.length, - })} - -
- - {/*ACTION*/} -
- , - to: `/shipping/location/${locationId}/fulfillment-set/${fulfillmentSetId}/service-zone/${zone.id}/shipping-options/create`, - }, - { - label: t("actions.delete"), - icon: , - onClick: handleDelete, - }, - ], - }, - ]} - /> - -
-
- {open && ( -
- -
- )} - - ) -} - -enum FulfillmentSetType { - Delivery = "delivery", - Pickup = "pickup", -} - -type FulfillmentSetProps = { - fulfillmentSet?: FulfillmentSetDTO - locationName: string - locationId: string - type: FulfillmentSetType -} - -function FulfillmentSet(props: FulfillmentSetProps) { - const { t } = useTranslation() - const navigate = useNavigate() - const { fulfillmentSet, locationName, locationId, type } = props - - const fulfillmentSetExists = !!fulfillmentSet - const hasServiceZones = !!fulfillmentSet?.service_zones?.length - - const { mutateAsync: createFulfillmentSet, isPending: isLoading } = - useCreateFulfillmentSet(locationId) - - const { mutateAsync: deleteFulfillmentSet } = useDeleteFulfillmentSet( - fulfillmentSet?.id - ) - - const handleCreate = async () => { - await createFulfillmentSet({ - name: `${locationName} ${type}`, - type: type, - }) - } - - const handleDelete = async () => { - await deleteFulfillmentSet() - } - - return ( -
-
- - {t(`shipping.fulfillmentSet.${type}.title`)} - - {!fulfillmentSetExists ? ( - - ) : ( - , - to: `/shipping/location/${locationId}/fulfillment-set/${fulfillmentSet.id}/service-zones/create`, - }, - { - label: t("shipping.fulfillmentSet.delete"), - icon: , - onClick: handleDelete, - }, - ], - }, - ]} - /> - )} -
- - {fulfillmentSetExists && !hasServiceZones && ( -
-
{t("shipping.fulfillmentSet.placeholder")}
- -
- )} - - {hasServiceZones && ( -
- {fulfillmentSet?.service_zones.map((zone) => ( - - ))} -
- )} -
- ) -} - -type LocationProps = { - location: StockLocationDTO -} - -function Location(props: LocationProps) { - const { location } = props - const { t } = useTranslation() - - return ( - -
-
- {/*ICON*/} -
-
- -
-
- - {/*LOCATION INFO*/} -
- {location.name} - - {location.address?.city},{" "} - { - countries.find( - (c) => - location.address?.country_code.toLowerCase() === c.iso_2 - )?.display_name - } - -
- - {/*ACTION*/} -
{/*// TODO*/}
-
-
- - f.type === FulfillmentSetType.Pickup - )} - /> - f.type === FulfillmentSetType.Delivery - )} - /> -
- ) -} - -export default Location diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/locations-list/location-list.tsx b/packages/admin-next/dashboard/src/v2-routes/shipping/locations-list/location-list.tsx deleted file mode 100644 index 7815b4823a48..000000000000 --- a/packages/admin-next/dashboard/src/v2-routes/shipping/locations-list/location-list.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Container, Heading, Text } from "@medusajs/ui" -import { useTranslation } from "react-i18next" -import { Outlet, useLoaderData } from "react-router-dom" - -import { shippingListLoader } from "./loader" -import { useStockLocations } from "../../../hooks/api/stock-locations" -import Location from "./components/location/location" -import { NoRecords } from "../../../components/common/empty-table-content" - -export function LocationList() { - const { t } = useTranslation() - - const initialData = useLoaderData() as Awaited< - ReturnType - > - - let { stock_locations: stockLocations = [], isPending } = useStockLocations( - { - fields: - "name,address.city,address.country_code,fulfillment_sets.type,fulfillment_sets.name,*fulfillment_sets.service_zones,*fulfillment_sets.service_zones.shipping_options,*fulfillment_sets.service_zones.shipping_options.shipping_profile", - }, - { initialData } - ) - - return ( - <> -
- - {t("shipping.title")} - - {t("shipping.description")} - - -
- {!isPending && !stockLocations.length && ( - - - - )} - {stockLocations.map((location) => ( - - ))} -
-
- - - ) -} diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-create/components/create-service-zone-form/create-service-zone-form.tsx b/packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-create/components/create-service-zone-form/create-service-zone-form.tsx index e3510ccc9fb7..a29b5e412b38 100644 --- a/packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-create/components/create-service-zone-form/create-service-zone-form.tsx +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-create/components/create-service-zone-form/create-service-zone-form.tsx @@ -7,10 +7,19 @@ import { } from "@tanstack/react-table" import * as zod from "zod" -import { Alert, Button, Checkbox, Heading, Input, Text } from "@medusajs/ui" +import { + Alert, + Badge, + Button, + Checkbox, + Heading, + IconButton, + Input, + Text, +} from "@medusajs/ui" import { FulfillmentSetDTO, RegionCountryDTO, RegionDTO } from "@medusajs/types" import { useTranslation } from "react-i18next" -import { Map } from "@medusajs/icons" +import { Map, XMark, XMarkMini } from "@medusajs/icons" import { RouteFocusModal, @@ -26,7 +35,6 @@ import { countries as staticCountries } from "../../../../../lib/countries" import { useDataTable } from "../../../../../hooks/use-data-table" import { useCountryTableColumns } from "../../../../regions/common/hooks/use-country-table-columns" import { DataTable } from "../../../../../components/table/data-table" -import { ListSummary } from "../../../../../components/common/list-summary" const PREFIX = "ac" const PAGE_SIZE = 50 @@ -87,7 +95,7 @@ export function CreateServiceZoneForm({ })), }) - handleSuccess("/shipping") + handleSuccess() }) const handleOpenChange = (open: boolean) => { @@ -129,12 +137,28 @@ export function CreateServiceZoneForm({ prefix: PREFIX, }) + const countriesWatch = form.watch("countries") + const onCountriesSave = () => { form.setValue("countries", Object.keys(rowSelection)) setOpen(false) } - const countriesWatch = form.watch("countries") + const removeCountry = (iso2: string) => { + const state = { ...rowSelection } + delete state[iso2] + setRowSelection(state) + + form.setValue( + "countries", + countriesWatch.filter((c) => c !== iso2) + ) + } + + const clearAll = () => { + setRowSelection({}) + form.setValue("countries", []) + } const selectedCountries = useMemo(() => { return staticCountries.filter((c) => c.iso_2 in rowSelection) @@ -184,15 +208,6 @@ export function CreateServiceZoneForm({ })} -
- - {t("shipping.serviceZone.create.subtitle")} - - - {t("shipping.serviceZone.create.description")} - -
-
- + @@ -214,8 +229,17 @@ export function CreateServiceZoneForm({
+ + + {t("shipping.serviceZone.create.subtitle")} + + + {t("shipping.serviceZone.create.description")} + + + {/*AREAS*/} -
+
{t("shipping.serviceZone.areas.title")} @@ -233,16 +257,31 @@ export function CreateServiceZoneForm({
{!!selectedCountries.length && ( -
-
-
- -
-
- c.display_name)} - /> +
+ {selectedCountries.map((c) => ( + + {c.display_name} + removeCountry(c.iso_2)} + className="text-ui-fg-subtle p-0 px-1 pt-[1px]" + variant="transparent" + > + + + + ))} +
)} {showAreasError && ( diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-create/loader.ts b/packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-create/loader.ts index 68fee79dd9b5..d390458e1122 100644 --- a/packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-create/loader.ts +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-create/loader.ts @@ -1,12 +1,12 @@ -import { adminStockLocationsKeys } from "medusa-react" import { LoaderFunctionArgs } from "react-router-dom" import { client } from "../../../lib/client" import { queryClient } from "../../../lib/medusa" import { StockLocationRes } from "../../../types/api-responses" +import { stockLocationsQueryKeys } from "../../../hooks/api/stock-locations" const fulfillmentSetCreateQuery = (id: string) => ({ - queryKey: adminStockLocationsKeys.detail(id), + queryKey: stockLocationsQueryKeys.detail(id), queryFn: async () => client.stockLocations.retrieve(id, { fields: "*fulfillment_sets", diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-edit/components/edit-region-form/edit-service-zone-form.tsx b/packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-edit/components/edit-region-form/edit-service-zone-form.tsx new file mode 100644 index 000000000000..bd5d66e9eb68 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-edit/components/edit-region-form/edit-service-zone-form.tsx @@ -0,0 +1,114 @@ +import { Alert, Button, Input, Text, toast } from "@medusajs/ui" +import { useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" +import * as zod from "zod" +import { ServiceZoneDTO } from "@medusajs/types" + +import { Form } from "../../../../../components/common/form" +import { + RouteDrawer, + useRouteModal, +} from "../../../../../components/route-modal" +import { useUpdateServiceZone } from "../../../../../hooks/api/stock-locations" + +type EditServiceZoneFormProps = { + zone: ServiceZoneDTO + fulfillmentSetId: string + locationId: string +} + +const EditServiceZoneSchema = zod.object({ + name: zod.string().min(1), +}) + +export const EditServiceZoneForm = ({ + zone, + fulfillmentSetId, + locationId, +}: EditServiceZoneFormProps) => { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + + const form = useForm>({ + defaultValues: { + name: zone.name, + }, + }) + + const { mutateAsync, isPending: isLoading } = useUpdateServiceZone( + fulfillmentSetId, + zone.id, + locationId + ) + + const handleSubmit = form.handleSubmit(async (values) => { + await mutateAsync( + { + name: values.name, + }, + { + onSuccess: () => { + toast.success(t("general.success"), { + // description: t("regions.toast.edit"), + dismissLabel: t("actions.close"), + }) + handleSuccess() + }, + onError: (e) => { + toast.error(t("general.error"), { + description: e.message, + dismissLabel: t("actions.close"), + }) + }, + } + ) + }) + + return ( + +
+ +
+
+ { + return ( + + {t("fields.name")} + + + + + + ) + }} + /> +
+ + + {t("shipping.serviceZone.create.subtitle")} + + + {t("shipping.serviceZone.create.description")} + + +
+
+ +
+ + + + +
+
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-edit/components/edit-region-form/index.ts b/packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-edit/components/edit-region-form/index.ts new file mode 100644 index 000000000000..fe15016f6923 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-edit/components/edit-region-form/index.ts @@ -0,0 +1 @@ +export * from "./edit-service-zone-form" diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-edit/index.ts b/packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-edit/index.ts new file mode 100644 index 000000000000..dc69fc18e247 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-edit/index.ts @@ -0,0 +1 @@ +export { ServiceZoneEdit as Component } from "./service-zone-edit" diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-edit/service-zone-edit.tsx b/packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-edit/service-zone-edit.tsx new file mode 100644 index 000000000000..2f8b5628af98 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-edit/service-zone-edit.tsx @@ -0,0 +1,50 @@ +import { Heading } from "@medusajs/ui" +import { useTranslation } from "react-i18next" +import { json, useParams } from "react-router-dom" + +import { RouteDrawer } from "../../../components/route-modal" +import { EditServiceZoneForm } from "./components/edit-region-form" +import { useStockLocation } from "../../../hooks/api/stock-locations" + +export const ServiceZoneEdit = () => { + const { t } = useTranslation() + const { location_id, fset_id, zone_id } = useParams() + + const { stock_location, isPending, isError, error } = useStockLocation( + location_id!, + { + fields: + "name,address.city,address.country_code,fulfillment_sets.type,fulfillment_sets.name,*fulfillment_sets.service_zones.geo_zones,*fulfillment_sets.service_zones,*fulfillment_sets.service_zones.shipping_options,*fulfillment_sets.service_zones.shipping_options.shipping_profile", + } + ) + + const zone = stock_location?.fulfillment_sets + .find((f) => f.id === fset_id) + ?.service_zones.find((z) => z.id === zone_id) + + if (isError) { + throw error + } + + if (!isPending && !zone) { + throw json( + { message: `Service zone with ID ${zone_id} was not found` }, + 404 + ) + } + + return ( + + + {t("shipping.serviceZone.edit.title")} + + {!isPending && zone && ( + + )} + + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-option-edit/components/edit-region-form/edit-shipping-option-form.tsx b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-option-edit/components/edit-region-form/edit-shipping-option-form.tsx new file mode 100644 index 000000000000..15777b3875ad --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-option-edit/components/edit-region-form/edit-shipping-option-form.tsx @@ -0,0 +1,278 @@ +import { ShippingOptionDTO } from "@medusajs/types" +import { Button, Input, RadioGroup, Select, Switch, toast } from "@medusajs/ui" +import { useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" +import * as zod from "zod" + +import { Form } from "../../../../../components/common/form" +import { + RouteDrawer, + useRouteModal, +} from "../../../../../components/route-modal" +import { useShippingProfiles } from "../../../../../hooks/api/shipping-profiles" +import { useUpdateShippingOptions } from "../../../../../hooks/api/shipping-options" +import { useFulfillmentProviders } from "../../../../../hooks/api/fulfillment-providers" +import { isOptionEnabledInStore } from "../../../../../lib/shipping-options" +import { formatProvider } from "../../../../../lib/format-provider" +import { pick } from "../../../../../lib/common" + +enum ShippingAllocation { + FlatRate = "flat", + Calculated = "calculated", +} + +type EditShippingOptionFormProps = { + isReturn?: boolean + shippingOption: ShippingOptionDTO +} + +const EditShippingOptionSchema = zod.object({ + name: zod.string().min(1), + price_type: zod.nativeEnum(ShippingAllocation), + enabled_in_store: zod.boolean().optional(), + shipping_profile_id: zod.string(), + provider_id: zod.string(), +}) + +export const EditShippingOptionForm = ({ + shippingOption, + isReturn, +}: EditShippingOptionFormProps) => { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + + const { shipping_profiles: shippingProfiles } = useShippingProfiles({ + limit: 999, + }) + + const { fulfillment_providers = [] } = useFulfillmentProviders({ + is_enabled: true, + }) + + const form = useForm>({ + defaultValues: { + name: shippingOption.name, + price_type: shippingOption.price_type as ShippingAllocation, + enabled_in_store: isOptionEnabledInStore(shippingOption), + shipping_profile_id: shippingOption.shipping_profile_id, + provider_id: shippingOption.provider_id, + }, + }) + + const { mutateAsync, isPending: isLoading } = useUpdateShippingOptions( + shippingOption.id + ) + + const handleSubmit = form.handleSubmit(async (values) => { + const rules = shippingOption.rules.map((r) => ({ + ...pick(r, ["id", "attribute", "operator", "value"]), + })) + + const storeRule = rules.find((r) => r.attribute === "enabled_in_store") + if (!storeRule) { + // NOTE: should always exist sice we always create this rule when we create a shipping option + rules.push({ + value: values.enabled_in_store ? "true" : "false", + attribute: "enabled_in_store", + operator: "eq", + }) + } else { + storeRule.value = values.enabled_in_store ? "true" : "false" + } + + await mutateAsync( + { + name: values.name, + price_type: values.price_type, + shipping_profile_id: values.shipping_profile_id, + provider_id: values.provider_id, + rules, + }, + { + onSuccess: () => { + toast.success(t("general.success"), { + dismissLabel: t("actions.close"), + }) + handleSuccess() + }, + onError: (e) => { + toast.error(t("general.error"), { + description: e.message, + dismissLabel: t("actions.close"), + }) + }, + } + ) + }) + + return ( + +
+ +
+
+ { + return ( + + + {t("shipping.shippingOptions.create.allocation")} + + + + + + + + + + ) + }} + /> + +
+
+ { + return ( + + {t("fields.name")} + + + + + + ) + }} + /> + + { + return ( + + + {t("shipping.shippingOptions.create.profile")} + + + + + + ) + }} + /> + { + return ( + + + {t("shipping.shippingOptions.edit.provider")} + + + + + + ) + }} + /> +
+ +
+ ( + +
+ + {t("shipping.shippingOptions.create.enable")} + + + + +
+ + {t( + "shipping.shippingOptions.create.enableDescription" + )} + + +
+ )} + /> +
+
+
+
+
+ +
+ + + + +
+
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-option-edit/components/edit-region-form/index.ts b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-option-edit/components/edit-region-form/index.ts new file mode 100644 index 000000000000..0c64e93b323d --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-option-edit/components/edit-region-form/index.ts @@ -0,0 +1 @@ +export * from "./edit-shipping-option-form" diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-option-edit/index.ts b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-option-edit/index.ts new file mode 100644 index 000000000000..bbc89cc44d8e --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-option-edit/index.ts @@ -0,0 +1 @@ +export { ShippingOptionEdit as Component } from "./shipping-option-edit" diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-option-edit/shipping-option-edit.tsx b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-option-edit/shipping-option-edit.tsx new file mode 100644 index 000000000000..6af2d482a26c --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-option-edit/shipping-option-edit.tsx @@ -0,0 +1,44 @@ +import { Heading } from "@medusajs/ui" +import { useTranslation } from "react-i18next" +import { json, useParams, useSearchParams } from "react-router-dom" + +import { RouteDrawer } from "../../../components/route-modal" +import { EditShippingOptionForm } from "./components/edit-region-form" +import { useShippingOptions } from "../../../hooks/api/shipping-options" + +export const ShippingOptionEdit = () => { + const { t } = useTranslation() + const [searchParams] = useSearchParams() + + const { location_id, fset_id, zone_id, so_id } = useParams() + const isReturn = searchParams.has("is_return") + + const { shipping_options, isPending, isError, error } = useShippingOptions() + + const shippingOption = shipping_options?.find((so) => so.id === so_id) + + if (isError) { + throw error + } + + if (!isPending && !shippingOption) { + throw json( + { message: `Shipping option with ID ${so_id} was not found` }, + 404 + ) + } + + return ( + + + {t("shipping.shippingOptions.edit.title")} + + {!isPending && shippingOption && ( + + )} + + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-options-create/components/create-shipping-options-form/create-shipping-options-form.tsx b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-options-create/components/create-shipping-options-form/create-shipping-options-form.tsx index 4a1446b95d65..4698810e5104 100644 --- a/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-options-create/components/create-shipping-options-form/create-shipping-options-form.tsx +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-options-create/components/create-shipping-options-form/create-shipping-options-form.tsx @@ -27,6 +27,9 @@ import { CreateShippingOptionsPricesForm } from "./create-shipping-options-price import { useCreateShippingOptions } from "../../../../../hooks/api/shipping-options" import { useShippingProfiles } from "../../../../../hooks/api/shipping-profiles" import { getDbAmount } from "../../../../../lib/money-amount-helpers" +import { useFulfillmentProviders } from "../../../../../hooks/api/fulfillment-providers" +import { formatProvider } from "../../../../../lib/format-provider" +import { useRegions } from "../../../../../hooks/api/regions" enum Tab { DETAILS = "details", @@ -45,29 +48,42 @@ type StepStatus = { const CreateServiceZoneSchema = zod.object({ name: zod.string().min(1), price_type: zod.nativeEnum(ShippingAllocation), - enable_in_store: zod.boolean().optional(), + enabled_in_store: zod.boolean().optional(), shipping_profile_id: zod.string(), + provider_id: zod.string().min(1), region_prices: zod.record(zod.string(), zod.string().optional()), currency_prices: zod.record(zod.string(), zod.string().optional()), }) type CreateServiceZoneFormProps = { zone: ServiceZoneDTO + isReturn?: boolean } export function CreateShippingOptionsForm({ zone, + isReturn, }: CreateServiceZoneFormProps) { const { t } = useTranslation() const { handleSuccess } = useRouteModal() const [tab, setTab] = React.useState(Tab.DETAILS) + const { fulfillment_providers = [] } = useFulfillmentProviders({ + is_enabled: true, + }) + + const { regions = [] } = useRegions({ + limit: 999, + fields: "id,currency_code", + }) + const form = useForm>({ defaultValues: { name: "", price_type: ShippingAllocation.FlatRate, - enable_in_store: true, + enabled_in_store: true, shipping_profile_id: "", + provider_id: "", region_prices: {}, currency_prices: {}, }, @@ -97,18 +113,41 @@ export function CreateShippingOptionsForm({ }) .filter((o) => !!o.amount) - /** - * TODO: region prices - */ - // Object.entries(data.region_prices).map(([region_id, value]) => {}) + const regionsMap = new Map(regions.map((r) => [r.id, r.currency_code])) + + const regionPrices = Object.entries(data.region_prices) + .map(([region_id, value]) => { + const code = regionsMap.get(region_id) + + const amount = + value === "" ? undefined : getDbAmount(Number(value), code) + + return { + region_id, + amount: amount, + } + }) + .filter((o) => !!o.amount) await createShippingOption({ name: data.name, price_type: data.price_type, service_zone_id: zone.id, shipping_profile_id: data.shipping_profile_id, - provider_id: "manual_test-provider", // TODO: FETCH PROVIDERS - prices: [...currencyPrices], + provider_id: data.provider_id, + prices: [...currencyPrices, ...regionPrices], + rules: [ + { + value: isReturn ? '"true"' : '"false"', // we want JSONB saved as string + attribute: "is_return", + operator: "eq", + }, + { + value: data.enabled_in_store ? '"true"' : '"false"', // we want JSONB saved as string + attribute: "enabled_in_store", + operator: "eq", + }, + ], type: { // TODO: FETCH TYPES label: "Type label", @@ -117,7 +156,7 @@ export function CreateShippingOptionsForm({ }, }) - handleSuccess("/shipping") + handleSuccess() }) const [status, setStatus] = React.useState({ @@ -141,7 +180,9 @@ export function CreateShippingOptionsForm({ }, [tab]) const canMoveToPricing = - form.watch("name").length && form.watch("shipping_profile_id") + form.watch("name").length && + form.watch("shipping_profile_id") && + form.watch("provider_id") useEffect(() => { if (form.formState.isDirty) { @@ -244,19 +285,26 @@ export function CreateShippingOptionsForm({
- {t("shipping.shippingOptions.create.title", { - zone: zone.name, - })} + {t( + `shipping.${ + isReturn ? "returnOptions" : "shippingOptions" + }.create.title`, + { + zone: zone.name, + } + )} -
- - {t("shipping.shippingOptions.create.subtitle")} - - - {t("shipping.shippingOptions.create.description")} - -
+ {!isReturn && ( +
+ + {t("shipping.shippingOptions.create.subtitle")} + + + {t("shipping.shippingOptions.create.description")} + +
+ )} -
+
+ { + return ( + + {t("fields.name")} + + + + + + ) + }} + /> +
+ +
{ + name="shipping_profile_id" + render={({ field: { onChange, ...field } }) => { return ( - {t("fields.name")} + + {t("shipping.shippingOptions.create.profile")} + - + - ) }} @@ -319,12 +400,12 @@ export function CreateShippingOptionsForm({ { return ( - {t("shipping.shippingOptions.create.profile")} + {t("shipping.shippingOptions.edit.provider")}