From 0a9b9b073dd2d3f4aa5e5cb1c16e2221a7200e0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frane=20Poli=C4=87?= <16856471+fPolic@users.noreply.github.com> Date: Tue, 16 Apr 2024 15:42:56 +0200 Subject: [PATCH] feat(dashboard): shipping management (#6995) **What** - shipping flow - shipping profile pages - delete fulfillment set endpoint - delete shipping profile endpoint --- .changeset/twenty-stingrays-work.md | 7 + .../__tests__/admin/fulfillment-sets.spec.ts | 30 +- .../__tests__/admin/shipping-profile.spec.ts | 20 +- .../public/locales/en-US/translation.json | 85 ++++ .../layout/main-layout/main-layout.tsx | 12 + .../src/hooks/api/shipping-options.ts | 47 +++ .../src/hooks/api/shipping-profiles.tsx | 80 ++++ .../src/hooks/api/stock-locations.tsx | 84 +++- .../dashboard/src/lib/client/client.ts | 4 + .../src/lib/client/shipping-options.ts | 21 + .../src/lib/client/shipping-profiles.ts | 27 ++ .../src/lib/client/stock-locations.ts | 40 ++ .../src/providers/router-provider/v2.tsx | 53 +++ .../dashboard/src/types/api-payloads.ts | 12 + .../dashboard/src/types/api-responses.ts | 17 +- .../dashboard/src/v2-routes/shipping/const.ts | 0 .../components/location/index.ts | 1 + .../components/location/location.tsx | 392 +++++++++++++++++ .../shipping/locations-list/index.ts | 2 + .../shipping/locations-list/loader.ts | 26 ++ .../shipping/locations-list/location-list.tsx | 56 +++ .../create-service-zone-form.tsx | 320 ++++++++++++++ .../create-service-zone-form/index.ts | 1 + .../shipping/service-zone-create/index.ts | 2 + .../shipping/service-zone-create/loader.ts | 24 ++ .../service-zone-create.tsx | 29 ++ .../create-shipping-options-form.tsx | 395 ++++++++++++++++++ .../create-shipping-options-prices-form.tsx | 152 +++++++ .../create-shipping-options-form/index.ts | 1 + .../shipping/shipping-options-create/index.ts | 2 + .../shipping-options-create/loader.ts | 25 ++ .../shipping-options-create.tsx | 26 ++ .../create-shipping-profile-form.tsx | 110 +++++ .../create-shipping-profile-form/index.ts | 1 + .../shipping/shipping-profile-create/index.ts | 1 + .../shipping-profile-create.tsx | 12 + .../shipping-profile-list-table/index.ts | 1 + .../shipping-options-row-actions.tsx | 53 +++ .../shipping-profile-list-table.tsx | 68 +++ .../use-shipping-profiles-table-columns.tsx | 30 ++ .../use-shipping-profiles-table-query.tsx | 21 + .../shipping/shipping-profiles-list/index.ts | 1 + .../shipping-profile-list.tsx | 11 + .../steps/delete-fulfillment-sets.ts | 28 ++ .../core-flows/src/fulfillment/steps/index.ts | 1 + .../workflows/delete-fulfillment-sets.ts | 17 + .../src/fulfillment/workflows/index.ts | 1 + packages/core-flows/src/index.ts | 1 + .../core-flows/src/shipping-profile/index.ts | 3 + .../steps/delete-shipping-profile.ts | 28 ++ .../src/shipping-profile/steps/index.ts | 1 + .../workflows/delete-shipping-profile.ts | 18 + .../src/shipping-profile/workflows/index.ts | 1 + .../admin/fulfillment-sets/[id]/route.ts | 40 ++ .../admin/fulfillment-sets/middlewares.ts | 5 + .../admin/shipping-profiles/[id]/route.ts | 37 +- packages/types/src/common/common.ts | 4 +- .../types/src/http/common/deleted-response.ts | 2 +- .../src/http/fulfillment/admin/fulfillment.ts | 7 + .../http/fulfillment/admin/shipping-option.ts | 3 +- .../fulfillment/admin/shipping-profile.ts | 8 +- packages/types/src/stock-location/common.ts | 6 + 62 files changed, 2501 insertions(+), 12 deletions(-) create mode 100644 .changeset/twenty-stingrays-work.md create mode 100644 packages/admin-next/dashboard/src/hooks/api/shipping-options.ts create mode 100644 packages/admin-next/dashboard/src/hooks/api/shipping-profiles.tsx create mode 100644 packages/admin-next/dashboard/src/lib/client/shipping-options.ts create mode 100644 packages/admin-next/dashboard/src/lib/client/shipping-profiles.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/shipping/const.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/shipping/locations-list/components/location/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/shipping/locations-list/components/location/location.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/shipping/locations-list/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/shipping/locations-list/loader.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/shipping/locations-list/location-list.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-create/components/create-service-zone-form/create-service-zone-form.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-create/components/create-service-zone-form/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-create/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-create/loader.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-create/service-zone-create.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/shipping/shipping-options-create/components/create-shipping-options-form/create-shipping-options-form.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/shipping/shipping-options-create/components/create-shipping-options-form/create-shipping-options-prices-form.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/shipping/shipping-options-create/components/create-shipping-options-form/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/shipping/shipping-options-create/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/shipping/shipping-options-create/loader.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/shipping/shipping-options-create/shipping-options-create.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profile-create/components/create-shipping-profile-form/create-shipping-profile-form.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profile-create/components/create-shipping-profile-form/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profile-create/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profile-create/shipping-profile-create.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profiles-list/components/shipping-profile-list-table/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profiles-list/components/shipping-profile-list-table/shipping-options-row-actions.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profiles-list/components/shipping-profile-list-table/shipping-profile-list-table.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profiles-list/components/shipping-profile-list-table/use-shipping-profiles-table-columns.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profiles-list/components/shipping-profile-list-table/use-shipping-profiles-table-query.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profiles-list/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profiles-list/shipping-profile-list.tsx create mode 100644 packages/core-flows/src/fulfillment/steps/delete-fulfillment-sets.ts create mode 100644 packages/core-flows/src/fulfillment/workflows/delete-fulfillment-sets.ts create mode 100644 packages/core-flows/src/shipping-profile/index.ts create mode 100644 packages/core-flows/src/shipping-profile/steps/delete-shipping-profile.ts create mode 100644 packages/core-flows/src/shipping-profile/steps/index.ts create mode 100644 packages/core-flows/src/shipping-profile/workflows/delete-shipping-profile.ts create mode 100644 packages/core-flows/src/shipping-profile/workflows/index.ts create mode 100644 packages/medusa/src/api-v2/admin/fulfillment-sets/[id]/route.ts diff --git a/.changeset/twenty-stingrays-work.md b/.changeset/twenty-stingrays-work.md new file mode 100644 index 0000000000..5d15c7c002 --- /dev/null +++ b/.changeset/twenty-stingrays-work.md @@ -0,0 +1,7 @@ +--- +"@medusajs/core-flows": patch +"@medusajs/medusa": patch +"@medusajs/types": patch +--- + +feat(medusa, core-flows,types): delete fulfillment set, delete shipping profile diff --git a/integration-tests/api/__tests__/admin/fulfillment-sets.spec.ts b/integration-tests/api/__tests__/admin/fulfillment-sets.spec.ts index 0682db48da..8551566adc 100644 --- a/integration-tests/api/__tests__/admin/fulfillment-sets.spec.ts +++ b/integration-tests/api/__tests__/admin/fulfillment-sets.spec.ts @@ -1,5 +1,8 @@ import { ModuleRegistrationName } from "@medusajs/modules-sdk" -import { IFulfillmentModuleService } from "@medusajs/types" +import { + IFulfillmentModuleService, + IStockLocationServiceNext, +} from "@medusajs/types" import { adminHeaders, createAdminUser, @@ -15,7 +18,7 @@ medusaIntegrationTestRunner({ }, testSuite: ({ dbConnection, getContainer, api }) => { let appContainer - let service: IFulfillmentModuleService + let service: IStockLocationServiceNext beforeEach(async () => { appContainer = getContainer() @@ -25,6 +28,29 @@ medusaIntegrationTestRunner({ service = appContainer.resolve(ModuleRegistrationName.STOCK_LOCATION) }) + describe("POST /admin/fulfillment-sets/:id", () => { + it("should delete a fulfillment set", async () => { + const fulfillmentService: IFulfillmentModuleService = + appContainer.resolve(ModuleRegistrationName.FULFILLMENT) + + const set = await fulfillmentService.create({ + name: "Test fulfillment set", + type: "pickup", + }) + + const deleteResponse = await api.delete( + `/admin/fulfillment-sets/${set.id}`, + adminHeaders + ) + + expect(deleteResponse.data).toEqual({ + id: set.id, + object: "fulfillment_set", + deleted: true, + }) + }) + }) + describe("POST /admin/fulfillment-sets/:id/service-zones", () => { it("should create, update, and delete a service zone for a fulfillment set", async () => { const stockLocationResponse = await api.post( diff --git a/integration-tests/api/__tests__/admin/shipping-profile.spec.ts b/integration-tests/api/__tests__/admin/shipping-profile.spec.ts index 1ab488c67e..358cc67438 100644 --- a/integration-tests/api/__tests__/admin/shipping-profile.spec.ts +++ b/integration-tests/api/__tests__/admin/shipping-profile.spec.ts @@ -35,7 +35,7 @@ medusaIntegrationTestRunner({ }) describe("Admin - Shipping Profiles", () => { - // TODO: Missing update and delete tests + // TODO: Missing update tests it("should test the entire lifecycle of a shipping profile", async () => { const payload = { name: "test-profile-2023", @@ -84,6 +84,23 @@ medusaIntegrationTestRunner({ created_at: expect.any(String), }) ) + + const { data } = await api.delete( + `/admin/shipping-profiles/${shipping_profile.id}`, + adminHeaders + ) + + expect(data).toEqual({ + id: retrievedProfile.id, + object: "shipping_profile", + deleted: true, + }) + + await api + .get(`/admin/shipping-profiles/${shipping_profile.id}`, adminHeaders) + .catch((err) => { + expect(err.response.status).toEqual(404) + }) }) }) @@ -205,7 +222,6 @@ medusaIntegrationTestRunner({ }) describe("DELETE /admin/shipping-profiles", () => { - // TODO: Delete is not added yet it("deletes a shipping profile", async () => { expect.assertions(2) 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 3cf6cd8e36..d4a5555ab3 100644 --- a/packages/admin-next/dashboard/public/locales/en-US/translation.json +++ b/packages/admin-next/dashboard/public/locales/en-US/translation.json @@ -565,6 +565,90 @@ "invalidEmail": "Email must be a valid email address." } }, + "shipping": { + "title": "Shipping & Delivery", + "domain": "Shipping & Delivery", + "description": "Choose where you ship and how much you charge for shipping at checkout. Define shipping options specific for your locations.", + "from": "Shipping from", + "add": "Add shipping", + "connectProvider": "Connect provider", + "addZone": "Add shipping zone", + "enablePickup": "Enable pickup", + "enableDelivery": "Enable delivery", + "noRecords" : { + "action": "Add Location", + "title": "No inventory locations", + "message": "Please create an invnetory location first." + }, + "create": { + "title": "Add shipping for {{location}}", + "delivery": "Delivery", + "pickup": "Pickup", + "type": "Shipping type" + }, + "fulfillmentSet": { + "placeholder": "Not covered by any shipping zones.", + "delete": "Delete shipping", + "create": { + "title": "Add service zone for {{fulfillmentSet}}" + }, + "addZone": "Add service zone", + "pickup": { + "title": "Pickup in", + "enable": "Enable pickup" + }, + "delivery": { + "title": "Shipping to", + "enable": "Enable delivery" + } + }, + "serviceZone": { + "create": { + "title": "Add service zone for {{fulfillmentSet}}", + "subtitle": "Service zone", + "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" + }, + "editPrices": "Edit prices", + "editOption": "Edit option", + "optionsLength_one": "shipping option", + "optionsLength_other": "shipping options", + "shippingOptionsPlaceholder": "Not covered by any shipping options.", + "addShippingOptions": "Add shipping options", + "shippingOptions": "Shipping options", + "returnOptions": "Return options", + "areas": { + "title": "Areas affected by this rule", + "description": "Select the geographical areas where this shipping zone should apply.", + "manage": "Manage areas", + "error": "Please select at least one country for this service zone." + } + }, + "shippingOptions": { + "create": { + "title": "Create shipping options for {{zone}}", + "subtitle": "General information", + "description": "To start selling, all you need is a name and a price", + "details": "Details", + "pricing": "Pricing", + "allocation": "Shipping amount", + "fixed": "Fixed", + "fixedDescription": "Shipping option's price is always the same amount.", + "enable": "Enable in store", + "enableDescription": "Enable or disable the shipping option visiblity in store", + "calculated": "Calculated", + "calculatedDescription": "Shipping option's price is calculated by the fulfillment provider.", + "profile": "Shipping profile" + } + } + }, + "shippingProfile": { + "domain": "Shipping Profiles", + "title": "Create a shipping profile", + "detailsHint": "Specify the details of the shipping profile", + "deleteWaring": "You are about to delete the profile: {{name}}. This action cannot be undone.", + "typeHint": "Enter shipping profile type, for example: Express, Freight, etc." + }, "discounts": { "domain": "Discounts", "startDate": "Start date", @@ -1283,6 +1367,7 @@ "issuedDate": "Issued date", "expiryDate": "Expiry date", "price": "Price", + "priceTemplate": "Price {{regionOrCountry}}", "height": "Height", "width": "Width", "length": "Length", 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 6f91909423..d15e9e8b02 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 @@ -2,6 +2,7 @@ import { Buildings, ChevronDownMini, CurrencyDollar, + Envelope, MinusMini, ReceiptPercent, ShoppingCart, @@ -143,6 +144,17 @@ const useCoreRoutes = (): Omit[] => { label: t("pricing.domain"), to: "/pricing", }, + { + icon: , + label: t("shipping.domain"), + to: "/shipping", + items: [ + { + label: t("shippingProfile.domain"), + to: "/shipping-profiles", + }, + ], + }, ] } diff --git a/packages/admin-next/dashboard/src/hooks/api/shipping-options.ts b/packages/admin-next/dashboard/src/hooks/api/shipping-options.ts new file mode 100644 index 0000000000..31639b95e8 --- /dev/null +++ b/packages/admin-next/dashboard/src/hooks/api/shipping-options.ts @@ -0,0 +1,47 @@ +import { useMutation, UseMutationOptions } from "@tanstack/react-query" + +import { + ShippingOptionDeleteRes, + ShippingOptionRes, +} from "../../types/api-responses" +import { CreateShippingOptionReq } from "../../types/api-payloads" +import { stockLocationsQueryKeys } from "./stock-locations" +import { queryClient } from "../../lib/medusa" +import { client } from "../../lib/client" + +export const useCreateShippingOptions = ( + options?: UseMutationOptions< + ShippingOptionRes, + Error, + CreateShippingOptionReq + > +) => { + return useMutation({ + mutationFn: (payload) => client.shippingOptions.create(payload), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ + queryKey: stockLocationsQueryKeys.lists(), + }) + + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useDeleteShippingOption = ( + optionId: string, + options?: UseMutationOptions +) => { + return useMutation({ + mutationFn: () => client.shippingOptions.delete(optionId), + onSuccess: (data: any, variables: any, context: any) => { + queryClient.invalidateQueries({ + queryKey: stockLocationsQueryKeys.lists(), + }) + + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} diff --git a/packages/admin-next/dashboard/src/hooks/api/shipping-profiles.tsx b/packages/admin-next/dashboard/src/hooks/api/shipping-profiles.tsx new file mode 100644 index 0000000000..a356384106 --- /dev/null +++ b/packages/admin-next/dashboard/src/hooks/api/shipping-profiles.tsx @@ -0,0 +1,80 @@ +import { + QueryKey, + useMutation, + UseMutationOptions, + useQuery, + UseQueryOptions, +} from "@tanstack/react-query" +import { CreateShippingProfileReq } from "../../types/api-payloads" +import { + ShippingProfileDeleteRes, + ShippingProfileListRes, + ShippingProfileRes, +} from "../../types/api-responses" + +import { client } from "../../lib/client" +import { queryClient } from "../../lib/medusa" +import { queryKeysFactory } from "../../lib/query-key-factory" + +const SHIPPING_PROFILE_QUERY_KEY = "shipping_profile" as const +export const shippingProfileQueryKeys = queryKeysFactory( + SHIPPING_PROFILE_QUERY_KEY +) + +export const useCreateShippingProfile = ( + options?: UseMutationOptions< + ShippingProfileRes, + Error, + CreateShippingProfileReq + > +) => { + return useMutation({ + mutationFn: (payload) => client.shippingProfiles.create(payload), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ + queryKey: shippingProfileQueryKeys.lists(), + }) + + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useShippingProfiles = ( + query?: Record, + options?: Omit< + UseQueryOptions< + ShippingProfileListRes, + Error, + ShippingProfileListRes, + QueryKey + >, + "queryFn" | "queryKey" + > +) => { + const { data, ...rest } = useQuery({ + queryFn: () => client.shippingProfiles.list(query), + queryKey: shippingProfileQueryKeys.list(query), + ...options, + }) + + return { ...data, ...rest } +} + +export const useDeleteShippingProfile = ( + profileId: string, + options?: UseMutationOptions +) => { + return useMutation({ + mutationFn: () => client.shippingProfiles.delete(profileId), + onSuccess: (data: any, variables: any, context: any) => { + queryClient.invalidateQueries({ + queryKey: shippingProfileQueryKeys.lists(), + }) + + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} 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 a1205fa462..177923ef28 100644 --- a/packages/admin-next/dashboard/src/hooks/api/stock-locations.tsx +++ b/packages/admin-next/dashboard/src/hooks/api/stock-locations.tsx @@ -10,17 +10,23 @@ import { client } from "../../lib/client" import { queryClient } from "../../lib/medusa" import { queryKeysFactory } from "../../lib/query-key-factory" import { + CreateFulfillmentSetReq, + CreateServiceZoneReq, CreateStockLocationReq, UpdateStockLocationReq, } from "../../types/api-payloads" import { + FulfillmentSetDeleteRes, + ServiceZoneDeleteRes, StockLocationDeleteRes, StockLocationListRes, StockLocationRes, } from "../../types/api-responses" const STOCK_LOCATIONS_QUERY_KEY = "stock_locations" as const -const stockLocationsQueryKeys = queryKeysFactory(STOCK_LOCATIONS_QUERY_KEY) +export const stockLocationsQueryKeys = queryKeysFactory( + STOCK_LOCATIONS_QUERY_KEY +) export const useStockLocation = ( id: string, @@ -115,3 +121,79 @@ export const useDeleteStockLocation = ( ...options, }) } + +export const useCreateFulfillmentSet = ( + locationId: string, + options?: UseMutationOptions +) => { + return useMutation({ + mutationFn: (payload) => + client.stockLocations.createFulfillmentSet(locationId, payload), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ + queryKey: stockLocationsQueryKeys.lists(), + }) + queryClient.invalidateQueries({ + queryKey: stockLocationsQueryKeys.detail(locationId), + }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useCreateServiceZone = ( + locationId: string, + fulfillmentSetId: string, + options?: UseMutationOptions +) => { + return useMutation({ + mutationFn: (payload) => + client.stockLocations.createServiceZone(fulfillmentSetId, payload), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ + queryKey: stockLocationsQueryKeys.detail(locationId), + }) + queryClient.invalidateQueries({ + queryKey: stockLocationsQueryKeys.lists(), + }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useDeleteFulfillmentSet = ( + setId: string, + options?: UseMutationOptions +) => { + return useMutation({ + mutationFn: () => client.stockLocations.deleteFulfillmentSet(setId), + onSuccess: (data: any, variables: any, context: any) => { + queryClient.invalidateQueries({ + queryKey: stockLocationsQueryKeys.lists(), + }) + + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useDeleteServiceZone = ( + setId: string, + zoneId: string, + options?: UseMutationOptions +) => { + return useMutation({ + mutationFn: () => client.stockLocations.deleteServiceZone(setId, zoneId), + onSuccess: (data: any, variables: any, context: any) => { + queryClient.invalidateQueries({ + queryKey: stockLocationsQueryKeys.lists(), + }) + + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} diff --git a/packages/admin-next/dashboard/src/lib/client/client.ts b/packages/admin-next/dashboard/src/lib/client/client.ts index 58fe10bcf7..e0907a5e46 100644 --- a/packages/admin-next/dashboard/src/lib/client/client.ts +++ b/packages/admin-next/dashboard/src/lib/client/client.ts @@ -15,12 +15,14 @@ import { products } from "./products" import { promotions } from "./promotions" import { regions } from "./regions" import { salesChannels } from "./sales-channels" +import { shippingOptions } from "./shipping-options" import { stockLocations } from "./stock-locations" import { stores } from "./stores" import { tags } from "./tags" import { taxes } from "./taxes" import { users } from "./users" import { workflowExecutions } from "./workflow-executions" +import { shippingProfiles } from "./shipping-profiles" export const client = { auth: auth, @@ -35,6 +37,8 @@ export const client = { payments: payments, stores: stores, salesChannels: salesChannels, + shippingOptions: shippingOptions, + shippingProfiles: shippingProfiles, tags: tags, users: users, regions: regions, diff --git a/packages/admin-next/dashboard/src/lib/client/shipping-options.ts b/packages/admin-next/dashboard/src/lib/client/shipping-options.ts new file mode 100644 index 0000000000..401d8a5016 --- /dev/null +++ b/packages/admin-next/dashboard/src/lib/client/shipping-options.ts @@ -0,0 +1,21 @@ +import { deleteRequest, postRequest } from "./common" +import { + ShippingOptionDeleteRes, + ShippingOptionRes, +} from "../../types/api-responses" +import { CreateShippingOptionReq } from "../../types/api-payloads" + +async function createShippingOptions(payload: CreateShippingOptionReq) { + return postRequest(`/admin/shipping-options`, payload) +} + +async function deleteShippingOption(optionId: string) { + return deleteRequest( + `/admin/shipping-options/${optionId}` + ) +} + +export const shippingOptions = { + create: createShippingOptions, + delete: deleteShippingOption, +} diff --git a/packages/admin-next/dashboard/src/lib/client/shipping-profiles.ts b/packages/admin-next/dashboard/src/lib/client/shipping-profiles.ts new file mode 100644 index 0000000000..9dbbcce73b --- /dev/null +++ b/packages/admin-next/dashboard/src/lib/client/shipping-profiles.ts @@ -0,0 +1,27 @@ +import { deleteRequest, getRequest, postRequest } from "./common" +import { CreateShippingProfileReq } from "../../types/api-payloads" +import { + ShippingProfileDeleteRes, + ShippingProfileListRes, + ShippingProfileRes, +} from "../../types/api-responses" + +async function createShippingProfile(payload: CreateShippingProfileReq) { + return postRequest(`/admin/shipping-profiles`, payload) +} + +async function listShippingProfiles(query?: Record) { + return getRequest(`/admin/shipping-profiles`, query) +} + +async function deleteShippingProfile(profileId: string) { + return deleteRequest( + `/admin/shipping-profiles/${profileId}` + ) +} + +export const shippingProfiles = { + create: createShippingProfile, + list: listShippingProfiles, + delete: deleteShippingProfile, +} 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 47af3490c0..818cc1e6f9 100644 --- a/packages/admin-next/dashboard/src/lib/client/stock-locations.ts +++ b/packages/admin-next/dashboard/src/lib/client/stock-locations.ts @@ -1,8 +1,12 @@ import { + CreateFulfillmentSetReq, + CreateServiceZoneReq, CreateStockLocationReq, UpdateStockLocationReq, } from "../../types/api-payloads" import { + FulfillmentSetDeleteRes, + ServiceZoneDeleteRes, StockLocationDeleteRes, StockLocationListRes, StockLocationRes, @@ -21,6 +25,26 @@ async function createStockLocation(payload: CreateStockLocationReq) { return postRequest(`/admin/stock-locations`, payload) } +async function createFulfillmentSet( + locationId: string, + payload: CreateFulfillmentSetReq +) { + return postRequest( + `/admin/stock-locations/${locationId}/fulfillment-sets`, + payload + ) +} + +async function createServiceZone( + fulfillmentSetId: string, + payload: CreateServiceZoneReq +) { + return postRequest( + `/admin/fulfillment-sets/${fulfillmentSetId}/service-zones`, + payload + ) +} + async function updateStockLocation( id: string, payload: UpdateStockLocationReq @@ -32,10 +56,26 @@ async function deleteStockLocation(id: string) { return deleteRequest(`/admin/stock-locations/${id}`) } +async function deleteFulfillmentSet(setId: string) { + return deleteRequest( + `/admin/fulfillment-sets/${setId}` + ) +} + +async function deleteServiceZone(setId: string, zoneId: string) { + return deleteRequest( + `/admin/fulfillment-sets/${setId}/service-zones/${zoneId}` + ) +} + export const stockLocations = { list: listStockLocations, retrieve: retrieveStockLocation, create: createStockLocation, update: updateStockLocation, delete: deleteStockLocation, + createFulfillmentSet, + deleteFulfillmentSet, + createServiceZone, + deleteServiceZone, } 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 0c4b2bf749..990769947b 100644 --- a/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx +++ b/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx @@ -291,6 +291,59 @@ 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: "shipping-profiles", + lazy: () => + import("../../v2-routes/shipping/shipping-profiles-list"), + handle: { + crumb: () => "Shipping Profiles", + }, + children: [ + { + path: "create", + lazy: () => + import("../../v2-routes/shipping/shipping-profile-create"), + }, + ], + }, { path: "/customers", handle: { diff --git a/packages/admin-next/dashboard/src/types/api-payloads.ts b/packages/admin-next/dashboard/src/types/api-payloads.ts index 6f160a7844..0792736f2a 100644 --- a/packages/admin-next/dashboard/src/types/api-payloads.ts +++ b/packages/admin-next/dashboard/src/types/api-payloads.ts @@ -6,6 +6,7 @@ import { CreateApiKeyDTO, CreateCampaignDTO, CreateCustomerDTO, + CreateFulfillmentSetDTO, CreateInviteDTO, CreatePriceListDTO, CreateProductCollectionDTO, @@ -13,6 +14,9 @@ import { CreatePromotionRuleDTO, CreateRegionDTO, CreateSalesChannelDTO, + CreateServiceZoneDTO, + CreateShippingOptionDTO, + CreateShippingProfileDTO, CreateStockLocationInput, InventoryNext, UpdateApiKeyDTO, @@ -62,6 +66,14 @@ export type CreateInviteReq = CreateInviteDTO // Stock Locations export type CreateStockLocationReq = CreateStockLocationInput export type UpdateStockLocationReq = UpdateStockLocationInput +export type CreateFulfillmentSetReq = CreateFulfillmentSetDTO +export type CreateServiceZoneReq = CreateServiceZoneDTO + +// Shipping Options +export type CreateShippingOptionReq = CreateShippingOptionDTO + +// Shipping Profile +export type CreateShippingProfileReq = CreateShippingProfileDTO // Product Collections export type CreateProductCollectionReq = CreateProductCollectionDTO diff --git a/packages/admin-next/dashboard/src/types/api-responses.ts b/packages/admin-next/dashboard/src/types/api-responses.ts index ffbf536684..214d9f8547 100644 --- a/packages/admin-next/dashboard/src/types/api-responses.ts +++ b/packages/admin-next/dashboard/src/types/api-responses.ts @@ -18,6 +18,8 @@ import { PromotionDTO, RegionDTO, SalesChannelDTO, + ShippingOptionDTO, + ShippingProfileDTO, StockLocationAddressDTO, StockLocationDTO, StoreDTO, @@ -128,8 +130,21 @@ export type StockLocationListRes = { stock_locations: ExtendedStockLocationDTO[] } & ListRes export type StockLocationDeleteRes = DeleteRes +export type FulfillmentSetDeleteRes = DeleteRes +export type ServiceZoneDeleteRes = DeleteRes -// Worfklow Executions +// Shipping options +export type ShippingOptionRes = { shipping_option: ShippingOptionDTO } +export type ShippingOptionDeleteRes = DeleteRes + +// Shipping profile +export type ShippingProfileRes = { shipping_profile: ShippingProfileDTO } +export type ShippingProfileListRes = { + shipping_profiles: ShippingProfileDTO[] +} & ListRes +export type ShippingProfileDeleteRes = DeleteRes + +// Workflow Executions export type WorkflowExecutionRes = { workflow_execution: WorkflowExecutionDTO } export type WorkflowExecutionListRes = { workflow_executions: WorkflowExecutionDTO[] diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/const.ts b/packages/admin-next/dashboard/src/v2-routes/shipping/const.ts new file mode 100644 index 0000000000..e69de29bb2 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/locations-list/components/location/index.ts new file mode 100644 index 0000000000..2774358cc3 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/locations-list/components/location/index.ts @@ -0,0 +1 @@ +export * from "./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 new file mode 100644 index 0000000000..1d1b42ba73 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/locations-list/components/location/location.tsx @@ -0,0 +1,392 @@ +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/index.ts b/packages/admin-next/dashboard/src/v2-routes/shipping/locations-list/index.ts new file mode 100644 index 0000000000..59a7340e6f --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/locations-list/index.ts @@ -0,0 +1,2 @@ +export { shippingListLoader as loader } from "./loader" +export { LocationList as Component } from "./location-list" diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/locations-list/loader.ts b/packages/admin-next/dashboard/src/v2-routes/shipping/locations-list/loader.ts new file mode 100644 index 0000000000..ce6236e68d --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/locations-list/loader.ts @@ -0,0 +1,26 @@ +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" + +const shippingListQuery = () => ({ + queryKey: adminStockLocationsKeys.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", + }), +}) + +export const shippingListLoader = async (_: LoaderFunctionArgs) => { + const query = shippingListQuery() + + return ( + queryClient.getQueryData(query.queryKey) ?? + (await queryClient.fetchQuery(query)) + ) +} 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 new file mode 100644 index 0000000000..7815b4823a --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/locations-list/location-list.tsx @@ -0,0 +1,56 @@ +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 new file mode 100644 index 0000000000..e3510ccc9f --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-create/components/create-service-zone-form/create-service-zone-form.tsx @@ -0,0 +1,320 @@ +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { + ColumnDef, + createColumnHelper, + RowSelectionState, +} from "@tanstack/react-table" +import * as zod from "zod" + +import { Alert, Button, Checkbox, Heading, Input, Text } from "@medusajs/ui" +import { FulfillmentSetDTO, RegionCountryDTO, RegionDTO } from "@medusajs/types" +import { useTranslation } from "react-i18next" +import { Map } from "@medusajs/icons" + +import { + RouteFocusModal, + useRouteModal, +} from "../../../../../components/route-modal" +import { Form } from "../../../../../components/common/form" +import { SplitView } from "../../../../../components/layout/split-view" +import { useCreateServiceZone } from "../../../../../hooks/api/stock-locations" +import { useEffect, useMemo, useState } from "react" +import { useCountryTableQuery } from "../../../../regions/common/hooks/use-country-table-query" +import { useCountries } from "../../../../regions/common/hooks/use-countries" +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 + +const ConditionsFooter = ({ onSave }: { onSave: () => void }) => { + const { t } = useTranslation() + + return ( +
+ + + + +
+ ) +} + +const CreateServiceZoneSchema = zod.object({ + name: zod.string().min(1), + countries: zod.array(zod.string().length(2)).min(1), +}) + +type CreateServiceZoneFormProps = { + fulfillmentSet: FulfillmentSetDTO + locationId: string +} + +export function CreateServiceZoneForm({ + fulfillmentSet, + locationId, +}: CreateServiceZoneFormProps) { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + const [open, setOpen] = useState(false) + const [rowSelection, setRowSelection] = useState({}) + + const form = useForm>({ + defaultValues: { + name: "", + countries: [], + }, + resolver: zodResolver(CreateServiceZoneSchema), + }) + + const { mutateAsync: createServiceZone, isPending: isLoading } = + useCreateServiceZone(locationId, fulfillmentSet.id) + + const handleSubmit = form.handleSubmit(async (data) => { + await createServiceZone({ + name: data.name, + geo_zones: data.countries.map((iso2) => ({ + country_code: iso2, + type: "country", + })), + }) + + handleSuccess("/shipping") + }) + + const handleOpenChange = (open: boolean) => { + setOpen(open) + } + + const { searchParams, raw } = useCountryTableQuery({ + pageSize: PAGE_SIZE, + prefix: PREFIX, + }) + const { countries, count } = useCountries({ + countries: staticCountries.map((c, i) => ({ + display_name: c.display_name, + name: c.name, + id: i as any, + iso_2: c.iso_2, + iso_3: c.iso_3, + num_code: c.num_code, + region_id: null, + region: {} as RegionDTO, + })), + ...searchParams, + }) + + const columns = useColumns() + + const { table } = useDataTable({ + data: countries || [], + columns, + count, + enablePagination: true, + enableRowSelection: true, + getRowId: (row) => row.iso_2, + pageSize: PAGE_SIZE, + rowSelection: { + state: rowSelection, + updater: setRowSelection, + }, + prefix: PREFIX, + }) + + const onCountriesSave = () => { + form.setValue("countries", Object.keys(rowSelection)) + setOpen(false) + } + + const countriesWatch = form.watch("countries") + + const selectedCountries = useMemo(() => { + return staticCountries.filter((c) => c.iso_2 in rowSelection) + }, [countriesWatch]) + + useEffect(() => { + // set selected rows from form state on open + if (open) { + setRowSelection( + countriesWatch.reduce((acc, c) => { + acc[c] = true + return acc + }, {}) + ) + } + }, [open]) + + const showAreasError = + form.formState.errors["countries"]?.type === "too_small" + + return ( + +
+ +
+ + + + +
+
+ + + + +
+ + {t("shipping.fulfillmentSet.create.title", { + fulfillmentSet: fulfillmentSet.name, + })} + + +
+ + {t("shipping.serviceZone.create.subtitle")} + + + {t("shipping.serviceZone.create.description")} + +
+ +
+ { + return ( + + + {t("shipping.serviceZone.create.zoneName")} + + + + + + + ) + }} + /> +
+
+ + {/*AREAS*/} +
+
+ + {t("shipping.serviceZone.areas.title")} + + + {t("shipping.serviceZone.areas.description")} + +
+ +
+ {!!selectedCountries.length && ( +
+
+
+ +
+
+ c.display_name)} + /> +
+ )} + {showAreasError && ( + + {t("shipping.serviceZone.areas.error")} + + )} +
+ +
+ + +
+
+
+
+
+
+ ) +} + +const columnHelper = createColumnHelper() + +const useColumns = () => { + const base = useCountryTableColumns() + + return useMemo( + () => [ + columnHelper.display({ + id: "select", + header: ({ table }) => { + return ( + + table.toggleAllPageRowsSelected(!!value) + } + /> + ) + }, + cell: ({ row }) => { + const isPreselected = !row.getCanSelect() + + return ( + row.toggleSelected(!!value)} + onClick={(e) => { + e.stopPropagation() + }} + /> + ) + }, + }), + ...base, + ], + [base] + ) as ColumnDef[] +} diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-create/components/create-service-zone-form/index.ts b/packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-create/components/create-service-zone-form/index.ts new file mode 100644 index 0000000000..9e98b4b633 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-create/components/create-service-zone-form/index.ts @@ -0,0 +1 @@ +export * from "./create-service-zone-form" diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-create/index.ts b/packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-create/index.ts new file mode 100644 index 0000000000..6072fba7d2 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-create/index.ts @@ -0,0 +1,2 @@ +export { ServiceZoneCreate as Component } from "./service-zone-create" +export { stockLocationLoader as loader } from "./loader" 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 new file mode 100644 index 0000000000..68fee79dd9 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-create/loader.ts @@ -0,0 +1,24 @@ +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" + +const fulfillmentSetCreateQuery = (id: string) => ({ + queryKey: adminStockLocationsKeys.detail(id), + queryFn: async () => + client.stockLocations.retrieve(id, { + fields: "*fulfillment_sets", + }), +}) + +export const stockLocationLoader = async ({ params }: LoaderFunctionArgs) => { + const id = params.location_id + const query = fulfillmentSetCreateQuery(id!) + + return ( + queryClient.getQueryData(query.queryKey) ?? + (await queryClient.fetchQuery(query)) + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-create/service-zone-create.tsx b/packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-create/service-zone-create.tsx new file mode 100644 index 0000000000..981aac711a --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/service-zone-create/service-zone-create.tsx @@ -0,0 +1,29 @@ +import { useLoaderData, useParams } from "react-router-dom" + +import { RouteFocusModal } from "../../../components/route-modal" +import { CreateServiceZoneForm } from "./components/create-service-zone-form" +import { stockLocationLoader } from "./loader" + +export function ServiceZoneCreate() { + const { fset_id, location_id } = useParams() + const { stock_location: stockLocation } = useLoaderData() as Awaited< + ReturnType + > + + const fulfillmentSet = stockLocation.fulfillment_sets.find( + (f) => f.id === fset_id + ) + + if (!fulfillmentSet) { + throw new Error("Fulfillment set doesn't exist") + } + + return ( + + + + ) +} 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 new file mode 100644 index 0000000000..4a1446b95d --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-options-create/components/create-shipping-options-form/create-shipping-options-form.tsx @@ -0,0 +1,395 @@ +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import React, { useEffect } from "react" +import * as zod from "zod" + +import { + Button, + Heading, + Input, + ProgressStatus, + ProgressTabs, + RadioGroup, + Switch, + Text, + clx, + Select, +} from "@medusajs/ui" +import { ServiceZoneDTO } from "@medusajs/types" +import { useTranslation } from "react-i18next" + +import { + RouteFocusModal, + useRouteModal, +} from "../../../../../components/route-modal" +import { Form } from "../../../../../components/common/form" +import { CreateShippingOptionsPricesForm } from "./create-shipping-options-prices-form" +import { useCreateShippingOptions } from "../../../../../hooks/api/shipping-options" +import { useShippingProfiles } from "../../../../../hooks/api/shipping-profiles" +import { getDbAmount } from "../../../../../lib/money-amount-helpers" + +enum Tab { + DETAILS = "details", + PRICING = "pricing", +} + +enum ShippingAllocation { + FlatRate = "flat", + Calculated = "calculated", +} + +type StepStatus = { + [key in Tab]: ProgressStatus +} + +const CreateServiceZoneSchema = zod.object({ + name: zod.string().min(1), + price_type: zod.nativeEnum(ShippingAllocation), + enable_in_store: zod.boolean().optional(), + shipping_profile_id: zod.string(), + region_prices: zod.record(zod.string(), zod.string().optional()), + currency_prices: zod.record(zod.string(), zod.string().optional()), +}) + +type CreateServiceZoneFormProps = { + zone: ServiceZoneDTO +} + +export function CreateShippingOptionsForm({ + zone, +}: CreateServiceZoneFormProps) { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + const [tab, setTab] = React.useState(Tab.DETAILS) + + const form = useForm>({ + defaultValues: { + name: "", + price_type: ShippingAllocation.FlatRate, + enable_in_store: true, + shipping_profile_id: "", + region_prices: {}, + currency_prices: {}, + }, + resolver: zodResolver(CreateServiceZoneSchema), + }) + + const isCalculatedPriceType = + form.watch("price_type") === ShippingAllocation.Calculated + + const { mutateAsync: createShippingOption, isPending: isLoading } = + useCreateShippingOptions() + + const { shipping_profiles: shippingProfiles } = useShippingProfiles({ + limit: 999, + }) + + const handleSubmit = form.handleSubmit(async (data) => { + const currencyPrices = Object.entries(data.currency_prices) + .map(([code, value]) => { + const amount = + value === "" ? undefined : getDbAmount(Number(value), code) + + return { + currency_code: code, + amount: amount, + } + }) + .filter((o) => !!o.amount) + + /** + * TODO: region prices + */ + // Object.entries(data.region_prices).map(([region_id, value]) => {}) + + 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], + type: { + // TODO: FETCH TYPES + label: "Type label", + description: "Type description", + code: "type-code", + }, + }) + + handleSuccess("/shipping") + }) + + const [status, setStatus] = React.useState({ + [Tab.PRICING]: "not-started", + [Tab.DETAILS]: "not-started", + }) + + const onTabChange = React.useCallback(async (value: Tab) => { + setTab(value) + }, []) + + const onNext = React.useCallback(async () => { + switch (tab) { + case Tab.DETAILS: { + setTab(Tab.PRICING) + break + } + case Tab.PRICING: + break + } + }, [tab]) + + const canMoveToPricing = + form.watch("name").length && form.watch("shipping_profile_id") + + useEffect(() => { + if (form.formState.isDirty) { + setStatus((prev) => ({ ...prev, [Tab.DETAILS]: "in-progress" })) + } else { + setStatus((prev) => ({ ...prev, [Tab.DETAILS]: "not-started" })) + } + }, [form.formState.isDirty]) + + useEffect(() => { + if (tab === Tab.DETAILS && form.formState.isDirty) { + setStatus((prev) => ({ ...prev, [Tab.DETAILS]: "in-progress" })) + } + + if (tab === Tab.PRICING) { + const hasPricingSet = form + .getValues(["region_prices", "currency_prices"]) + .map(Object.keys) + .some((i) => i.length) + + setStatus((prev) => ({ + ...prev, + [Tab.DETAILS]: "completed", + [Tab.PRICING]: hasPricingSet ? "in-progress" : "not-started", + })) + } + }, [tab]) + + return ( + +
+ onTabChange(tab as Tab)} + > + + + + + {t("shipping.shippingOptions.create.details")} + + + {!isCalculatedPriceType && ( + + + {t("shipping.shippingOptions.create.pricing")} + + + )} + +
+ + + + +
+
+ + + +
+ + {t("shipping.shippingOptions.create.title", { + zone: zone.name, + })} + + +
+ + {t("shipping.shippingOptions.create.subtitle")} + + + {t("shipping.shippingOptions.create.description")} + +
+ + { + return ( + + + {t("shipping.shippingOptions.create.allocation")} + + + + + + + + + + ) + }} + /> + +
+
+ { + return ( + + {t("fields.name")} + + + + + + ) + }} + /> + + { + return ( + + + {t("shipping.shippingOptions.create.profile")} + + + + + + ) + }} + /> +
+
+ ( + +
+ + {t("shipping.shippingOptions.create.enable")} + + + + +
+ + {t( + "shipping.shippingOptions.create.enableDescription" + )} + + +
+ )} + /> +
+
+
+
+ + + + +
+
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-options-create/components/create-shipping-options-form/create-shipping-options-prices-form.tsx b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-options-create/components/create-shipping-options-form/create-shipping-options-prices-form.tsx new file mode 100644 index 0000000000..9a4a53299a --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-options-create/components/create-shipping-options-form/create-shipping-options-prices-form.tsx @@ -0,0 +1,152 @@ +import { PropsWithChildren } from "react" +import { CurrencyDTO, ProductVariantDTO, RegionDTO } from "@medusajs/types" +import { ColumnDef, createColumnHelper } from "@tanstack/react-table" +import { useEffect, useMemo, useState } from "react" +import { UseFormReturn } from "react-hook-form" +import { useTranslation } from "react-i18next" +import * as zod from "zod" + +import { CurrencyCell } from "../../../../../components/grid/grid-cells/common/currency-cell" +import { DataGrid } from "../../../../../components/grid/data-grid" +import { DataGridMeta } from "../../../../../components/grid/types" +import { useCurrencies } from "../../../../../hooks/api/currencies" +import { ExtendedProductDTO } from "../../../../../types/api-responses" +import { useRegions } from "../../../../../hooks/api/regions" +import { useStore } from "../../../../../hooks/api/store" + +const PricingCreateSchemaType = zod.record( + zod.object({ + currency_prices: zod.record(zod.string().optional()), + region_prices: zod.record(zod.string().optional()), + }) +) + +type PricingPricesFormProps = { + form: UseFormReturn +} + +enum ColumnType { + REGION = "region", + CURRENCY = "currency", +} + +type EnabledColumnRecord = Record + +export const CreateShippingOptionsPricesForm = ({ + form, +}: PricingPricesFormProps) => { + const { + store, + isLoading: isStoreLoading, + isError: isStoreError, + error: storeError, + } = useStore() + const { currencies, isLoading: isCurrenciesLoading } = useCurrencies( + { + code: store?.supported_currency_codes, + }, + { + enabled: !!store, + } + ) + + const { regions } = useRegions() + + const [enabledColumns, setEnabledColumns] = useState({}) + + useEffect(() => { + if ( + store?.default_currency_code && + Object.keys(enabledColumns).length === 0 + ) { + setEnabledColumns({ + ...enabledColumns, + [store.default_currency_code]: ColumnType.CURRENCY, + }) + } + }, [store, enabledColumns]) + + const columns = useColumns({ + currencies, + regions, + }) + + const initializing = + isStoreLoading || isCurrenciesLoading || !store || !currencies + + if (isStoreError) { + throw storeError + } + + const data = useMemo( + () => [[...(currencies || []), ...(regions || [])]], + [currencies, regions] + ) + + return ( +
+ +
+ ) +} + +const columnHelper = createColumnHelper< + ExtendedProductDTO | ProductVariantDTO +>() + +const useColumns = ({ + currencies = [], + regions = [], +}: { + currencies?: CurrencyDTO[] + regions?: RegionDTO[] +}) => { + const { t } = useTranslation() + + const colDefs: ColumnDef[] = + useMemo(() => { + return [ + ...currencies.map((currency) => { + return columnHelper.display({ + header: t("fields.priceTemplate", { + regionOrCountry: currency.code.toUpperCase(), + }), + cell: ({ row, table }) => { + return ( + } + field={`currency_prices.${currency.code}`} + /> + ) + }, + }) + }), + ...regions.map((region) => { + return columnHelper.display({ + header: t("fields.priceTemplate", { + regionOrCountry: region.name, + }), + cell: ({ row, table }) => { + return ( + c.code === region.currency_code + )} + meta={table.options.meta as DataGridMeta} + field={`region_prices.${region.id}`} + /> + ) + }, + }) + }), + ] + }, [t, currencies, regions]) + + return colDefs +} diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-options-create/components/create-shipping-options-form/index.ts b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-options-create/components/create-shipping-options-form/index.ts new file mode 100644 index 0000000000..572f2b7716 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-options-create/components/create-shipping-options-form/index.ts @@ -0,0 +1 @@ +export * from "./create-shipping-options-form.tsx" diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-options-create/index.ts b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-options-create/index.ts new file mode 100644 index 0000000000..135ecb2465 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-options-create/index.ts @@ -0,0 +1,2 @@ +export { ShippingOptionsCreate as Component } from "./shipping-options-create" +export { stockLocationLoader as loader } from "./loader" diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-options-create/loader.ts b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-options-create/loader.ts new file mode 100644 index 0000000000..5b53c1edd7 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-options-create/loader.ts @@ -0,0 +1,25 @@ +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" + +const fulfillmentSetCreateQuery = (id: string) => ({ + queryKey: adminStockLocationsKeys.list(), // Use the list cache key for now + queryFn: async () => + client.stockLocations.retrieve(id, { + fields: + "*fulfillment_sets,*fulfillment_sets.service_zones,*fulfillment_sets.service_zones.shipping_options", + }), +}) + +export const stockLocationLoader = async ({ params }: LoaderFunctionArgs) => { + const id = params.location_id + const query = fulfillmentSetCreateQuery(id!) + + return ( + queryClient.getQueryData(query.queryKey) ?? + (await queryClient.fetchQuery(query)) + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-options-create/shipping-options-create.tsx b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-options-create/shipping-options-create.tsx new file mode 100644 index 0000000000..4c891215a6 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-options-create/shipping-options-create.tsx @@ -0,0 +1,26 @@ +import { useLoaderData, useParams } from "react-router-dom" + +import { RouteFocusModal } from "../../../components/route-modal" +import { CreateShippingOptionsForm } from "./components/create-shipping-options-form" +import { stockLocationLoader } from "./loader" + +export function ShippingOptionsCreate() { + const { fset_id, zone_id } = useParams() + const { stock_location: stockLocation } = useLoaderData() as Awaited< + ReturnType + > + + const zone = stockLocation.fulfillment_sets + ?.find((f) => f.id === fset_id) + ?.service_zones?.find((z) => z.id === zone_id) + + if (!zone) { + throw new Error("Zone set doesn't exist") + } + + return ( + + + + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profile-create/components/create-shipping-profile-form/create-shipping-profile-form.tsx b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profile-create/components/create-shipping-profile-form/create-shipping-profile-form.tsx new file mode 100644 index 0000000000..fad098b480 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profile-create/components/create-shipping-profile-form/create-shipping-profile-form.tsx @@ -0,0 +1,110 @@ +import { Button, Heading, Input, Text } from "@medusajs/ui" +import { useForm } from "react-hook-form" +import * as zod from "zod" +import { zodResolver } from "@hookform/resolvers/zod" +import { useTranslation } from "react-i18next" + +import { + RouteFocusModal, + useRouteModal, +} from "../../../../../components/route-modal" +import { Form } from "../../../../../components/common/form" +import { useCreateShippingProfile } from "../../../../../hooks/api/shipping-profiles" + +const CreateShippingOptionsSchema = zod.object({ + name: zod.string().min(1), + type: zod.string().min(1), +}) + +export function CreateShippingProfileForm() { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + + const form = useForm>({ + defaultValues: { + name: "", + type: "", + }, + resolver: zodResolver(CreateShippingOptionsSchema), + }) + + const { mutateAsync, isPending } = useCreateShippingProfile() + + const handleSubmit = form.handleSubmit(async (values) => { + await mutateAsync({ + name: values.name, + type: values.type, + }) + handleSuccess("/shipping-profiles") + }) + + return ( + +
+ +
+ + + + +
+
+ +
+
+
+ + {t("shippingProfile.title")} + + + {t("shippingProfile.detailsHint")} + +
+
+ { + return ( + + {t("fields.name")} + + + + + + ) + }} + /> + { + return ( + + + {t("fields.type")} + + + + + + + ) + }} + /> +
+
+
+
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profile-create/components/create-shipping-profile-form/index.ts b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profile-create/components/create-shipping-profile-form/index.ts new file mode 100644 index 0000000000..8b7e2486b4 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profile-create/components/create-shipping-profile-form/index.ts @@ -0,0 +1 @@ +export * from "./create-shipping-profile-form" diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profile-create/index.ts b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profile-create/index.ts new file mode 100644 index 0000000000..07812c0cad --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profile-create/index.ts @@ -0,0 +1 @@ +export { ShippingProfileCreate as Component } from "./shipping-profile-create" diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profile-create/shipping-profile-create.tsx b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profile-create/shipping-profile-create.tsx new file mode 100644 index 0000000000..9b214de52f --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profile-create/shipping-profile-create.tsx @@ -0,0 +1,12 @@ +import React from "react" + +import { RouteFocusModal } from "../../../components/route-modal" +import { CreateShippingProfileForm } from "./components/create-shipping-profile-form" + +export function ShippingProfileCreate() { + return ( + + + + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profiles-list/components/shipping-profile-list-table/index.ts b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profiles-list/components/shipping-profile-list-table/index.ts new file mode 100644 index 0000000000..e62126453b --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profiles-list/components/shipping-profile-list-table/index.ts @@ -0,0 +1 @@ +export * from "./shipping-profile-list-table" diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profiles-list/components/shipping-profile-list-table/shipping-options-row-actions.tsx b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profiles-list/components/shipping-profile-list-table/shipping-options-row-actions.tsx new file mode 100644 index 0000000000..4f79e67a31 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profiles-list/components/shipping-profile-list-table/shipping-options-row-actions.tsx @@ -0,0 +1,53 @@ +import { Trash } from "@medusajs/icons" +import { usePrompt } from "@medusajs/ui" +import { useTranslation } from "react-i18next" +import { ShippingProfileDTO } from "@medusajs/types" + +import { ActionMenu } from "../../../../../components/common/action-menu" +import { useDeleteShippingProfile } from "../../../../../hooks/api/shipping-profiles" + +export const ShippingOptionsRowActions = ({ + profile, +}: { + profile: ShippingProfileDTO +}) => { + const { t } = useTranslation() + const prompt = usePrompt() + // TODO: MISSING ENDPOINT + const { mutateAsync } = useDeleteShippingProfile(profile.id) + + const handleDelete = async () => { + const res = await prompt({ + title: t("general.areYouSure"), + description: t("shippingProfile.deleteWaring", { + name: profile.name, + }), + verificationText: profile.name, + verificationInstruction: t("general.typeToConfirm"), + confirmText: t("actions.delete"), + cancelText: t("actions.cancel"), + }) + + if (!res) { + return + } + + await mutateAsync() + } + + return ( + , + label: t("actions.delete"), + onClick: handleDelete, + }, + ], + }, + ]} + /> + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profiles-list/components/shipping-profile-list-table/shipping-profile-list-table.tsx b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profiles-list/components/shipping-profile-list-table/shipping-profile-list-table.tsx new file mode 100644 index 0000000000..86568cfdc7 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profiles-list/components/shipping-profile-list-table/shipping-profile-list-table.tsx @@ -0,0 +1,68 @@ +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 { useDataTable } from "../../../../../hooks/use-data-table" +import { useShippingProfilesTableColumns } from "./use-shipping-profiles-table-columns" +import { useShippingProfilesTableQuery } from "./use-shipping-profiles-table-query" +import { NoRecords } from "../../../../../components/common/empty-table-content" +import { useShippingProfiles } from "../../../../../hooks/api/shipping-profiles" + +const PAGE_SIZE = 20 + +export const ShippingProfileListTable = () => { + const { t } = useTranslation() + + const { raw, searchParams } = useShippingProfilesTableQuery({ + pageSize: PAGE_SIZE, + }) + + const { shipping_profiles, count, isLoading, isError, error } = + useShippingProfiles({ + ...searchParams, + }) + + const columns = useShippingProfilesTableColumns() + + const { table } = useDataTable({ + data: shipping_profiles, + columns, + count, + enablePagination: true, + getRowId: (row) => row.id, + pageSize: PAGE_SIZE, + }) + + if (isError) { + throw error + } + + const noData = !isLoading && !shipping_profiles.length + + return ( + +
+ {t("shippingProfile.domain")} +
+ +
+
+ {noData ? ( + + ) : ( + + )} +
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profiles-list/components/shipping-profile-list-table/use-shipping-profiles-table-columns.tsx b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profiles-list/components/shipping-profile-list-table/use-shipping-profiles-table-columns.tsx new file mode 100644 index 0000000000..a7ae29183d --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profiles-list/components/shipping-profile-list-table/use-shipping-profiles-table-columns.tsx @@ -0,0 +1,30 @@ +import { createColumnHelper } from "@tanstack/react-table" +import { useMemo } from "react" +import { useTranslation } from "react-i18next" +import { ShippingProfileDTO } from "@medusajs/types" + +import { ShippingOptionsRowActions } from "./shipping-options-row-actions" + +const columnHelper = createColumnHelper() + +export const useShippingProfilesTableColumns = () => { + const { t } = useTranslation() + + return useMemo( + () => [ + columnHelper.accessor("name", { + header: t("fields.name"), + cell: (cell) => cell.getValue(), + }), + columnHelper.accessor("type", { + header: t("fields.type"), + cell: (cell) => cell.getValue(), + }), + columnHelper.display({ + id: "actions", + cell: ({ row }) => , + }), + ], + [t] + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profiles-list/components/shipping-profile-list-table/use-shipping-profiles-table-query.tsx b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profiles-list/components/shipping-profile-list-table/use-shipping-profiles-table-query.tsx new file mode 100644 index 0000000000..51ba906e7e --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profiles-list/components/shipping-profile-list-table/use-shipping-profiles-table-query.tsx @@ -0,0 +1,21 @@ +import { useQueryParams } from "../../../../../hooks/use-query-params" + +export const useShippingProfilesTableQuery = ({ + pageSize = 20, + prefix, +}: { + pageSize?: number + prefix?: string +}) => { + const raw = useQueryParams(["offset"], prefix) + + const searchParams = { + limit: pageSize, + offset: raw.offset, + } + + return { + searchParams, + raw, + } +} diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profiles-list/index.ts b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profiles-list/index.ts new file mode 100644 index 0000000000..399e0d1902 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profiles-list/index.ts @@ -0,0 +1 @@ +export { ShippingProfileList as Component } from "./shipping-profile-list" diff --git a/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profiles-list/shipping-profile-list.tsx b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profiles-list/shipping-profile-list.tsx new file mode 100644 index 0000000000..b6dda68fa0 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/shipping/shipping-profiles-list/shipping-profile-list.tsx @@ -0,0 +1,11 @@ +import { Outlet } from "react-router-dom" +import { ShippingProfileListTable } from "./components/shipping-profile-list-table" + +export const ShippingProfileList = () => { + return ( +
+ + +
+ ) +} diff --git a/packages/core-flows/src/fulfillment/steps/delete-fulfillment-sets.ts b/packages/core-flows/src/fulfillment/steps/delete-fulfillment-sets.ts new file mode 100644 index 0000000000..32e35731e8 --- /dev/null +++ b/packages/core-flows/src/fulfillment/steps/delete-fulfillment-sets.ts @@ -0,0 +1,28 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { IFulfillmentModuleService } from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +export const deleteFulfillmentSetsStepId = "delete-fulfillment-sets" +export const deleteFulfillmentSetsStep = createStep( + deleteFulfillmentSetsStepId, + async (ids: string[], { container }) => { + const service = container.resolve( + ModuleRegistrationName.FULFILLMENT + ) + + await service.softDelete(ids) + + return new StepResponse(void 0, ids) + }, + async (prevIds, { container }) => { + if (!prevIds?.length) { + return + } + + const service = container.resolve( + ModuleRegistrationName.FULFILLMENT + ) + + await service.restore(prevIds) + } +) diff --git a/packages/core-flows/src/fulfillment/steps/index.ts b/packages/core-flows/src/fulfillment/steps/index.ts index f0698b8416..fe9e711cb2 100644 --- a/packages/core-flows/src/fulfillment/steps/index.ts +++ b/packages/core-flows/src/fulfillment/steps/index.ts @@ -5,6 +5,7 @@ export * from "./create-service-zones" export * from "./upsert-shipping-options" export * from "./delete-service-zones" export * from "./delete-shipping-options" +export * from "./delete-fulfillment-sets" export * from "./create-shipping-profiles" export * from "./remove-rules-from-fulfillment-shipping-option" export * from "./set-shipping-options-prices" diff --git a/packages/core-flows/src/fulfillment/workflows/delete-fulfillment-sets.ts b/packages/core-flows/src/fulfillment/workflows/delete-fulfillment-sets.ts new file mode 100644 index 0000000000..e39f23cd8a --- /dev/null +++ b/packages/core-flows/src/fulfillment/workflows/delete-fulfillment-sets.ts @@ -0,0 +1,17 @@ +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { deleteFulfillmentSetsStep } from "../steps" +import { removeRemoteLinkStep } from "../../common" +import { Modules } from "@medusajs/modules-sdk" + +export const deleteFulfillmentSetsWorkflowId = + "delete-fulfillment-sets-workflow" +export const deleteFulfillmentSetsWorkflow = createWorkflow( + deleteFulfillmentSetsWorkflowId, + (input: WorkflowData<{ ids: string[] }>) => { + deleteFulfillmentSetsStep(input.ids) + + removeRemoteLinkStep({ + [Modules.FULFILLMENT]: { fulfillment_set_id: input.ids }, + }) + } +) diff --git a/packages/core-flows/src/fulfillment/workflows/index.ts b/packages/core-flows/src/fulfillment/workflows/index.ts index ea0602ebe9..6f51ddb82a 100644 --- a/packages/core-flows/src/fulfillment/workflows/index.ts +++ b/packages/core-flows/src/fulfillment/workflows/index.ts @@ -4,6 +4,7 @@ export * from "./create-shipping-options" export * from "./create-shipping-profiles" export * from "./delete-service-zones" export * from "./delete-shipping-options" +export * from "./delete-fulfillment-sets" export * from "./remove-rules-from-fulfillment-shipping-option" export * from "./update-service-zones" export * from "./update-shipping-options" diff --git a/packages/core-flows/src/index.ts b/packages/core-flows/src/index.ts index 392a1ea1c6..9496cd1307 100644 --- a/packages/core-flows/src/index.ts +++ b/packages/core-flows/src/index.ts @@ -22,6 +22,7 @@ export * from "./reservation" export * from "./region" export * from "./sales-channel" export * from "./shipping-options" +export * from "./shipping-profile" export * from "./stock-location" export * from "./store" export * from "./tax" diff --git a/packages/core-flows/src/shipping-profile/index.ts b/packages/core-flows/src/shipping-profile/index.ts new file mode 100644 index 0000000000..e58562ad24 --- /dev/null +++ b/packages/core-flows/src/shipping-profile/index.ts @@ -0,0 +1,3 @@ +export * from "./steps" +export * from "./workflows" + diff --git a/packages/core-flows/src/shipping-profile/steps/delete-shipping-profile.ts b/packages/core-flows/src/shipping-profile/steps/delete-shipping-profile.ts new file mode 100644 index 0000000000..677c9a6b41 --- /dev/null +++ b/packages/core-flows/src/shipping-profile/steps/delete-shipping-profile.ts @@ -0,0 +1,28 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { IFulfillmentModuleService } from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +export const deleteShippingProfilesStepId = "delete-shipping-profile" +export const deleteShippingProfilesStep = createStep( + deleteShippingProfilesStepId, + async (ids: string[], { container }) => { + const service = container.resolve( + ModuleRegistrationName.FULFILLMENT + ) + + await service.softDeleteShippingProfiles(ids) + + return new StepResponse(void 0, ids) + }, + async (prevIds, { container }) => { + if (!prevIds?.length) { + return + } + + const service = container.resolve( + ModuleRegistrationName.FULFILLMENT + ) + + await service.restoreShippingProfiles(prevIds) + } +) diff --git a/packages/core-flows/src/shipping-profile/steps/index.ts b/packages/core-flows/src/shipping-profile/steps/index.ts new file mode 100644 index 0000000000..631019d285 --- /dev/null +++ b/packages/core-flows/src/shipping-profile/steps/index.ts @@ -0,0 +1 @@ +export * from "./delete-shipping-profile" diff --git a/packages/core-flows/src/shipping-profile/workflows/delete-shipping-profile.ts b/packages/core-flows/src/shipping-profile/workflows/delete-shipping-profile.ts new file mode 100644 index 0000000000..49e415c220 --- /dev/null +++ b/packages/core-flows/src/shipping-profile/workflows/delete-shipping-profile.ts @@ -0,0 +1,18 @@ +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { Modules } from "@medusajs/modules-sdk" + +import { deleteShippingProfilesStep } from "../steps" +import { removeRemoteLinkStep } from "../../common" + +export const deleteShippingProfileWorkflowId = + "delete-shipping-profile-workflow" +export const deleteShippingProfileWorkflow = createWorkflow( + deleteShippingProfileWorkflowId, + (input: WorkflowData<{ ids: string[] }>) => { + deleteShippingProfilesStep(input.ids) + + removeRemoteLinkStep({ + [Modules.FULFILLMENT]: { shipping_profile_id: input.ids }, + }) + } +) diff --git a/packages/core-flows/src/shipping-profile/workflows/index.ts b/packages/core-flows/src/shipping-profile/workflows/index.ts new file mode 100644 index 0000000000..631019d285 --- /dev/null +++ b/packages/core-flows/src/shipping-profile/workflows/index.ts @@ -0,0 +1 @@ +export * from "./delete-shipping-profile" diff --git a/packages/medusa/src/api-v2/admin/fulfillment-sets/[id]/route.ts b/packages/medusa/src/api-v2/admin/fulfillment-sets/[id]/route.ts new file mode 100644 index 0000000000..788a63e809 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/fulfillment-sets/[id]/route.ts @@ -0,0 +1,40 @@ +import { + AdminFulfillmentSetsDeleteResponse, + IFulfillmentModuleService, +} from "@medusajs/types" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { deleteFulfillmentSetsWorkflow } from "@medusajs/core-flows" + +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "../../../../types/routing" + +export const DELETE = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const { id } = req.params + + const fulfillmentModuleService = req.scope.resolve( + ModuleRegistrationName.FULFILLMENT + ) + + // Test if exists + await fulfillmentModuleService.retrieve(id) + + const { errors } = await deleteFulfillmentSetsWorkflow(req.scope).run({ + input: { ids: [id] }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + res.status(200).json({ + id, + object: "fulfillment_set", + deleted: true, + }) +} diff --git a/packages/medusa/src/api-v2/admin/fulfillment-sets/middlewares.ts b/packages/medusa/src/api-v2/admin/fulfillment-sets/middlewares.ts index 899542b7d6..8a553ed26a 100644 --- a/packages/medusa/src/api-v2/admin/fulfillment-sets/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/fulfillment-sets/middlewares.ts @@ -32,6 +32,11 @@ export const adminFulfillmentSetsRoutesMiddlewares: MiddlewareRoute[] = [ matcher: "/admin/fulfillment-sets/:id/service-zones/:zone_id", middlewares: [], }, + { + method: ["DELETE"], + matcher: "/admin/fulfillment-sets/:id", + middlewares: [], + }, { method: ["POST"], matcher: "/admin/fulfillment-sets/:id/service-zones/:zone_id", diff --git a/packages/medusa/src/api-v2/admin/shipping-profiles/[id]/route.ts b/packages/medusa/src/api-v2/admin/shipping-profiles/[id]/route.ts index 352fc63516..684474dbb2 100644 --- a/packages/medusa/src/api-v2/admin/shipping-profiles/[id]/route.ts +++ b/packages/medusa/src/api-v2/admin/shipping-profiles/[id]/route.ts @@ -1,4 +1,10 @@ -import { AdminShippingProfileResponse } from "@medusajs/types" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { + AdminShippingProfileDeleteResponse, + AdminShippingProfileResponse, + IFulfillmentModuleService, +} from "@medusajs/types" +import { deleteShippingProfileWorkflow } from "@medusajs/core-flows" import { ContainerRegistrationKeys, remoteQueryObjectFromString, @@ -24,3 +30,32 @@ export const GET = async ( res.status(200).json({ shipping_profile: shippingProfile }) } + +export const DELETE = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const { id } = req.params + + const fulfillmentModuleService = req.scope.resolve( + ModuleRegistrationName.FULFILLMENT + ) + + // Test if exists + await fulfillmentModuleService.retrieveShippingProfile(id) + + const { errors } = await deleteShippingProfileWorkflow(req.scope).run({ + input: { ids: [id] }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + res.status(200).json({ + id, + object: "shipping_profile", + deleted: true, + }) +} diff --git a/packages/types/src/common/common.ts b/packages/types/src/common/common.ts index e6a14b71ae..bc4b0b67ae 100644 --- a/packages/types/src/common/common.ts +++ b/packages/types/src/common/common.ts @@ -250,7 +250,7 @@ export type PaginatedResponse = { /** * The fields returned in the response of a DELETE request. */ -export type DeleteResponse = { +export type DeleteResponse = { /** * The ID of the item that was deleted. */ @@ -259,7 +259,7 @@ export type DeleteResponse = { /** * The type of the item that was deleted. */ - object: string + object: T /** * Whether the item was deleted successfully. diff --git a/packages/types/src/http/common/deleted-response.ts b/packages/types/src/http/common/deleted-response.ts index a446c109dd..88f7b63f47 100644 --- a/packages/types/src/http/common/deleted-response.ts +++ b/packages/types/src/http/common/deleted-response.ts @@ -1,7 +1,7 @@ /** * The fields returned in the response of a DELETE request. */ -export type DeleteResponse = { +export type DeleteResponse = { /** * The ID of the item that was deleted. */ diff --git a/packages/types/src/http/fulfillment/admin/fulfillment.ts b/packages/types/src/http/fulfillment/admin/fulfillment.ts index bf5086580d..6b8a044182 100644 --- a/packages/types/src/http/fulfillment/admin/fulfillment.ts +++ b/packages/types/src/http/fulfillment/admin/fulfillment.ts @@ -2,6 +2,7 @@ import { AdminFulfillmentAddressResponse } from "./fulfillment-address" import { AdminFulfillmentProviderResponse } from "./fulfillment-provider" import { AdminFulfillmentItemResponse } from "./fulfillment-item" import { AdminFulfillmentLabelResponse } from "./fulfillment-label" +import { DeleteResponse } from "../../../common" /** * @experimental @@ -25,3 +26,9 @@ export interface AdminFulfillmentResponse { updated_at: Date deleted_at: Date | null } + +/** + * @experimental + */ +export interface AdminFulfillmentSetsDeleteResponse + extends DeleteResponse<"fulfillment_set"> {} diff --git a/packages/types/src/http/fulfillment/admin/shipping-option.ts b/packages/types/src/http/fulfillment/admin/shipping-option.ts index 3f7cbea2fc..2b76ceefbc 100644 --- a/packages/types/src/http/fulfillment/admin/shipping-option.ts +++ b/packages/types/src/http/fulfillment/admin/shipping-option.ts @@ -48,4 +48,5 @@ export interface AdminShippingOptionListResponse extends PaginatedResponse { /** * @experimental */ -export interface AdminShippingOptionDeleteResponse extends DeleteResponse {} +export interface AdminShippingOptionDeleteResponse + extends DeleteResponse<"shipping_option"> {} diff --git a/packages/types/src/http/fulfillment/admin/shipping-profile.ts b/packages/types/src/http/fulfillment/admin/shipping-profile.ts index 05e689901e..f6b046291f 100644 --- a/packages/types/src/http/fulfillment/admin/shipping-profile.ts +++ b/packages/types/src/http/fulfillment/admin/shipping-profile.ts @@ -1,4 +1,4 @@ -import { PaginatedResponse } from "../../../common" +import { DeleteResponse, PaginatedResponse } from "../../../common" /** * @experimental @@ -20,3 +20,9 @@ export interface AdminShippingProfileResponse { export interface AdminShippingProfilesResponse extends PaginatedResponse { shipping_profiles: ShippingProfileResponse[] } + +/** + * @experimental + */ +export interface AdminShippingProfileDeleteResponse + extends DeleteResponse<"shipping_profile"> {} diff --git a/packages/types/src/stock-location/common.ts b/packages/types/src/stock-location/common.ts index 48d7dab682..8f8ea3fc17 100644 --- a/packages/types/src/stock-location/common.ts +++ b/packages/types/src/stock-location/common.ts @@ -1,6 +1,7 @@ import { BaseFilterable, OperatorMap } from "../dal" import { StringComparisonOperator } from "../common/common" +import { FulfillmentSetDTO } from "../fulfillment" /** * @schema StockLocationAddressDTO @@ -205,6 +206,11 @@ export type StockLocationDTO = { */ address?: StockLocationAddressDTO + /** + * Fulfillment sets for the location + */ + fulfillment_sets: FulfillmentSetDTO[] + /** * The creation date of the stock location. */