feat(admin-next, inventory-next, medusa, types): Add admin reservations flow (#7080)

* add reservation endpoints

* add changeset

* initial

* add reservations table

* add edit-item modal

* udpate inventory item attributes

* manage locations skeleton

* add combi batch endpoint

* cleanup

* fix manage locations

* add adjust inventory

* prep for pr

* minor fixes to region domain and api (#7042)

* initial reservation

* init

* update reservation

* create reservation

* polishing

* minor fix

* prep for pr

* prep for pr

* polishing

* inventory items reservations

* Update packages/admin-next/dashboard/src/v2-routes/reservations/reservation-list/components/reservation-list-table/reservation-list-table.tsx

Co-authored-by: Frane Polić <16856471+fPolic@users.noreply.github.com>

* fix feedback

* rename to ispending

---------

Co-authored-by: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com>
Co-authored-by: Frane Polić <16856471+fPolic@users.noreply.github.com>
This commit is contained in:
Philip Korsholm
2024-04-25 10:11:29 +02:00
committed by GitHub
parent 347aece924
commit e4898fb00d
48 changed files with 1415 additions and 175 deletions

View File

@@ -190,6 +190,25 @@ medusaIntegrationTestRunner({
})
})
it("should update a reservation item description", async () => {
await breaking(null, async () => {
const reservationResponse = await api.post(
`/admin/reservations/${reservationId}`,
{
description: "test description 1",
},
adminHeaders
)
expect(reservationResponse.status).toEqual(200)
expect(reservationResponse.data.reservation).toEqual(
expect.objectContaining({
description: "test description 1",
})
)
})
})
it("should update a reservation item", async () => {
await breaking(null, async () => {
const reservationResponse = await api.post(

View File

@@ -343,7 +343,25 @@
"locationLevels": "Location levels",
"associatedVariants": "Associated variants",
"manageLocations": "Manage locations",
"deleteWarning": "You are about to delete an inventory item. This action cannot be undone."
"deleteWarning": "You are about to delete an inventory item. This action cannot be undone.",
"reservation": {
"header": "Reservation of {{itemName}}",
"editItemDetails": "Edit item details",
"orderID": "Order ID",
"description": "Description",
"location": "Location",
"inStockAtLocation": "In stock at this location",
"availableAtLocation": "Available at this location",
"reservedAtLocation": "Reserved at this location",
"reservedAmount": "Reserve amount",
"create": "Create reservation",
"itemToReserve": "Item to reserve",
"quantityPlaceholder": "How many do you want to reserve?",
"descriptionPlaceholder": "What type of reservation is this?",
"successToast": "Reservation was successfully created.",
"updateSuccessToast": "Reservation was successfully updated.",
"deleteSuccessToast": "Reservation was successfully deleted."
}
},
"giftCards": {
"domain": "Gift Cards",

View File

@@ -1,11 +1,9 @@
import { AdminInventoryItemResponse, InventoryNext } from "@medusajs/types"
import {
InventoryItemDeleteRes,
InventoryItemListRes,
InventoryItemLocationLevelsRes,
InventoryItemRes,
ReservationItemDeleteRes,
ReservationItemListRes,
ReservationItemRes,
} from "../../types/api-responses"
import {
InventoryItemLocationBatch,
@@ -20,7 +18,6 @@ import {
useQuery,
} from "@tanstack/react-query"
import { InventoryNext } from "@medusajs/types"
import { client } from "../../lib/client"
import { queryClient } from "../../lib/medusa"
import { queryKeysFactory } from "../../lib/query-key-factory"
@@ -35,11 +32,6 @@ export const inventoryItemLevelsQueryKeys = queryKeysFactory(
INVENTORY_ITEM_LEVELS_QUERY_KEY
)
const RESERVATION_ITEMS_QUERY_KEY = "reservation_items" as const
export const reservationItemsQueryKeys = queryKeysFactory(
RESERVATION_ITEMS_QUERY_KEY
)
export const useInventoryItems = (
query?: Record<string, any>,
options?: Omit<
@@ -171,7 +163,7 @@ export const useUpdateInventoryItemLevel = (
inventoryItemId: string,
locationId: string,
options?: UseMutationOptions<
AdminInventoryLevelResponse,
AdminInventoryItemResponse,
Error,
UpdateInventoryLevelReq
>
@@ -225,67 +217,3 @@ export const useBatchInventoryItemLevels = (
...options,
})
}
export const useReservationItems = (
query?: Record<string, any>,
options?: Omit<
UseQueryOptions<
ReservationItemListRes,
Error,
ReservationItemListRes,
QueryKey
>,
"queryKey" | "queryFn"
>
) => {
const { data, ...rest } = useQuery({
queryFn: () => client.inventoryItems.listReservationItems(query),
queryKey: reservationItemsQueryKeys.list(query),
...options,
})
return { ...data, ...rest }
}
export const useUpdateReservationItem = (
id: string,
payload: InventoryNext.UpdateInventoryItemInput,
options?: UseMutationOptions<
ReservationItemRes,
Error,
UpdateInventoryItemReq
>
) => {
return useMutation({
mutationFn: () => client.inventoryItems.updateReservationItem(id, payload),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: inventoryItemsQueryKeys.lists(),
})
queryClient.invalidateQueries({
queryKey: inventoryItemsQueryKeys.detail(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useDeleteReservationItem = (
id: string,
options?: UseMutationOptions<ReservationItemDeleteRes, Error, void>
) => {
return useMutation({
mutationFn: () => client.inventoryItems.deleteReservationItem(id),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: inventoryItemsQueryKeys.lists(),
})
queryClient.invalidateQueries({
queryKey: inventoryItemsQueryKeys.detail(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}

View File

@@ -0,0 +1,119 @@
import {
CreateReservationReq,
UpdateReservationReq,
} from "../../types/api-payloads"
import {
QueryKey,
UseMutationOptions,
UseQueryOptions,
useMutation,
useQuery,
} from "@tanstack/react-query"
import {
ReservationItemDeleteRes,
ReservationItemListRes,
ReservationItemRes,
} from "../../types/api-responses"
import { InventoryNext } from "@medusajs/types"
import { client } from "../../lib/client"
import { queryClient } from "../../lib/medusa"
import { queryKeysFactory } from "../../lib/query-key-factory"
const RESERVATION_ITEMS_QUERY_KEY = "reservation_items" as const
export const reservationItemsQueryKeys = queryKeysFactory(
RESERVATION_ITEMS_QUERY_KEY
)
export const useReservationItem = (
id: string,
query?: Record<string, any>,
options?: Omit<
UseQueryOptions<ReservationItemRes, Error, ReservationItemRes, QueryKey>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryKey: reservationItemsQueryKeys.detail(id),
queryFn: async () => client.reservations.retrieve(id, query),
...options,
})
return { ...data, ...rest }
}
export const useReservationItems = (
query?: Record<string, any>,
options?: Omit<
UseQueryOptions<
ReservationItemListRes,
Error,
ReservationItemListRes,
QueryKey
>,
"queryKey" | "queryFn"
>
) => {
const { data, ...rest } = useQuery({
queryFn: () => client.reservations.list(query),
queryKey: reservationItemsQueryKeys.list(query),
...options,
})
return { ...data, ...rest }
}
export const useUpdateReservationItem = (
id: string,
options?: UseMutationOptions<ReservationItemRes, Error, UpdateReservationReq>
) => {
return useMutation({
mutationFn: (payload: InventoryNext.UpdateReservationItemInput) =>
client.reservations.update(id, payload),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: reservationItemsQueryKeys.detail(id),
})
queryClient.invalidateQueries({
queryKey: reservationItemsQueryKeys.lists(),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useCreateReservationItem = (
options?: UseMutationOptions<ReservationItemRes, Error, CreateReservationReq>
) => {
return useMutation({
mutationFn: (payload: CreateReservationReq) =>
client.reservations.create(payload),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: reservationItemsQueryKeys.lists(),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useDeleteReservationItem = (
id: string,
options?: UseMutationOptions<ReservationItemDeleteRes, Error, void>
) => {
return useMutation({
mutationFn: () => client.reservations.delete(id),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: reservationItemsQueryKeys.lists(),
})
queryClient.invalidateQueries({
queryKey: reservationItemsQueryKeys.detail(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}

View File

@@ -14,6 +14,7 @@ import { productTypes } from "./product-types"
import { products } from "./products"
import { promotions } from "./promotions"
import { regions } from "./regions"
import { reservations } from "./reservations"
import { salesChannels } from "./sales-channels"
import { shippingOptions } from "./shipping-options"
import { stockLocations } from "./stock-locations"
@@ -45,6 +46,7 @@ export const client = {
taxes: taxes,
invites: invites,
inventoryItems: inventoryItems,
reservations: reservations,
products: products,
productTypes: productTypes,
priceLists: priceLists,

View File

@@ -1,7 +1,6 @@
import {
AdminInventoryItemListResponse,
AdminInventoryItemResponse,
AdminInventoryLevelListResponse,
AdminInventoryLevelResponse,
} from "@medusajs/types"
import {
@@ -12,9 +11,7 @@ import {
} from "../../types/api-payloads"
import {
InventoryItemLevelDeleteRes,
ReservationItemDeleteRes,
ReservationItemListRes,
ReservationItemRes,
InventoryItemLocationLevelsRes,
} from "../../types/api-responses"
import { deleteRequest, getRequest, postRequest } from "./common"
@@ -59,7 +56,7 @@ async function listInventoryItemLevels(
id: string,
query?: Record<string, any>
) {
return getRequest<AdminInventoryLevelListResponse>(
return getRequest<InventoryItemLocationLevelsRes>(
`/admin/inventory-items/${id}/location-levels`,
query
)
@@ -79,32 +76,12 @@ async function updateInventoryLevel(
locationId: string,
payload: UpdateInventoryLevelReq
) {
return postRequest<AdminInventoryLevelResponse>(
return postRequest<AdminInventoryItemResponse>(
`/admin/inventory-items/${inventoryItemId}/location-levels/${locationId}`,
payload
)
}
async function listReservationItems(query?: Record<string, any>) {
return getRequest<ReservationItemListRes>(`/admin/reservations`, query)
}
async function deleteReservationItem(reservationId: string) {
return deleteRequest<ReservationItemDeleteRes>(
`/admin/reservations/${reservationId}`
)
}
async function updateReservationItem(
reservationId: string,
payload: UpdateInventoryItemReq
) {
return postRequest<ReservationItemRes>(
`/admin/reservatinos/${reservationId}`,
payload
)
}
async function batchPostLocationLevels(
inventoryItemId: string,
payload: InventoryItemLocationBatch
@@ -127,8 +104,5 @@ export const inventoryItems = {
listLocationLevels: listInventoryItemLevels,
updateInventoryLevel,
deleteInventoryItemLevel,
listReservationItems,
deleteReservationItem,
updateReservationItem,
batchPostLocationLevels,
}

View File

@@ -0,0 +1,41 @@
import {
ReservationDeleteRes,
ReservationListRes,
ReservationRes,
} from "../../types/api-responses"
import { deleteRequest, getRequest, postRequest } from "./common"
import { InventoryNext } from "@medusajs/types"
async function retrieveReservation(id: string, query?: Record<string, any>) {
return getRequest<ReservationRes>(`/admin/reservations/${id}`, query)
}
async function listReservations(query?: Record<string, any>) {
return getRequest<ReservationListRes>(`/admin/reservations`, query)
}
async function createReservation(
payload: InventoryNext.CreateReservationItemInput
) {
return postRequest<ReservationRes>(`/admin/reservations`, payload)
}
async function updateReservation(
id: string,
payload: InventoryNext.UpdateReservationItemInput
) {
return postRequest<ReservationRes>(`/admin/reservations/${id}`, payload)
}
async function deleteReservation(id: string) {
return deleteRequest<ReservationDeleteRes>(`/admin/reservations/${id}`)
}
export const reservations = {
retrieve: retrieveReservation,
list: listReservations,
create: createReservation,
update: updateReservation,
delete: deleteReservation,
}

View File

@@ -1,10 +1,3 @@
import { AdminCustomersRes } from "@medusajs/client-types"
import {
AdminCollectionsRes,
AdminProductsRes,
AdminPromotionRes,
AdminRegionsRes,
} from "@medusajs/medusa"
import {
AdminApiKeyResponse,
AdminCustomerGroupResponse,
@@ -14,13 +7,20 @@ import {
SalesChannelDTO,
UserDTO,
} from "@medusajs/types"
import {
AdminCollectionsRes,
AdminProductsRes,
AdminPromotionRes,
AdminRegionsRes,
} from "@medusajs/medusa"
import { InventoryItemRes, PriceListRes } from "../../types/api-responses"
import { Outlet, RouteObject } from "react-router-dom"
import { ProtectedRoute } from "../../components/authentication/protected-route"
import { AdminCustomersRes } from "@medusajs/client-types"
import { ErrorBoundary } from "../../components/error/error-boundary"
import { MainLayout } from "../../components/layout/main-layout"
import { ProtectedRoute } from "../../components/authentication/protected-route"
import { SettingsLayout } from "../../components/layout/settings-layout"
import { InventoryItemRes, PriceListRes } from "../../types/api-responses"
/**
* Experimental V2 routes.
@@ -383,7 +383,7 @@ export const v2Routes: RouteObject[] = [
path: "create",
lazy: () =>
import(
"../../v2-routes/customer-groups/customer-group-create"
"../../v2-routes/reservations/reservation-list/create-reservation"
),
},
],
@@ -417,6 +417,51 @@ export const v2Routes: RouteObject[] = [
},
],
},
{
path: "/reservations",
handle: {
crumb: () => "Reservations",
},
children: [
{
path: "",
lazy: () =>
import("../../v2-routes/reservations/reservation-list"),
children: [
{
path: "create",
lazy: () =>
import(
"../../v2-routes/reservations/reservation-list/create-reservation"
),
},
],
},
{
path: ":id",
lazy: () =>
import("../../v2-routes/reservations/reservation-detail"),
handle: {
crumb: ({ reservation }: any) => {
return (
reservation?.inventory_item?.title ??
reservation?.inventory_item?.sku ??
reservation?.id
)
},
},
children: [
{
path: "edit",
lazy: () =>
import(
"../../v2-routes/reservations/reservation-detail/components/edit-reservation"
),
},
],
},
],
},
{
path: "/inventory",
handle: {
@@ -437,7 +482,6 @@ export const v2Routes: RouteObject[] = [
},
children: [
{
// TODO: edit item
path: "edit",
lazy: () =>
import(
@@ -445,7 +489,6 @@ export const v2Routes: RouteObject[] = [
),
},
{
// TODO: edit item attributes
path: "attributes",
lazy: () =>
import(
@@ -453,7 +496,6 @@ export const v2Routes: RouteObject[] = [
),
},
{
// TODO: manage locations
path: "locations",
lazy: () =>
import(
@@ -461,7 +503,6 @@ export const v2Routes: RouteObject[] = [
),
},
{
// TODO: adjust item level
path: "locations/:location_id",
lazy: () =>
import(

View File

@@ -1,6 +1,7 @@
import { Container, Heading } from "@medusajs/ui"
import { ExtendedReservationItem } from "@medusajs/medusa"
import { Container } from "@medusajs/ui"
import { useAdminInventoryItem } from "medusa-react"
import { useInventoryItem } from "../../../../../hooks/api/inventory"
type ReservationGeneralSectionProps = {
reservation: ExtendedReservationItem
@@ -11,11 +12,11 @@ type ReservationGeneralSectionProps = {
export const ReservationGeneralSection = ({
reservation,
}: ReservationGeneralSectionProps) => {
const { inventory_item, isLoading, isError, error } = useAdminInventoryItem(
const { inventory_item, isPending, isError, error } = useInventoryItem(
reservation.inventory_item_id
)
if (isLoading || !inventory_item) {
if (isPending || !inventory_item) {
return <div>Loading...</div>
}

View File

@@ -1 +1 @@
export { ReservationDetail as Component } from "./reservation-edit"
export { ReservationDetail as Component } from "./reservation-detail"

View File

@@ -1,9 +1,10 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { ExtendedReservationItem } from "@medusajs/medusa"
import { usePrompt } from "@medusajs/ui"
import { useAdminDeleteReservation } from "medusa-react"
import { useTranslation } from "react-i18next"
import { toast, usePrompt } from "@medusajs/ui"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { ExtendedReservationItem } from "@medusajs/medusa"
import { useDeleteReservationItem } from "../../../../../hooks/api/reservations"
import { useTranslation } from "react-i18next"
export const ReservationActions = ({
reservation,
@@ -12,7 +13,7 @@ export const ReservationActions = ({
}) => {
const { t } = useTranslation()
const prompt = usePrompt()
const { mutateAsync } = useAdminDeleteReservation(reservation.id)
const { mutateAsync } = useDeleteReservationItem(reservation.id)
const handleDelete = async () => {
const res = await prompt({
@@ -26,7 +27,14 @@ export const ReservationActions = ({
return
}
await mutateAsync()
await mutateAsync(undefined, {
onSuccess: () => {
toast.success(t("general.success"), {
dismissLabel: t("actions.close"),
description: t("inventory.reservation.deleteSuccessToast"),
})
},
})
}
return (

View File

@@ -109,6 +109,13 @@ export type UpdateInventoryItemReq = Omit<
"id"
>
// Reservations
export type UpdateReservationReq = Omit<
InventoryNext.UpdateReservationItemInput,
"id"
>
export type CreateReservationReq = InventoryNext.CreateReservationItemInput
// Inventory Item Levels
export type InventoryItemLocationBatch = {
creates: { location_id: string; stocked_quantity?: number }[]

View File

@@ -71,6 +71,13 @@ export type RegionRes = { region: RegionDTO }
export type RegionListRes = { regions: RegionDTO[] } & ListRes
export type RegionDeleteRes = DeleteRes
// Reservations
export type ReservationRes = { reservation: InventoryNext.ReservationItemDTO }
export type ReservationListRes = {
reservations: InventoryNext.ReservationItemDTO[]
} & ListRes
export type ReservationDeleteRes = DeleteRes
// Campaigns
export type CampaignRes = { campaign: CampaignDTO }
export type CampaignListRes = { campaigns: CampaignDTO[] } & ListRes

View File

@@ -109,11 +109,11 @@ export const AdjustInventoryForm = ({
/>
<AttributeGridRow
title={t("inventory.reserved")}
value={item.reserved_quantity}
value={level.reserved_quantity}
/>
<AttributeGridRow
title={t("inventory.available")}
value={stockedQuantityUpdate - item.reserved_quantity}
value={stockedQuantityUpdate - level.reserved_quantity}
/>
</div>
<Form.Field

View File

@@ -18,7 +18,7 @@ export const InventoryItemReservationsSection = ({
<div className="flex items-center justify-between px-6 py-4">
<Heading>{t("reservations.domain")}</Heading>
<Button size="small" variant="secondary" asChild>
<Link to="locations">{t("actions.create")}</Link>
<Link to="/reservations/create">{t("actions.create")}</Link>
</Button>
</div>
<ReservationItemTable inventoryItem={inventoryItem} />

View File

@@ -1,9 +1,9 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { toast, usePrompt } from "@medusajs/ui"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { InventoryNext } from "@medusajs/types"
import { useDeleteReservationItem } from "../../../../../hooks/api/inventory"
import { usePrompt } from "@medusajs/ui"
import { useDeleteReservationItem } from "../../../../../hooks/api/reservations"
import { useTranslation } from "react-i18next"
export const ReservationActions = ({
@@ -27,7 +27,14 @@ export const ReservationActions = ({
return
}
await mutateAsync()
await mutateAsync(undefined, {
onSuccess: () => {
toast.success(t("general.success"), {
dismissLabel: t("actions.close"),
description: t("inventory.reservation.deleteSuccessToast"),
})
},
})
}
return (
@@ -38,7 +45,7 @@ export const ReservationActions = ({
{
icon: <PencilSquare />,
label: t("actions.edit"),
to: `/reservation/${reservation.id}/edit`,
to: `/reservations/${reservation.id}/edit`,
},
],
},

View File

@@ -1,9 +1,9 @@
import { DataTable } from "../../../../../components/table/data-table"
import { InventoryNext } from "@medusajs/types"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { useInventoryTableColumns } from "./use-reservation-list-table-columns"
import { useInventoryTableQuery } from "./use-reservation-list-table-query"
import { useReservationItems } from "../../../../../hooks/api/inventory"
import { useReservationItems } from "../../../../../hooks/api/reservations"
import { useReservationTableColumn } from "./use-reservation-list-table-columns"
import { useReservationsTableQuery } from "./use-reservation-list-table-query"
const PAGE_SIZE = 20
@@ -12,29 +12,24 @@ export const ReservationItemTable = ({
}: {
inventoryItem: InventoryNext.InventoryItemDTO
}) => {
const { searchParams, raw } = useInventoryTableQuery({
const { searchParams, raw } = useReservationsTableQuery({
pageSize: PAGE_SIZE,
})
const {
reservations,
count,
isPending: isLoading,
isError,
error,
} = useReservationItems({
...searchParams,
inventory_item_id: [inventoryItem.id],
})
const { reservations, count, isPending, isError, error } =
useReservationItems({
...searchParams,
inventory_item_id: [inventoryItem.id],
})
const columns = useInventoryTableColumns({ sku: inventoryItem.sku! })
const columns = useReservationTableColumn({ sku: inventoryItem.sku! })
const { table } = useDataTable({
data: reservations ?? [],
columns,
count,
enablePagination: true,
getRowId: (row) => row.id,
getRowId: (row: InventoryNext.ReservationItemDTO) => row.id,
pageSize: PAGE_SIZE,
})
@@ -48,7 +43,7 @@ export const ReservationItemTable = ({
columns={columns}
pageSize={PAGE_SIZE}
count={count}
isLoading={isLoading}
isLoading={isPending}
pagination
queryObject={raw}
/>

View File

@@ -9,14 +9,14 @@ import { useTranslation } from "react-i18next"
/**
* Adds missing properties to the InventoryItemDTO type.
*/
interface ExtendedInventoryItem extends InventoryNext.ReservationItemDTO {
interface ExtendedReservationItem extends InventoryNext.ReservationItemDTO {
line_item: { order_id: string }
location: StockLocationDTO
}
const columnHelper = createColumnHelper<ExtendedInventoryItem>()
const columnHelper = createColumnHelper<ExtendedReservationItem>()
export const useInventoryTableColumns = ({ sku }: { sku: string }) => {
export const useReservationTableColumn = ({ sku }: { sku: string }) => {
const { t } = useTranslation()
return useMemo(
@@ -32,17 +32,17 @@ export const useInventoryTableColumns = ({ sku }: { sku: string }) => {
},
}),
columnHelper.accessor("line_item.order_id", {
header: t("inventory.reserved"),
header: t("inventory.reservation.orderID"),
cell: ({ getValue }) => {
const quantity = getValue()
const orderId = getValue()
if (Number.isNaN(quantity)) {
if (!orderId) {
return <PlaceholderCell />
}
return (
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{quantity}</span>
<span className="truncate">{orderId}</span>
</div>
)
},
@@ -64,7 +64,7 @@ export const useInventoryTableColumns = ({ sku }: { sku: string }) => {
},
}),
columnHelper.accessor("location.name", {
header: t("inventory.location"),
header: t("inventory.reservation.location"),
cell: ({ getValue }) => {
const location = getValue()
@@ -99,7 +99,7 @@ export const useInventoryTableColumns = ({ sku }: { sku: string }) => {
}),
columnHelper.display({
id: "actions",
cell: ({ row }) => <ReservationActions item={row.original} />,
cell: ({ row }) => <ReservationActions reservation={row.original} />,
}),
],
[t]

View File

@@ -1,6 +1,6 @@
import { useQueryParams } from "../../../../../hooks/use-query-params"
export const useInventoryTableQuery = ({
export const useReservationsTableQuery = ({
pageSize = 20,
prefix,
}: {

View File

@@ -0,0 +1,208 @@
import * as zod from "zod"
import { Button, Input, Select, Text, Textarea, toast } from "@medusajs/ui"
import { InventoryNext, StockLocationDTO } from "@medusajs/types"
import {
RouteDrawer,
useRouteModal,
} from "../../../../../../components/route-modal"
import { Form } from "../../../../../../components/common/form"
import { InventoryItemRes } from "../../../../../../types/api-responses"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { useUpdateReservationItem } from "../../../../../../hooks/api/reservations"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
type EditReservationFormProps = {
reservation: InventoryNext.ReservationItemDTO
locations: StockLocationDTO[]
item: InventoryItemRes["inventory_item"]
}
const EditReservationSchema = z.object({
location_id: z.string(),
description: z.string().optional(),
quantity: z.number().min(1),
})
const AttributeGridRow = ({
title,
value,
}: {
title: string
value: string | number
}) => {
return (
<div className="grid grid-cols-2 divide-x">
<Text className="px-2 py-1.5" size="small" leading="compact">
{title}
</Text>
<Text className="px-2 py-1.5" size="small" leading="compact">
{value}
</Text>
</div>
)
}
const getDefaultValues = (reservation: InventoryNext.ReservationItemDTO) => {
return {
quantity: reservation.quantity,
location_id: reservation.location_id,
description: reservation.description ?? undefined,
}
}
export const EditReservationForm = ({
reservation,
item,
locations,
}: EditReservationFormProps) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const form = useForm<zod.infer<typeof EditReservationSchema>>({
defaultValues: getDefaultValues(reservation),
resolver: zodResolver(EditReservationSchema),
})
const { mutateAsync } = useUpdateReservationItem(reservation.id)
const handleSubmit = form.handleSubmit(async (values) => {
mutateAsync(values as any, {
onSuccess: () => {
handleSuccess()
toast.success(t("general.success"), {
dismissLabel: t("actions.close"),
description: t("inventory.reservation.updateSuccessToast"),
})
},
})
})
const reservedQuantity = form.watch("quantity")
const locationId = form.watch("location_id")
const level = item.location_levels!.find(
(level: InventoryNext.InventoryLevelDTO) => level.location_id === locationId
)
return (
<RouteDrawer.Form form={form}>
<form
onSubmit={handleSubmit}
className="flex flex-1 flex-col overflow-hidden"
>
<RouteDrawer.Body className="flex flex-1 flex-col gap-y-8 overflow-auto">
<Form.Field
control={form.control}
name="location_id"
render={({ field: { onChange, value, ref, ...field } }) => {
return (
<Form.Item>
<Form.Label>{t("inventory.reservation.location")}</Form.Label>
<Form.Control>
<Select
value={value}
onValueChange={(v) => {
onChange(v)
}}
{...field}
>
<Select.Trigger ref={ref}>
<Select.Value />
</Select.Trigger>
<Select.Content>
{(locations || []).map((r) => (
<Select.Item key={r.id} value={r.id}>
{r.name}
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<div className="text-ui-fg-subtle shadow-elevation-card-rest grid grid-rows-4 divide-y rounded-lg border">
<AttributeGridRow
title={t("fields.title")}
value={item.title ?? item.sku!}
/>
<AttributeGridRow title={t("fields.sku")} value={item.sku!} />
<AttributeGridRow
title={t("fields.inStock")}
value={level!.stocked_quantity}
/>
<AttributeGridRow
title={t("inventory.available")}
value={level!.stocked_quantity - reservedQuantity}
/>
</div>
<Form.Field
control={form.control}
name="quantity"
render={({ field: { onChange, value, ...field } }) => {
return (
<Form.Item>
<Form.Label>
{t("inventory.reservation.reservedAmount")}
</Form.Label>
<Form.Control>
<Input
type="number"
min={0}
max={level!.available_quantity + reservation.quantity}
value={value || ""}
onChange={(e) => {
const value = e.target.value
if (value === "") {
onChange(null)
} else {
onChange(parseFloat(value))
}
}}
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="description"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>{t("fields.description")}</Form.Label>
<Form.Control>
<Textarea {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex items-center justify-end gap-x-2">
<RouteDrawer.Close asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</RouteDrawer.Close>
<Button type="submit" size="small" isLoading={false}>
{t("actions.save")}
</Button>
</div>
</RouteDrawer.Footer>
</form>
</RouteDrawer.Form>
)
}

View File

@@ -0,0 +1 @@
export { EditReservationForm as Component } from "./edit-reservation-form"

View File

@@ -0,0 +1,53 @@
import { EditReservationForm } from "./components/edit-reservation-form"
import { Heading } from "@medusajs/ui"
import { InventoryNext } from "@medusajs/types"
import { RouteDrawer } from "../../../../../components/route-modal"
import { useInventoryItem } from "../../../../../hooks/api/inventory"
import { useParams } from "react-router-dom"
import { useReservationItem } from "../../../../../hooks/api/reservations"
import { useStockLocations } from "../../../../../hooks/api/stock-locations"
import { useTranslation } from "react-i18next"
export const ReservationEdit = () => {
const { id } = useParams()
const { t } = useTranslation()
const { reservation, isPending, isError, error } = useReservationItem(id!)
const { inventory_item: inventoryItem } = useInventoryItem(
reservation?.inventory_item_id!,
{
enabled: !!reservation,
}
)
const { stock_locations } = useStockLocations(
{
id: inventoryItem?.location_levels?.map(
(l: InventoryNext.InventoryLevelDTO) => l.location_id
),
},
{
enabled: !!inventoryItem?.location_levels,
}
)
const ready = !isPending && reservation && inventoryItem && stock_locations
if (isError) {
throw error
}
return (
<RouteDrawer>
<RouteDrawer.Header>
<Heading>{t("inventory.reservation.editItemDetails")}</Heading>
</RouteDrawer.Header>
{ready && (
<EditReservationForm
locations={stock_locations}
reservation={reservation}
item={inventoryItem}
/>
)}
</RouteDrawer>
)
}

View File

@@ -0,0 +1 @@
export { ReservationEdit as Component } from "./edit-reservation-modal"

View File

@@ -0,0 +1 @@
export * from "./reservation-general-section"

View File

@@ -0,0 +1,89 @@
import { AdminReservationResponse, StockLocationDTO } from "@medusajs/types"
import { Container, Heading } from "@medusajs/ui"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { InventoryNext } from "@medusajs/types"
import { PencilSquare } from "@medusajs/icons"
import { SectionRow } from "../../../../../components/common/section"
import { useInventoryItem } from "../../../../../hooks/api/inventory"
import { useStockLocation } from "../../../../../hooks/api/stock-locations"
import { useTranslation } from "react-i18next"
type ReservationGeneralSectionProps = {
reservation: AdminReservationResponse["reservation"]
}
export const ReservationGeneralSection = ({
reservation,
}: ReservationGeneralSectionProps) => {
const { t } = useTranslation()
const { inventory_item: inventoryItem, isPending: isLoadingInventoryItem } =
useInventoryItem(reservation.inventory_item_id)
const { stock_location: location, isPending: isLoadingLocation } =
useStockLocation(reservation.location_id)
if (
isLoadingInventoryItem ||
!inventoryItem ||
isLoadingLocation ||
!location
) {
return <div>Loading...</div>
}
const locationLevel = inventoryItem.location_levels!.find(
(l: InventoryNext.InventoryLevelDTO) =>
l.location_id === reservation.location_id
)
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading>
{t("inventory.reservation.header", {
itemName: inventoryItem.title ?? inventoryItem.sku,
})}
</Heading>
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("actions.edit"),
to: `edit`,
},
],
},
]}
/>
</div>
<SectionRow
title={t("inventory.reservation.orderID")}
value={reservation.line_item_id} // TODO fetch order
/>
<SectionRow
title={t("inventory.reservation.description")}
value={reservation.description}
/>
<SectionRow
title={t("inventory.reservation.location")}
value={location?.name}
/>
<SectionRow
title={t("inventory.reservation.inStockAtLocation")}
value={locationLevel?.stocked_quantity}
/>
<SectionRow
title={t("inventory.reservation.availableAtLocation")}
value={locationLevel?.available_quantity}
/>
<SectionRow
title={t("inventory.reservation.reservedAtLocation")}
value={reservation.quantity}
/>
</Container>
)
}

View File

@@ -0,0 +1,2 @@
export { ReservationDetail as Component } from "./reservation-edit"
export { reservationItemLoader as loader } from "./loader"

View File

@@ -0,0 +1,20 @@
import { LoaderFunctionArgs } from "react-router-dom"
import { ReservationItemRes } from "../../../types/api-responses"
import { client } from "../../../lib/client"
import { queryClient } from "../../../lib/medusa"
import { reservationItemsQueryKeys } from "../../../hooks/api/reservations"
const reservationDetailQuery = (id: string) => ({
queryKey: reservationItemsQueryKeys.detail(id),
queryFn: async () => client.reservations.retrieve(id),
})
export const reservationItemLoader = async ({ params }: LoaderFunctionArgs) => {
const id = params.id
const query = reservationDetailQuery(id!)
return (
queryClient.getQueryData<ReservationItemRes>(query.queryKey) ??
(await queryClient.fetchQuery(query))
)
}

View File

@@ -0,0 +1,53 @@
import { Outlet, useLoaderData, useParams } from "react-router-dom"
import { InventoryItemGeneralSection } from "../../inventory/inventory-detail/components/inventory-item-general-section"
import { JsonViewSection } from "../../../components/common/json-view-section"
import { ReservationGeneralSection } from "./components/reservation-general-section"
import { reservationItemLoader } from "./loader"
import { useReservationItem } from "../../../hooks/api/reservations"
export const ReservationDetail = () => {
const { id } = useParams()
const initialData = useLoaderData() as Awaited<
ReturnType<typeof reservationItemLoader>
>
const { reservation, isLoading, isError, error } = useReservationItem(
id!,
{},
{
initialData,
}
)
if (isLoading || !reservation) {
return <div>Loading...</div>
}
if (isError) {
throw error
}
return (
<div className="flex flex-col gap-y-2">
<div className="flex flex-col gap-x-4 lg:flex-row lg:items-start">
<div className="flex w-full flex-col gap-y-2">
<ReservationGeneralSection reservation={reservation} />
<div className="hidden lg:block">
<JsonViewSection data={reservation} />
</div>
</div>
<div className="mt-2 flex w-full max-w-[100%] flex-col gap-y-2 lg:mt-0 lg:max-w-[400px]">
<InventoryItemGeneralSection
inventoryItem={reservation.inventory_item}
/>
<div className="lg:hidden">
<JsonViewSection data={reservation} />
</div>
<Outlet />
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./reservation-list-table"

View File

@@ -0,0 +1,57 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { ExtendedReservationItem } from "@medusajs/medusa"
import { useDeleteReservationItem } from "../../../../../hooks/api/reservations"
import { usePrompt } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
export const ReservationActions = ({
reservation,
}: {
reservation: ExtendedReservationItem
}) => {
const { t } = useTranslation()
const prompt = usePrompt()
const { mutateAsync } = useDeleteReservationItem(reservation.id)
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("reservations.deleteWarning"),
confirmText: t("actions.delete"),
cancelText: t("actions.cancel"),
})
if (!res) {
return
}
await mutateAsync()
}
return (
<ActionMenu
groups={[
{
actions: [
{
label: t("actions.edit"),
to: `${reservation.id}/edit`,
icon: <PencilSquare />,
},
],
},
{
actions: [
{
label: t("actions.delete"),
onClick: handleDelete,
icon: <Trash />,
},
],
},
]}
/>
)
}

View File

@@ -0,0 +1,62 @@
import { Button, Container, Heading } from "@medusajs/ui"
import { DataTable } from "../../../../../components/table/data-table"
import { Link } from "react-router-dom"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { useReservationItems } from "../../../../../hooks/api/reservations"
import { useReservationTableColumns } from "./use-reservation-table-columns"
import { useReservationTableFilters } from "./use-reservation-table-filters"
import { useReservationTableQuery } from "./use-reservation-table-query"
import { useTranslation } from "react-i18next"
const PAGE_SIZE = 20
export const ReservationListTable = () => {
const { t } = useTranslation()
const { searchParams } = useReservationTableQuery({
pageSize: PAGE_SIZE,
})
const { reservations, count, isPending, isError, error } =
useReservationItems({
...searchParams,
})
const filters = useReservationTableFilters()
const columns = useReservationTableColumns()
const { table } = useDataTable({
data: reservations || [],
columns,
count,
enablePagination: true,
getRowId: (row) => row.id,
pageSize: PAGE_SIZE,
})
if (isError) {
throw error
}
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading>{t("reservations.domain")}</Heading>
<Button variant="secondary" size="small" asChild>
<Link to="create">{t("actions.create")}</Link>
</Button>
</div>
<DataTable
table={table}
columns={columns}
pageSize={PAGE_SIZE}
count={count}
isLoading={isPending}
filters={filters}
pagination
navigateTo={(row) => row.id}
search={false}
/>
</Container>
)
}

View File

@@ -0,0 +1,104 @@
import { ExtendedReservationItem } from "@medusajs/medusa"
import { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { InlineLink } from "../../../../../components/common/inline-link"
import { DateCell } from "../../../../../components/table/table-cells/common/date-cell"
import { PlaceholderCell } from "../../../../../components/table/table-cells/common/placeholder-cell"
import { ReservationActions } from "./reservation-actions"
const columnHelper = createColumnHelper<ExtendedReservationItem>()
export const useReservationTableColumns = () => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.accessor("inventory_item", {
header: t("fields.sku"),
cell: ({ getValue }) => {
const inventoryItem = getValue()
if (!inventoryItem || !inventoryItem.sku) {
return <PlaceholderCell />
}
return (
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{inventoryItem.sku}</span>
</div>
)
},
}),
columnHelper.accessor("line_item", {
header: t("fields.order"),
cell: ({ getValue }) => {
const inventoryItem = getValue()
if (!inventoryItem || !inventoryItem.order?.display_id) {
return <PlaceholderCell />
}
return (
<div className="flex size-full items-center overflow-hidden">
<InlineLink to={`/orders/${inventoryItem.order.id}`}>
<span className="truncate">
#{inventoryItem.order.display_id}
</span>
</InlineLink>
</div>
)
},
}),
columnHelper.accessor("description", {
header: t("fields.description"),
cell: ({ getValue }) => {
const description = getValue()
if (!description) {
return <PlaceholderCell />
}
return (
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{description}</span>
</div>
)
},
}),
columnHelper.accessor("created_at", {
header: t("fields.created"),
cell: ({ getValue }) => {
const created = getValue()
return <DateCell date={created} />
},
}),
columnHelper.accessor("quantity", {
header: () => (
<div className="flex size-full items-center justify-end overflow-hidden text-right">
<span className="truncate">{t("fields.quantity")}</span>
</div>
),
cell: ({ getValue }) => {
const quantity = getValue()
return (
<div className="flex size-full items-center justify-end overflow-hidden text-right">
<span className="truncate">{quantity}</span>
</div>
)
},
}),
columnHelper.display({
id: "actions",
cell: ({ row }) => {
const reservation = row.original
return <ReservationActions reservation={reservation} />
},
}),
],
[t]
)
}

View File

@@ -0,0 +1,35 @@
import { Filter } from "../../../../../components/table/data-table"
import { useStockLocations } from "../../../../../hooks/api/stock-locations"
import { useTranslation } from "react-i18next"
export const useReservationTableFilters = () => {
const { t } = useTranslation()
const { stock_locations } = useStockLocations({
limit: 1000,
})
const filters: Filter[] = []
if (stock_locations) {
const stockLocationFilter: Filter = {
type: "select",
options: stock_locations.map((s) => ({
label: s.name,
value: s.id,
})),
key: "location_id",
searchable: true,
label: t("fields.location"),
}
filters.push(stockLocationFilter)
}
filters.push({
type: "date",
key: "created_at",
label: t("fields.createdAt"),
})
return filters
}

View File

@@ -0,0 +1,32 @@
import { AdminGetReservationsParams } from "@medusajs/medusa"
import { useQueryParams } from "../../../../../hooks/use-query-params"
export const useReservationTableQuery = ({
pageSize = 20,
prefix,
}: {
pageSize?: number
prefix?: string
}) => {
const raw = useQueryParams(
["location_id", "offset", "created_at", "quantity", "updated_at", "order"],
prefix
)
const { location_id, created_at, updated_at, quantity, offset, ...rest } = raw
const searchParams: AdminGetReservationsParams = {
limit: pageSize,
offset: offset ? parseInt(offset) : undefined,
location_id: location_id,
created_at: created_at ? JSON.parse(created_at) : undefined,
updated_at: updated_at ? JSON.parse(updated_at) : undefined,
quantity: quantity ? JSON.parse(quantity) : undefined,
...rest,
}
return {
searchParams,
raw,
}
}

View File

@@ -0,0 +1 @@
export const reservationListExpand = "line_item"

View File

@@ -0,0 +1,286 @@
import * as zod from "zod"
import { Button, Heading, Input, Text, Textarea, toast } from "@medusajs/ui"
import {
RouteFocusModal,
useRouteModal,
} from "../../../../../../components/route-modal"
import { Combobox } from "../../../../../../components/common/combobox"
import { Form } from "../../../../../../components/common/form"
import { InventoryItemRes } from "../../../../../../types/api-responses"
import { InventoryNext } from "@medusajs/types"
import React from "react"
import { useCreateReservationItem } from "../../../../../../hooks/api/reservations"
import { useForm } from "react-hook-form"
import { useInventoryItems } from "../../../../../../hooks/api/inventory"
import { useStockLocations } from "../../../../../../hooks/api/stock-locations"
import { useTranslation } from "react-i18next"
import { zodResolver } from "@hookform/resolvers/zod"
export const CreateReservationSchema = zod.object({
inventory_item_id: zod.string().min(1),
location_id: zod.string().min(1),
quantity: zod.number().min(1),
description: zod.string().optional(),
})
const AttributeGridRow = ({
title,
value,
}: {
title: string
value: string | number
}) => {
return (
<div className="grid grid-cols-2 divide-x">
<Text className="px-2 py-1.5" size="small" leading="compact">
{title}
</Text>
<Text className="px-2 py-1.5" size="small" leading="compact">
{value}
</Text>
</div>
)
}
export const CreateReservationForm = () => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const [inventorySearch, setInventorySearch] = React.useState<string | null>(
null
)
const form = useForm<zod.infer<typeof CreateReservationSchema>>({
defaultValues: {
inventory_item_id: "",
location_id: "",
quantity: 0,
description: "",
},
resolver: zodResolver(CreateReservationSchema),
})
const { inventory_items } = useInventoryItems(
{
q: inventorySearch,
},
{
enabled: !!inventorySearch,
}
)
const inventoryItemId = form.watch("inventory_item_id")
const selectedInventoryItem = inventory_items?.find(
(it) => it.id === inventoryItemId
) as InventoryItemRes["inventory_item"] | undefined
const locationId = form.watch("location_id")
const selectedLocationLevel = selectedInventoryItem?.location_levels?.find(
(it) => it.location_id === locationId
)
const quantity = form.watch("quantity")
const { stock_locations } = useStockLocations(
{
id:
selectedInventoryItem?.location_levels?.map(
(level: InventoryNext.InventoryLevelDTO) => level.location_id
) ?? [],
},
{
enabled: !!selectedInventoryItem,
}
)
const { mutateAsync, isPending } = useCreateReservationItem()
const handleSubmit = form.handleSubmit(async (data) => {
await mutateAsync(data, {
onSuccess: ({ reservation }) => {
toast.success(t("general.success"), {
dismissLabel: t("actions.close"),
description: t("inventory.reservation.successToast"),
})
handleSuccess(`/reservations/${reservation.id}`)
},
})
})
return (
<RouteFocusModal.Form form={form}>
<form onSubmit={handleSubmit}>
<RouteFocusModal.Header>
<div className="flex items-center justify-end gap-x-2">
<RouteFocusModal.Close asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button
type="submit"
variant="primary"
size="small"
isLoading={isPending}
>
{t("actions.reservation")}
</Button>
</div>
</RouteFocusModal.Header>
<RouteFocusModal.Body className="flex flex-col items-center pt-[72px]">
<div className="flex w-full max-w-[720px] flex-col gap-y-8">
<Heading>{t("inventory.reservation.create")}</Heading>
<div className="grid grid-cols-2 gap-4">
<Form.Field
key={"inventory_item_id"}
control={form.control}
name={"inventory_item_id"}
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<Form.Label>
{t("inventory.reservation.itemToReserve")}
</Form.Label>
<Form.Control>
<Combobox
onSearchValueChange={(value: string) =>
setInventorySearch(value)
}
value={value}
onChange={(v) => {
onChange(v)
}}
{...field}
options={(inventory_items ?? []).map(
(inventoryItem) => ({
label: inventoryItem.title ?? inventoryItem.sku!,
value: inventoryItem.id,
})
)}
/>
</Form.Control>
</Form.Item>
)
}}
/>
<Form.Field
key={"location_id"}
control={form.control}
name={"location_id"}
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<Form.Label>{t("fields.location")}</Form.Label>
<Form.Control>
<Combobox
disabled={!!selectedInventoryItem}
value={value}
onChange={(v) => {
onChange(v)
}}
{...field}
options={(stock_locations ?? []).map(
(stockLocation) => ({
label: stockLocation.name,
value: stockLocation.id,
})
)}
/>
</Form.Control>
</Form.Item>
)
}}
/>
</div>
<div className="text-ui-fg-subtle shadow-elevation-card-rest grid grid-rows-4 divide-y rounded-lg border">
<AttributeGridRow
title={t("fields.title")}
value={
selectedInventoryItem?.title ??
selectedInventoryItem?.sku ??
"-"
}
/>
<AttributeGridRow
title={t("fields.sku")}
value={selectedInventoryItem?.sku ?? "-"}
/>
<AttributeGridRow
title={t("fields.inStock")}
value={selectedLocationLevel?.stocked_quantity ?? "-"}
/>
<AttributeGridRow
title={t("inventory.available")}
value={
selectedLocationLevel
? selectedLocationLevel.available_quantity - quantity
: "-"
}
/>
</div>
<div className="w-full lg:w-1/2">
<Form.Field
control={form.control}
name="quantity"
render={({ field: { onChange, value, ...field } }) => {
return (
<Form.Item>
<Form.Label>{t("fields.quantity")}</Form.Label>
<Form.Control>
<Input
type="number"
placeholder={t(
"inventory.reservation.quantityPlaceholder"
)}
min={0}
max={
selectedLocationLevel
? selectedLocationLevel.available_quantity
: 0
}
value={value || ""}
onChange={(e) => {
const value = e.target.value
if (value === "") {
onChange(null)
} else {
onChange(parseFloat(value))
}
}}
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<Form.Field
control={form.control}
name="description"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>{t("fields.description")}</Form.Label>
<Form.Control>
<Textarea
{...field}
placeholder={t(
"inventory.reservation.descriptionPlaceholder"
)}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</RouteFocusModal.Body>
</form>
</RouteFocusModal.Form>
)
}

View File

@@ -0,0 +1,10 @@
import { CreateReservationForm } from "./components/create-reservation-form"
import { RouteFocusModal } from "../../../../components/route-modal"
export const CreateReservationModal = () => {
return (
<RouteFocusModal>
<CreateReservationForm />
</RouteFocusModal>
)
}

View File

@@ -0,0 +1 @@
export { CreateReservationModal as Component } from "./create-reservation-modal"

View File

@@ -0,0 +1 @@
export { ReservationList as Component } from "./reservation-list"

View File

@@ -0,0 +1,11 @@
import { Outlet } from "react-router-dom"
import { ReservationListTable } from "./components/reservation-list-table"
export const ReservationList = () => {
return (
<div className="flex flex-col gap-y-2">
<ReservationListTable />
<Outlet />
</div>
)
}

View File

@@ -676,20 +676,22 @@ export default class InventoryModuleService<
context
)
const levelAdjustmentUpdates = inventoryLevels.map((level) => {
const adjustment = adjustments
.get(level.inventory_item_id)
?.get(level.location_id)
const levelAdjustmentUpdates = inventoryLevels
.map((level) => {
const adjustment = adjustments
.get(level.inventory_item_id)
?.get(level.location_id)
if (!adjustment) {
return
}
if (!adjustment) {
return
}
return {
id: level.id,
reserved_quantity: level.reserved_quantity + adjustment,
}
})
return {
id: level.id,
reserved_quantity: level.reserved_quantity + adjustment,
}
})
.filter(Boolean)
await this.inventoryLevelService_.update(levelAdjustmentUpdates, context)

View File

@@ -1,3 +1,5 @@
import { defaultAdminInventoryItemFields } from "../inventory-items/query-config"
export const defaultAdminReservationFields = [
"id",
"location_id",
@@ -8,6 +10,7 @@ export const defaultAdminReservationFields = [
"metadata",
"created_at",
"updated_at",
...defaultAdminInventoryItemFields.map((f) => `inventory_item.${f}`),
]
export const retrieveTransformQueryConfig = {

View File

@@ -10,3 +10,5 @@ export * from "./promotion"
export * from "./sales-channel"
export * from "./stock-locations"
export * from "./tax"
export * from "./product-category"
export * from "./reservation"

View File

@@ -0,0 +1 @@
export * from "./reservation"

View File

@@ -0,0 +1,34 @@
import { PaginatedResponse } from "../../../common"
/**
* @experimental
*/
interface ReservationResponse {
id: string
line_item_id: string | null
location_id: string
quantity: string
external_id: string | null
description: string | null
inventory_item_id: string
inventory_item: Record<string, unknown> // TODO: add InventoryItemResponse
metadata?: Record<string, unknown>
created_by?: string | null
deleted_at?: Date | string | null
created_at?: Date | string
updated_at?: Date | string
}
/**
* @experimental
*/
export interface AdminReservationResponse {
reservation: ReservationResponse
}
/**
* @experimental
*/
export interface AdminReservationListResponse extends PaginatedResponse {
reservations: ReservationResponse[]
}

View File

@@ -0,0 +1 @@
export * from "./admin"