) => {
+ const key = `${QUICK_CHECKOUT_INDIVIDUAL_PREFIX}${asset.bookingAssetId}`;
+ const current = get(scannedItemsAtom);
+ if (current[key]) return;
+ set(scannedItemsAtom, {
+ [key]: {
+ type: "asset",
+ codeType: "qr",
+ data: {
+ id: asset.id,
+ bookingAssetId: asset.bookingAssetId,
+ title: asset.title,
+ mainImage: asset.mainImage,
+ thumbnailImage: asset.thumbnailImage,
+ kitId: asset.kitId ?? null,
+ type: "INDIVIDUAL",
+ } as unknown as AssetFromQr,
+ },
+ ...current,
+ });
+ }
+);
+
/*******************************/
/* BOOKING FULFIL-AND-CHECKOUT ATOMS */
diff --git a/apps/webapp/app/components/scanner/drawer/uses/partial-checkin-drawer.test.tsx b/apps/webapp/app/components/scanner/drawer/uses/partial-checkin-drawer.test.tsx
index f34f42689..43aef329e 100644
--- a/apps/webapp/app/components/scanner/drawer/uses/partial-checkin-drawer.test.tsx
+++ b/apps/webapp/app/components/scanner/drawer/uses/partial-checkin-drawer.test.tsx
@@ -275,7 +275,7 @@ describe("PartialCheckinDrawer", () => {
useRouteLoaderDataMock.mockReturnValue({ minimizedSidebar: false });
});
- it("exposes 'Check in without scanning' only on pending qty-tracked rows", () => {
+ it("exposes 'Check in without scanning' on pending individual and qty-tracked rows", () => {
const assets: BookingExpectedAsset[] = [
individual({ id: "asset-ind-1", title: "Camera body" }),
qty({ id: "asset-qty-1", title: "Battery" }),
@@ -293,20 +293,16 @@ describe("PartialCheckinDrawer", () => {
const buttons = screen.queryAllByRole("button", {
name: /check in without scanning/i,
});
- // Exactly one — the qty-tracked pending row.
- expect(buttons).toHaveLength(1);
-
- // The pending-qty row title must be adjacent to the button. Walk up
- // to the row container and assert the Battery title lives there.
- const row = buttons[0].closest("tr");
- expect(row).not.toBeNull();
- expect(within(row!).getByText("Battery")).toBeInTheDocument();
-
- // The individual assets (pending + already reconciled) must not
- // surface the button. Already-reconciled rows also render under a
- // collapser (closed by default), so absence of the button is the
- // invariant we care about.
- expect(screen.queryByText("Camera body")).toBeInTheDocument();
+ // Both pending individual and qty-tracked rows have it.
+ expect(buttons).toHaveLength(2);
+
+ const row1 = buttons[0].closest("tr");
+ expect(row1).not.toBeNull();
+ expect(within(row1!).getByText("Camera body")).toBeInTheDocument();
+
+ const row2 = buttons[1].closest("tr");
+ expect(row2).not.toBeNull();
+ expect(within(row2!).getByText("Battery")).toBeInTheDocument();
});
it("click inserts a synthetic-keyed entry into scannedItemsAtom", async () => {
diff --git a/apps/webapp/app/components/scanner/drawer/uses/partial-checkin-drawer.tsx b/apps/webapp/app/components/scanner/drawer/uses/partial-checkin-drawer.tsx
index d843285b0..3cd28f3b5 100644
--- a/apps/webapp/app/components/scanner/drawer/uses/partial-checkin-drawer.tsx
+++ b/apps/webapp/app/components/scanner/drawer/uses/partial-checkin-drawer.tsx
@@ -54,6 +54,8 @@ import {
removeScannedItemsByAssetIdAtom,
removeMultipleScannedItemsAtom,
scannedItemsAtom,
+ quickCheckinIndividualAssetAtom,
+ QUICK_CHECKIN_INDIVIDUAL_PREFIX,
} from "~/atoms/qr-scanner";
import { AvailabilityBadge } from "~/components/booking/availability-label";
import { BookingStatusBadge } from "~/components/booking/booking-status-badge";
@@ -419,6 +421,9 @@ export default function PartialCheckinDrawer({
const removeAssetsFromList = useSetAtom(removeScannedItemsByAssetIdAtom);
const removeItemsFromList = useSetAtom(removeMultipleScannedItemsAtom);
const quickCheckinQtyAsset = useSetAtom(quickCheckinQtyAssetAtom);
+ const quickCheckinIndividualAsset = useSetAtom(
+ quickCheckinIndividualAssetAtom
+ );
/**
* BookingAsset id of the most-recently-added quick-checkin row. The
@@ -1290,17 +1295,21 @@ export default function PartialCheckinDrawer({
* 600ms so subsequent re-renders don't keep stealing focus.
*/
const handleQuickCheckin = useCallback(
- (asset: QtyExpectedAsset) => {
- quickCheckinQtyAsset(asset);
- setRecentlyAddedBookingAssetId(asset.bookingAssetId);
- if (recentlyAddedTimerRef.current) {
- clearTimeout(recentlyAddedTimerRef.current);
+ (asset: BookingExpectedAsset) => {
+ if (asset.kind === "QUANTITY_TRACKED") {
+ quickCheckinQtyAsset(asset);
+ setRecentlyAddedBookingAssetId(asset.bookingAssetId);
+ if (recentlyAddedTimerRef.current) {
+ clearTimeout(recentlyAddedTimerRef.current);
+ }
+ recentlyAddedTimerRef.current = setTimeout(() => {
+ setRecentlyAddedBookingAssetId(null);
+ }, 600);
+ } else {
+ quickCheckinIndividualAsset(asset);
}
- recentlyAddedTimerRef.current = setTimeout(() => {
- setRecentlyAddedBookingAssetId(null);
- }, 600);
},
- [quickCheckinQtyAsset]
+ [quickCheckinQtyAsset, quickCheckinIndividualAsset]
);
/**
@@ -1939,10 +1948,12 @@ export function AssetRow({ asset }: { asset: AssetFromQr }) {
})();
// An active synthetic entry (via quick-checkin) lives under a key
- // prefixed by `qty-checkin:` + the slice's bookingAssetId. Used below to
+ // prefixed by `qty-checkin:` or `ind-checkin:` + the slice's bookingAssetId. Used below to
// pick between the "Scanned" and "Checked in without scan" badges.
const isQuickCheckin = Boolean(
- bookingAssetId && items[`${QUICK_CHECKIN_QR_PREFIX}${bookingAssetId}`]
+ bookingAssetId &&
+ (items[`${QUICK_CHECKIN_QR_PREFIX}${bookingAssetId}`] ||
+ items[`${QUICK_CHECKIN_INDIVIDUAL_PREFIX}${bookingAssetId}`])
);
// Use custom configurations for partial check-in context
@@ -2007,7 +2018,7 @@ export function AssetRow({ asset }: { asset: AssetFromQr }) {
badgeText: "Checked in without scan",
tooltipTitle: "Marked in without scanning",
tooltipContent:
- "This quantity-tracked asset was added via the Check in without scanning button — no QR scan required.",
+ "This asset was added via the Check in without scanning button — no QR scan required.",
priority: 50,
className: "bg-indigo-50 border-indigo-200 text-indigo-700",
},
diff --git a/apps/webapp/app/components/scanner/drawer/uses/partial-checkout-drawer.test.tsx b/apps/webapp/app/components/scanner/drawer/uses/partial-checkout-drawer.test.tsx
index ef314d7af..50b622b7d 100644
--- a/apps/webapp/app/components/scanner/drawer/uses/partial-checkout-drawer.test.tsx
+++ b/apps/webapp/app/components/scanner/drawer/uses/partial-checkout-drawer.test.tsx
@@ -578,7 +578,7 @@ describe("PartialCheckoutDrawer", () => {
* object that must be physically confirmed). Already-checked-out
* individuals are dropped from the pending bucket entirely.
*/
- it("exposes 'Check out without scanning' only on pending qty-tracked rows", () => {
+ it("exposes 'Check out without scanning' on pending individual and qty-tracked rows", () => {
const assets: BookingExpectedAsset[] = [
individualExpected({ id: "asset-ind-1", title: "Camera body" }),
qtyExpected({ id: "asset-qty-1", title: "Battery" }),
@@ -599,16 +599,16 @@ describe("PartialCheckoutDrawer", () => {
const buttons = screen.queryAllByRole("button", {
name: /check out without scanning/i,
});
- // Exactly one — the qty-tracked pending row.
- expect(buttons).toHaveLength(1);
+ // Both pending individual and qty-tracked rows have it.
+ expect(buttons).toHaveLength(2);
- // The pending-qty row title must be adjacent to the button.
- const row = buttons[0].closest("tr");
- expect(row).not.toBeNull();
- expect(within(row!).getByText("Battery")).toBeInTheDocument();
+ const row1 = buttons[0].closest("tr");
+ expect(row1).not.toBeNull();
+ expect(within(row1!).getByText("Camera body")).toBeInTheDocument();
- // The pending INDIVIDUAL row still renders, but exposes no button.
- expect(screen.getByText("Camera body")).toBeInTheDocument();
+ const row2 = buttons[1].closest("tr");
+ expect(row2).not.toBeNull();
+ expect(within(row2!).getByText("Battery")).toBeInTheDocument();
});
/**
diff --git a/apps/webapp/app/components/scanner/drawer/uses/partial-checkout-drawer.tsx b/apps/webapp/app/components/scanner/drawer/uses/partial-checkout-drawer.tsx
index 468c2a964..9b6412be7 100644
--- a/apps/webapp/app/components/scanner/drawer/uses/partial-checkout-drawer.tsx
+++ b/apps/webapp/app/components/scanner/drawer/uses/partial-checkout-drawer.tsx
@@ -21,6 +21,8 @@ import {
scannedItemsAtom,
removeScannedItemsByAssetIdAtom,
removeMultipleScannedItemsAtom,
+ quickCheckoutIndividualAssetAtom,
+ QUICK_CHECKOUT_INDIVIDUAL_PREFIX,
} from "~/atoms/qr-scanner";
import { BookingStatusBadge } from "~/components/booking/booking-status-badge";
import CheckoutDialog from "~/components/booking/checkout-dialog";
@@ -341,6 +343,9 @@ export default function PartialCheckoutDrawer({
// entry into `scannedItemsAtom`. Wired to the pending-list's
// `onQuickAction` below.
const quickCheckoutQtyAsset = useSetAtom(quickCheckoutQtyAssetAtom);
+ const quickCheckoutIndividualAsset = useSetAtom(
+ quickCheckoutIndividualAssetAtom
+ );
// Per-slice qty state — keyed by `bookingAssetId`, NOT `asset.id`, so a
// single qty asset booked under multiple slices (kit-driven +
@@ -982,10 +987,14 @@ export default function PartialCheckoutDrawer({
* render.
*/
const handleQuickCheckout = useCallback(
- (asset: QtyExpectedAsset) => {
- quickCheckoutQtyAsset(asset);
+ (asset: BookingExpectedAsset) => {
+ if (asset.kind === "QUANTITY_TRACKED") {
+ quickCheckoutQtyAsset(asset);
+ } else {
+ quickCheckoutIndividualAsset(asset);
+ }
},
- [quickCheckoutQtyAsset]
+ [quickCheckoutQtyAsset, quickCheckoutIndividualAsset]
);
/**
@@ -1185,13 +1194,12 @@ export function AssetRow({ asset }: { asset: AssetFromQr }) {
// default checkout badges and the indigo "Checked out without
// scan" marker. Mirrors check-in's `isQuickCheckin` probe.
const scannedBookingAssetId =
- asset.type === AssetType.QUANTITY_TRACKED
- ? (asset as unknown as { bookingAssetId?: string | null })
- .bookingAssetId ?? null
- : null;
+ (asset as unknown as { bookingAssetId?: string | null }).bookingAssetId ??
+ null;
const isQuickCheckout = Boolean(
scannedBookingAssetId &&
- items[`${QUICK_CHECKOUT_QR_PREFIX}${scannedBookingAssetId}`]
+ (items[`${QUICK_CHECKOUT_QR_PREFIX}${scannedBookingAssetId}`] ||
+ items[`${QUICK_CHECKOUT_INDIVIDUAL_PREFIX}${scannedBookingAssetId}`])
);
// Use custom configurations for partial check-out context
@@ -1283,7 +1291,7 @@ export function AssetRow({ asset }: { asset: AssetFromQr }) {
badgeText: "Checked out without scan",
tooltipTitle: "Marked out without scanning",
tooltipContent:
- "This quantity-tracked asset was added via the Check out without scanning button — no QR scan required.",
+ "This asset was added via the Check out without scanning button — no QR scan required.",
priority: 50,
className: "bg-indigo-50 border-indigo-200 text-indigo-700",
},
diff --git a/apps/webapp/app/components/scanner/drawer/uses/pending-items-list.tsx b/apps/webapp/app/components/scanner/drawer/uses/pending-items-list.tsx
index 84f1e3d69..89687c6dd 100644
--- a/apps/webapp/app/components/scanner/drawer/uses/pending-items-list.tsx
+++ b/apps/webapp/app/components/scanner/drawer/uses/pending-items-list.tsx
@@ -179,7 +179,7 @@ function PendingKitGroup({
}: {
kit: { id: string; name: string; mainImage: string | null };
assets: BookingExpectedAsset[];
- onQuickAction: (asset: QtyExpectedAsset) => void;
+ onQuickAction: (asset: BookingExpectedAsset) => void;
copy: ModeCopy;
}) {
// Collapsed by default — a pending kit is N rows of noise while the
@@ -268,7 +268,7 @@ function PendingKitGroup({
{/* Indented child row. Left border + padding mirrors
the booking-overview kit grouping so it's visually
obvious these assets belong to the kit above. */}
-
+
+
@@ -379,26 +389,25 @@ function PendingKitQtyChild({
}
/**
- * Render a pending (not-yet-scanned) INDIVIDUAL asset row. No action
- * buttons — operator must scan the QR code. Mirrors the audit drawer's
- * `renderPendingAsset` layout.
+ * Render a pending (not-yet-scanned) INDIVIDUAL asset row.
*/
function renderPendingIndividualAsset(
asset: IndividualExpectedAsset,
kit: { id: string; name: string } | undefined,
+ onQuickAction: () => void,
copy: ModeCopy
): ReactNode {
return (
|
-
-
+
+
-
+
{asset.title}
@@ -418,10 +427,20 @@ function renderPendingIndividualAsset(
+
+
|
- {/* No remove button for pending items */}
|
@@ -554,10 +573,9 @@ export type PendingItemsListProps = {
>;
/**
* Called when the operator clicks the "Check N without scanning"
- * button on a qty row. The caller wires the synthetic-entry dispatch
- * + any per-mode focus management.
+ * button on a qty row or individual row.
*/
- onQuickAction: (asset: QtyExpectedAsset) => void;
+ onQuickAction: (asset: BookingExpectedAsset) => void;
/**
* Total pending count (`pendingIndividuals.length +
* pendingQtyTracked.length`). Used to gate the muted section header.
@@ -635,7 +653,12 @@ export function PendingItemsList({
/>
))}
{looseIndividuals.map((asset) =>
- renderPendingIndividualAsset(asset, undefined, copy)
+ renderPendingIndividualAsset(
+ asset,
+ undefined,
+ () => onQuickAction(asset),
+ copy
+ )
)}
{looseQty.map((asset) =>
renderPendingQtyAsset(