diff --git a/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-number-cell.tsx b/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-number-cell.tsx index 04accaccc1..54fdfc1536 100644 --- a/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-number-cell.tsx +++ b/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-number-cell.tsx @@ -5,7 +5,12 @@ import { DataGridCellContainer } from "./data-grid-cell-container" export const DataGridNumberCell = ({ field, context, -}: DataGridCellProps) => { + ...rest +}: DataGridCellProps & { + min?: number + max?: number + placeholder?: string +}) => { const { register, attributes, container } = useDataGridCell({ field, context, @@ -18,7 +23,19 @@ export const DataGridNumberCell = ({ type="number" {...register(field, { valueAsNumber: true, + onChange: (e) => { + if (e.target.value) { + const parsedValue = Number(e.target.value) + if (Number.isNaN(parsedValue)) { + return undefined + } + + return parsedValue + } + }, })} + className="h-full w-full bg-transparent p-2 text-right [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + {...rest} /> ) diff --git a/packages/admin-next/dashboard/src/hooks/api/inventory.tsx b/packages/admin-next/dashboard/src/hooks/api/inventory.tsx index b20ce675fd..93f7c867c8 100644 --- a/packages/admin-next/dashboard/src/hooks/api/inventory.tsx +++ b/packages/admin-next/dashboard/src/hooks/api/inventory.tsx @@ -1,4 +1,4 @@ -import { AdminInventoryItemResponse, InventoryNext } from "@medusajs/types" +import { HttpTypes } from "@medusajs/types" import { QueryKey, UseMutationOptions, @@ -6,19 +6,9 @@ import { useMutation, useQuery, } from "@tanstack/react-query" -import { - InventoryItemLocationBatch, - UpdateInventoryItemReq, - UpdateInventoryLevelReq, -} from "../../types/api-payloads" -import { - InventoryItemDeleteRes, - InventoryItemListRes, - InventoryItemLocationLevelsRes, - InventoryItemRes, -} from "../../types/api-responses" +import { FetchError } from "@medusajs/js-sdk" -import { client } from "../../lib/client" +import { sdk } from "../../lib/client" import { queryClient } from "../../lib/query-client" import { queryKeysFactory } from "../../lib/query-key-factory" @@ -36,16 +26,16 @@ export const useInventoryItems = ( query?: Record, options?: Omit< UseQueryOptions< - InventoryItemListRes, - Error, - InventoryItemListRes, + HttpTypes.AdminInventoryItemListResponse, + FetchError, + HttpTypes.AdminInventoryItemListResponse, QueryKey >, "queryKey" | "queryFn" > ) => { const { data, ...rest } = useQuery({ - queryFn: () => client.inventoryItems.list(query), + queryFn: () => sdk.admin.inventoryItem.list(query), queryKey: inventoryItemsQueryKeys.list(query), ...options, }) @@ -57,12 +47,17 @@ export const useInventoryItem = ( id: string, query?: Record, options?: Omit< - UseQueryOptions, + UseQueryOptions< + HttpTypes.AdminInventoryItemResponse, + FetchError, + HttpTypes.AdminInventoryItemResponse, + QueryKey + >, "queryKey" | "queryFn" > ) => { const { data, ...rest } = useQuery({ - queryFn: () => client.inventoryItems.retrieve(id, query), + queryFn: () => sdk.admin.inventoryItem.retrieve(id, query), queryKey: inventoryItemsQueryKeys.detail(id), ...options, }) @@ -70,13 +65,37 @@ export const useInventoryItem = ( return { ...data, ...rest } } -export const useUpdateInventoryItem = ( - id: string, - options?: UseMutationOptions +export const useCreateInventoryItem = ( + options?: UseMutationOptions< + HttpTypes.AdminInventoryItemResponse, + FetchError, + HttpTypes.AdminCreateInventoryItem + > ) => { return useMutation({ - mutationFn: (payload: InventoryNext.UpdateInventoryItemInput) => - client.inventoryItems.update(id, payload), + mutationFn: (payload: HttpTypes.AdminCreateInventoryItem) => + sdk.admin.inventoryItem.create(payload), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ + queryKey: inventoryItemsQueryKeys.lists(), + }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useUpdateInventoryItem = ( + id: string, + options?: UseMutationOptions< + HttpTypes.AdminInventoryItemResponse, + FetchError, + HttpTypes.AdminUpdateInventoryItem + > +) => { + return useMutation({ + mutationFn: (payload: HttpTypes.AdminUpdateInventoryItem) => + sdk.admin.inventoryItem.update(id, payload), onSuccess: (data, variables, context) => { queryClient.invalidateQueries({ queryKey: inventoryItemsQueryKeys.lists(), @@ -92,10 +111,14 @@ export const useUpdateInventoryItem = ( export const useDeleteInventoryItem = ( id: string, - options?: UseMutationOptions + options?: UseMutationOptions< + HttpTypes.AdminInventoryItemDeleteResponse, + FetchError, + void + > ) => { return useMutation({ - mutationFn: () => client.inventoryItems.delete(id), + mutationFn: () => sdk.admin.inventoryItem.delete(id), onSuccess: (data, variables, context) => { queryClient.invalidateQueries({ queryKey: inventoryItemsQueryKeys.lists(), @@ -112,14 +135,15 @@ export const useDeleteInventoryItem = ( export const useDeleteInventoryItemLevel = ( inventoryItemId: string, locationId: string, - options?: UseMutationOptions + options?: UseMutationOptions< + HttpTypes.AdminInventoryItemDeleteResponse, + FetchError, + void + > ) => { return useMutation({ mutationFn: () => - client.inventoryItems.deleteInventoryItemLevel( - inventoryItemId, - locationId - ), + sdk.admin.inventoryItem.deleteLevel(inventoryItemId, locationId), onSuccess: (data, variables, context) => { queryClient.invalidateQueries({ queryKey: inventoryItemsQueryKeys.lists(), @@ -141,17 +165,16 @@ export const useInventoryItemLevels = ( query?: Record, options?: Omit< UseQueryOptions< - InventoryItemLocationLevelsRes, - Error, - InventoryItemLocationLevelsRes, + HttpTypes.AdminInventoryLevelListResponse, + FetchError, + HttpTypes.AdminInventoryLevelListResponse, QueryKey >, "queryKey" | "queryFn" > ) => { const { data, ...rest } = useQuery({ - queryFn: () => - client.inventoryItems.listLocationLevels(inventoryItemId, query), + queryFn: () => sdk.admin.inventoryItem.listLevels(inventoryItemId, query), queryKey: inventoryItemLevelsQueryKeys.detail(inventoryItemId), ...options, }) @@ -159,22 +182,18 @@ export const useInventoryItemLevels = ( return { ...data, ...rest } } -export const useUpdateInventoryItemLevel = ( +export const useUpdateInventoryLevel = ( inventoryItemId: string, locationId: string, options?: UseMutationOptions< - AdminInventoryItemResponse, - Error, - UpdateInventoryLevelReq + HttpTypes.AdminInventoryItemResponse, + FetchError, + HttpTypes.AdminUpdateInventoryLevel > ) => { return useMutation({ - mutationFn: (payload: UpdateInventoryLevelReq) => - client.inventoryItems.updateInventoryLevel( - inventoryItemId, - locationId, - payload - ), + mutationFn: (payload: HttpTypes.AdminUpdateInventoryLevel) => + sdk.admin.inventoryItem.updateLevel(inventoryItemId, locationId, payload), onSuccess: (data, variables, context) => { queryClient.invalidateQueries({ queryKey: inventoryItemsQueryKeys.lists(), @@ -191,17 +210,17 @@ export const useUpdateInventoryItemLevel = ( }) } -export const useBatchInventoryItemLevels = ( +export const useBatchUpdateInventoryLevels = ( inventoryItemId: string, options?: UseMutationOptions< - InventoryItemLocationLevelsRes, - Error, - InventoryItemLocationBatch + HttpTypes.AdminInventoryItemResponse, + FetchError, + HttpTypes.AdminBatchUpdateInventoryLevelLocation > ) => { return useMutation({ - mutationFn: (payload: InventoryItemLocationBatch) => - client.inventoryItems.batchPostLocationLevels(inventoryItemId, payload), + mutationFn: (payload: HttpTypes.AdminBatchUpdateInventoryLevelLocation) => + sdk.admin.inventoryItem.batchUpdateLevels(inventoryItemId, payload), onSuccess: (data, variables, context) => { queryClient.invalidateQueries({ queryKey: inventoryItemsQueryKeys.lists(), diff --git a/packages/admin-next/dashboard/src/i18n/translations/en.json b/packages/admin-next/dashboard/src/i18n/translations/en.json index 3159837cb9..3111409f95 100644 --- a/packages/admin-next/dashboard/src/i18n/translations/en.json +++ b/packages/admin-next/dashboard/src/i18n/translations/en.json @@ -403,6 +403,15 @@ "associatedVariants": "Associated variants", "manageLocations": "Manage locations", "deleteWarning": "You are about to delete an inventory item. This action cannot be undone.", + "create": { + "title": "Add item", + "details": "Details", + "availability": "Availability", + "locations": "Locations", + "attributes": "Attributes", + "requiresShipping": "Requires shipping", + "requiresShippingHint": "Does the inventory item require shipping?" + }, "reservation": { "header": "Reservation of {{itemName}}", "editItemDetails": "Edit item details", diff --git a/packages/admin-next/dashboard/src/lib/client/client.ts b/packages/admin-next/dashboard/src/lib/client/client.ts index 2fb36f6a98..beb7d56ed3 100644 --- a/packages/admin-next/dashboard/src/lib/client/client.ts +++ b/packages/admin-next/dashboard/src/lib/client/client.ts @@ -6,7 +6,6 @@ import { currencies } from "./currencies" import { customerGroups } from "./customer-groups" import { fulfillmentProviders } from "./fulfillment-providers" import { fulfillments } from "./fulfillments" -import { inventoryItems } from "./inventory" import { invites } from "./invites" import { payments } from "./payments" import { priceLists } from "./price-lists" @@ -40,7 +39,6 @@ export const client = { productTags: tags, users: users, invites: invites, - inventoryItems: inventoryItems, reservations: reservations, fulfillments: fulfillments, fulfillmentProviders: fulfillmentProviders, diff --git a/packages/admin-next/dashboard/src/lib/client/inventory.ts b/packages/admin-next/dashboard/src/lib/client/inventory.ts deleted file mode 100644 index ffaa1342b4..0000000000 --- a/packages/admin-next/dashboard/src/lib/client/inventory.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { - AdminInventoryItemListResponse, - AdminInventoryItemResponse, - AdminInventoryLevelResponse, -} from "@medusajs/types" -import { - CreateInventoryItemReq, - InventoryItemLocationBatch, - UpdateInventoryItemReq, - UpdateInventoryLevelReq, -} from "../../types/api-payloads" -import { - InventoryItemLevelDeleteRes, - InventoryItemLocationLevelsRes, -} from "../../types/api-responses" -import { deleteRequest, getRequest, postRequest } from "./common" - -async function retrieveInventoryItem(id: string, query?: Record) { - return getRequest( - `/admin/inventory-items/${id}`, - query - ) -} - -async function listInventoryItems(query?: Record) { - return getRequest( - `/admin/inventory-items`, - query - ) -} - -async function createInventoryItem(payload: CreateInventoryItemReq) { - return postRequest( - `/admin/inventory-items`, - payload - ) -} - -async function updateInventoryItem( - id: string, - payload: UpdateInventoryItemReq -) { - return postRequest( - `/admin/inventory-items/${id}`, - payload - ) -} - -async function deleteInventoryItem(id: string) { - return deleteRequest( - `/admin/inventory-items/${id}` - ) -} - -async function listInventoryItemLevels( - id: string, - query?: Record -) { - return getRequest( - `/admin/inventory-items/${id}/location-levels`, - query - ) -} - -async function deleteInventoryItemLevel( - inventoryItemId: string, - locationId: string -) { - return deleteRequest( - `/admin/inventory-items/${inventoryItemId}/location-levels/${locationId}` - ) -} - -async function updateInventoryLevel( - inventoryItemId: string, - locationId: string, - payload: UpdateInventoryLevelReq -) { - return postRequest( - `/admin/inventory-items/${inventoryItemId}/location-levels/${locationId}`, - payload - ) -} - -async function batchPostLocationLevels( - inventoryItemId: string, - payload: InventoryItemLocationBatch -) { - return postRequest( - `/admin/inventory-items/${inventoryItemId}/location-levels/batch`, - { - create: payload.creates, - delete: payload.deletes, - } - ) -} - -export const inventoryItems = { - retrieve: retrieveInventoryItem, - list: listInventoryItems, - create: createInventoryItem, - update: updateInventoryItem, - delete: deleteInventoryItem, - listLocationLevels: listInventoryItemLevels, - updateInventoryLevel, - deleteInventoryItemLevel, - batchPostLocationLevels, -} diff --git a/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx b/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx index 6222a6d529..ab2e307777 100644 --- a/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx +++ b/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx @@ -13,7 +13,7 @@ import { ProtectedRoute } from "../../components/authentication/protected-route" import { MainLayout } from "../../components/layout/main-layout" import { SettingsLayout } from "../../components/layout/settings-layout" import { ErrorBoundary } from "../../components/utilities/error-boundary" -import { InventoryItemRes, PriceListRes } from "../../types/api-responses" +import { PriceListRes } from "../../types/api-responses" import { RouteExtensions } from "./route-extensions" import { SettingsExtensions } from "./settings-extensions" @@ -467,12 +467,19 @@ export const RouteMap: RouteObject[] = [ { path: "", lazy: () => import("../../routes/inventory/inventory-list"), + children: [ + { + path: "create", + lazy: () => + import("../../routes/inventory/inventory-create"), + }, + ], }, { path: ":id", lazy: () => import("../../routes/inventory/inventory-detail"), handle: { - crumb: (data: InventoryItemRes) => + crumb: (data: HttpTypes.AdminInventoryItemResponse) => data.inventory_item.title ?? data.inventory_item.sku, }, children: [ diff --git a/packages/admin-next/dashboard/src/routes/inventory/inventory-create/components/create-inventory-item-form/create-inventory-availability-form.tsx b/packages/admin-next/dashboard/src/routes/inventory/inventory-create/components/create-inventory-item-form/create-inventory-availability-form.tsx new file mode 100644 index 0000000000..326643d242 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/inventory/inventory-create/components/create-inventory-item-form/create-inventory-availability-form.tsx @@ -0,0 +1,71 @@ +import { useMemo } from "react" + +import { StockLocationDTO } from "@medusajs/types" +import { UseFormReturn } from "react-hook-form" +import { useTranslation } from "react-i18next" +import { DataGridRoot } from "../../../../../components/data-grid/data-grid-root" +import { createDataGridHelper } from "../../../../../components/data-grid/utils" +import { DataGridReadOnlyCell } from "../../../../../components/data-grid/data-grid-cells/data-grid-readonly-cell" +import { DataGridNumberCell } from "../../../../../components/data-grid/data-grid-cells/data-grid-number-cell" +import { useStockLocations } from "../../../../../hooks/api/stock-locations" + +type Props = { + form: UseFormReturn<{}> +} + +export const CreateInventoryAvailabilityForm = ({ form }: Props) => { + const { isPending, stock_locations = [] } = useStockLocations({ limit: 999 }) + + const columns = useColumns() + + return ( +
+ {isPending ? ( +
Loading...
+ ) : ( + + )} +
+ ) +} + +const columnHelper = createDataGridHelper() + +const useColumns = () => { + const { t } = useTranslation() + + return useMemo( + () => [ + columnHelper.column({ + id: "location", + header: () => ( +
+ {t("locations.domain")} +
+ ), + cell: ({ row }) => { + return ( + {row.original.name} + ) + }, + disableHidding: true, + }), + columnHelper.column({ + id: "in-stock", + name: t("fields.inStock"), + header: t("fields.inStock"), + cell: (context) => { + return ( + + ) + }, + }), + ], + [t] + ) +} diff --git a/packages/admin-next/dashboard/src/routes/inventory/inventory-create/components/create-inventory-item-form/create-inventory-item-form.tsx b/packages/admin-next/dashboard/src/routes/inventory/inventory-create/components/create-inventory-item-form/create-inventory-item-form.tsx new file mode 100644 index 0000000000..b9ab2268b6 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/inventory/inventory-create/components/create-inventory-item-form/create-inventory-item-form.tsx @@ -0,0 +1,553 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import React, { useEffect } from "react" +import { useForm } from "react-hook-form" +import * as zod from "zod" + +import { + Button, + Heading, + ProgressStatus, + ProgressTabs, + clx, + Input, + Textarea, + Switch, + toast, +} from "@medusajs/ui" +import { useTranslation } from "react-i18next" + +import { + RouteFocusModal, + useRouteModal, +} from "../../../../../components/route-modal" +import { CreateInventoryAvailabilityForm } from "./create-inventory-availability-form" +import { CountrySelect } from "../../../../../components/inputs/country-select" +import { Form } from "../../../../../components/common/form" +import { + inventoryItemsQueryKeys, + useCreateInventoryItem, +} from "../../../../../hooks/api/inventory" +import { sdk } from "../../../../../lib/client" +import { optionalInt } from "../../../../../lib/validation" +import { queryClient } from "../../../../../lib/query-client" + +enum Tab { + DETAILS = "details", + AVAILABILITY = "availability", +} + +type StepStatus = { + [key in Tab]: ProgressStatus +} + +const CreateInventoryItemSchema = zod.object({ + title: zod.string().min(1), + + sku: zod.string().optional(), + hs_code: zod.string().optional(), + weight: optionalInt, + length: optionalInt, + height: optionalInt, + width: optionalInt, + origin_country: zod.string().optional(), + mid_code: zod.string().optional(), + material: zod.string().optional(), + description: zod.string().optional(), + requires_shipping: zod.boolean().optional(), + thumbnail: zod.string().optional(), + locations: zod.record(zod.string(), zod.number().optional()).optional(), + // metadata: zod.record(zod.string(), zod.unknown()).optional(), +}) + +type CreateInventoryItemFormProps = {} + +export function CreateInventoryItemForm({}: CreateInventoryItemFormProps) { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + const [tab, setTab] = React.useState(Tab.DETAILS) + + const form = useForm>({ + defaultValues: { + title: "", + sku: "", + hs_code: "", + weight: "", + length: "", + height: "", + width: "", + origin_country: "", + mid_code: "", + material: "", + description: "", + requires_shipping: true, + thumbnail: "", + }, + resolver: zodResolver(CreateInventoryItemSchema), + }) + + const { mutateAsync: createInventoryItem, isPending: isLoading } = + useCreateInventoryItem() + + const handleSubmit = form.handleSubmit(async (data) => { + let { locations, ...payload } = data + + for (const k in payload) { + if (payload[k] === "") { + delete payload[k] + continue + } + + if (["weight", "length", "height", "width"].includes(k)) { + payload[k] = parseInt(payload[k]) + } + } + + try { + const { inventory_item } = await createInventoryItem(payload) + + try { + await sdk.admin.inventoryItem.batchUpdateLevels(inventory_item.id, { + create: Object.entries(locations) + .filter(([_, quantiy]) => !!quantiy) + .map(([location_id, stocked_quantity]) => ({ + location_id, + stocked_quantity, + })), + }) + + await queryClient.invalidateQueries({ + queryKey: inventoryItemsQueryKeys.lists(), + }) + } catch (e) { + toast.error(t("general.error"), { + description: e.message, + dismissLabel: t("actions.close"), + }) + } + + handleSuccess() + } catch (e) { + toast.error(t("general.error"), { + description: e.message, + dismissLabel: t("actions.close"), + }) + } + }) + + const [status, setStatus] = React.useState({ + [Tab.AVAILABILITY]: "not-started", + [Tab.DETAILS]: "not-started", + }) + + const onTabChange = React.useCallback(async (value: Tab) => { + const result = await form.trigger() + + if (!result) { + return + } + + setTab(value) + }, []) + + const onNext = React.useCallback(async () => { + const result = await form.trigger() + + if (!result) { + return + } + + switch (tab) { + case Tab.DETAILS: { + setTab(Tab.AVAILABILITY) + break + } + case Tab.AVAILABILITY: + break + } + }, [tab]) + + 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.AVAILABILITY) { + setStatus((prev) => ({ + ...prev, + [Tab.DETAILS]: "completed", + [Tab.AVAILABILITY]: "in-progress", + })) + } + }, [tab]) + + return ( + +
+ onTabChange(tab as Tab)} + > + + + + + {t("inventory.create.details")} + + + + + {t("inventory.create.availability")} + + + +
+ + + + +
+
+ + + +
+ + {t("inventory.create.title")} + + +
+
+ { + return ( + + {t("fields.title")} + + + + + + ) + }} + /> + + { + return ( + + {t("fields.sku")} + + + + + + ) + }} + /> + +
+ { + return ( + + + {t("products.fields.description.label")} + + +