diff --git a/apps/companion/app/(tabs)/bookings/[id].tsx b/apps/companion/app/(tabs)/bookings/[id].tsx index 76741c843..1109836f3 100644 --- a/apps/companion/app/(tabs)/bookings/[id].tsx +++ b/apps/companion/app/(tabs)/bookings/[id].tsx @@ -1372,42 +1372,73 @@ export default function BookingDetailScreen() { after the booking has gone ONGOING (canPartialCheckout, not canCheckout) so the user can keep taking the rest. */} {canPartialCheckout && ( - { - setSelectMode(selectMode === "checkout" ? null : "checkout"); - setSelectedAssetIds(new Set()); - }} - accessibilityLabel={ - selectMode === "checkout" - ? "Cancel selection" - : "Select assets to check out" - } - accessibilityRole="button" - > - + + router.push( + `/(tabs)/scanner?bookingId=${ + booking.id + }&bookingName=${encodeURIComponent( + booking.name + )}&bookingAction=checkout` + ) } - /> - + + + Scan to Check Out + + + + { + setSelectMode( + selectMode === "checkout" ? null : "checkout" + ); + setSelectedAssetIds(new Set()); + }} + accessibilityLabel={ + selectMode === "checkout" + ? "Cancel selection" + : "Select assets to check out" + } + accessibilityRole="button" > - {selectMode === "checkout" ? "Cancel" : "Select to Check Out"} - - + + + {selectMode === "checkout" + ? "Cancel" + : "Select to Check Out"} + + + )} {/* Book-by-model: a RESERVED booking with unfulfilled reservations diff --git a/apps/companion/app/(tabs)/scanner.tsx b/apps/companion/app/(tabs)/scanner.tsx index 52766ca5b..4bbfd0702 100644 --- a/apps/companion/app/(tabs)/scanner.tsx +++ b/apps/companion/app/(tabs)/scanner.tsx @@ -204,6 +204,7 @@ function ScannerContent() { // all the add-mode scan capture / blockers / list rendering apply to both. const isBookingAddMode = isBookingMode && (bookingAction === "add" || bookingAction === "fulfil"); + const isBookingCheckoutMode = isBookingMode && bookingAction === "checkout"; // Filter scanner actions based on the user's role in the current org const availableActions = useMemo( @@ -891,32 +892,49 @@ function ScannerContent() { return; } - // Only assets currently checked out for this booking are eligible - // to check in — mirror the single-asset gate (line ~933). A kit - // member that was never checked out (e.g. skipped during a - // progressive check-out) would otherwise be submitted and - // 400-rejected by partialCheckinBooking's progressive-checkout - // guard, failing the entire batch including its checked-out peers. + // For check-in: Only assets currently checked out for this booking are eligible + // to check in. For check-out: Exclude members that are CHECKED_OUT or IN_CUSTODY. const checkedOutMembers = members.filter( (a) => a.status === "CHECKED_OUT" ); - const eligible = checkedOutMembers.filter( - (a) => - !bookingCtx.checkedInAssetIds.has(a.id) && - !bookingCheckinItems.some((item) => item.targetId === a.id) + const checkoutEligibleMembers = members.filter( + (a) => a.status !== "CHECKED_OUT" && a.status !== "IN_CUSTODY" ); + const eligible = isBookingCheckoutMode + ? checkoutEligibleMembers.filter( + (a) => + !bookingCheckinItems.some((item) => item.targetId === a.id) + ) + : checkedOutMembers.filter( + (a) => + !bookingCtx.checkedInAssetIds.has(a.id) && + !bookingCheckinItems.some((item) => item.targetId === a.id) + ); if (eligible.length === 0) { - // Distinguish "none are checked out" from "all already covered". - const reason = - checkedOutMembers.length === 0 - ? { - title: "Not Checked Out", - message: `None of "${kit.name}"'s assets in this booking are checked out.`, - } - : { - title: "Already Covered", - message: `All of "${kit.name}"'s checked-out assets are already checked in or scanned.`, - }; + let reason; + if (isBookingCheckoutMode) { + reason = + checkoutEligibleMembers.length === 0 + ? { + title: "Already Checked Out", + message: `None of "${kit.name}"'s assets in this booking are eligible for checkout (already checked out or in custody).`, + } + : { + title: "Already Covered", + message: `All of "${kit.name}"'s eligible assets are already scanned.`, + }; + } else { + reason = + checkedOutMembers.length === 0 + ? { + title: "Not Checked Out", + message: `None of "${kit.name}"'s assets in this booking are checked out.`, + } + : { + title: "Already Covered", + message: `All of "${kit.name}"'s checked-out assets are already checked in or scanned.`, + }; + } flashFrame("error"); Haptics.notificationAsync( Haptics.NotificationFeedbackType.Warning @@ -1272,30 +1290,59 @@ function ScannerContent() { return; } - if (bookingCtx.checkedInAssetIds.has(asset.id)) { - flashFrame("error"); - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); - setScanResult({ - type: "error", - title: "Already Checked In", - message: `"${asset.title}" has already been checked in for this booking.`, - }); - finalizeScan(); - return; - } + if (isBookingCheckoutMode) { + if (asset.status === "CHECKED_OUT") { + flashFrame("error"); + Haptics.notificationAsync( + Haptics.NotificationFeedbackType.Warning + ); + setScanResult({ + type: "error", + title: "Already Checked Out", + message: `"${asset.title}" is already checked out.`, + }); + finalizeScan(); + return; + } + if (asset.status === "IN_CUSTODY") { + flashFrame("error"); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + setScanResult({ + type: "error", + title: "In Custody", + message: `"${asset.title}" is currently in custody.`, + }); + finalizeScan(); + return; + } + } else { + if (bookingCtx.checkedInAssetIds.has(asset.id)) { + flashFrame("error"); + Haptics.notificationAsync( + Haptics.NotificationFeedbackType.Warning + ); + setScanResult({ + type: "error", + title: "Already Checked In", + message: `"${asset.title}" has already been checked in for this booking.`, + }); + finalizeScan(); + return; + } - if (asset.status !== "CHECKED_OUT") { - flashFrame("error"); - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); - setScanResult({ - type: "error", - title: "Not Checked Out", - message: `"${asset.title}" is ${asset.status - .replace(/_/g, " ") - .toLowerCase()}, not checked out.`, - }); - finalizeScan(); - return; + if (asset.status !== "CHECKED_OUT") { + flashFrame("error"); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + setScanResult({ + type: "error", + title: "Not Checked Out", + message: `"${asset.title}" is ${asset.status + .replace(/_/g, " ") + .toLowerCase()}, not checked out.`, + }); + finalizeScan(); + return; + } } setBookingCheckinItems((prev) => [newItem, ...prev]); @@ -1305,9 +1352,9 @@ function ScannerContent() { setScanResult({ type: "success", title: asset.title, - message: `Added to check-in (${ - bookingCheckinItems.length + 1 - } items)`, + message: isBookingCheckoutMode + ? `Added to check-out (${bookingCheckinItems.length + 1} items)` + : `Added to check-in (${bookingCheckinItems.length + 1} items)`, }); setTimeout(() => setScanResult(null), 1200); @@ -2000,6 +2047,80 @@ function ScannerContent() { ); }; + const handleBookingCheckout = () => { + if (!bookingId || !currentOrg || bookingCheckinItems.length === 0) return; + + const count = bookingCheckinItems.length; + Alert.alert( + "Check Out Assets", + `Check out ${count} ${count === 1 ? "asset" : "assets"} for "${ + bookingName || "this booking" + }"?`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Check Out", + onPress: async () => { + setIsBookingSubmitting(true); + const assetIds = bookingCheckinItems.map((i) => i.targetId); + const timeZone = (() => { + try { + return ( + Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC" + ); + } catch { + return "UTC"; + } + })(); + + const { data: result, error } = await api.partialCheckoutBooking( + currentOrg.id, + bookingId, + assetIds, + timeZone + ); + setIsBookingSubmitting(false); + + if (error) { + Alert.alert("Error", error); + return; + } + + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + playScanSound(); + const msg = result?.isComplete + ? `All assets checked out! "${ + bookingName || "Booking" + }" is now ongoing.` + : `${ + result?.checkedOutCount ?? bookingCheckinItems.length + } checked out, ${result?.remainingCount ?? "some"} remaining.`; + Alert.alert("Checked Out", msg, [ + { + text: "OK", + onPress: () => { + setBookingCheckinItems([]); + lastScanRef.current = ""; + markBookingDirty(bookingId); + if (result?.isComplete) { + InteractionManager.runAfterInteractions(() => { + pushIntoTab( + "/(tabs)/bookings", + `/(tabs)/bookings/${bookingId}` + ); + }); + } else { + fetchBookingCtx(); + } + }, + }, + ]); + }, + }, + ] + ); + }; + // ── Permission states ─────────────────────────────── if (!permission) { @@ -2126,10 +2247,15 @@ function ScannerContent() { ? "Fulfil & Check Out" : isBookingAddMode ? "Add to Booking" + : isBookingCheckoutMode + ? "Booking Check-Out" : "Booking Check-In"} - {bookingName || "Scan assets to check in"} + {bookingName || + (isBookingCheckoutMode + ? "Scan assets to check out" + : "Scan assets to check in")} {isBookingFulfilMode && bookingCtx && @@ -2222,6 +2348,8 @@ function ScannerContent() { ? "Scan the reserved units to assign" : isBookingAddMode ? "Scan assets or kits to add" + : isBookingCheckoutMode + ? "Scan assets to check out" : "Scan assets to check in" : instructionMap[action]} @@ -2383,7 +2511,7 @@ function ScannerContent() { /> )} - {/* ── Booking Drawer (check-in / scan-to-add) ── */} + {/* ── Booking Drawer (check-in / check-out / scan-to-add) ── */} {showBookingDrawer && ( 1 ? "s" : "" } scanned` + : isBookingCheckoutMode + ? `${bookingCheckinItems.length} asset${ + bookingCheckinItems.length > 1 ? "s" : "" + } to check out` : `${bookingCheckinItems.length} asset${ bookingCheckinItems.length > 1 ? "s" : "" } to check in` @@ -2412,6 +2544,10 @@ function ScannerContent() { } more to assign` : isBookingAddMode ? "Add to Booking" + : isBookingCheckoutMode + ? `Check Out ${bookingCheckinItems.length} ${ + bookingCheckinItems.length === 1 ? "Asset" : "Assets" + }` : `Check In ${bookingCheckinItems.length} ${ bookingCheckinItems.length === 1 ? "Asset" : "Assets" }` @@ -2421,6 +2557,8 @@ function ScannerContent() { ? "log-out-outline" : isBookingAddMode ? "add-circle-outline" + : isBookingCheckoutMode + ? "log-out-outline" : "log-in-outline" } isSubmitting={isBookingSubmitting} @@ -2431,6 +2569,8 @@ function ScannerContent() { ? handleBookingFulfil : isBookingAddMode ? handleBookingAdd + : isBookingCheckoutMode + ? handleBookingCheckout : handleBookingCheckin } showStatus={isBookingAddMode} diff --git a/apps/webapp/app/atoms/qr-scanner.ts b/apps/webapp/app/atoms/qr-scanner.ts index 66b826c72..94bcb240d 100644 --- a/apps/webapp/app/atoms/qr-scanner.ts +++ b/apps/webapp/app/atoms/qr-scanner.ts @@ -683,6 +683,64 @@ export const quickCheckoutQtyAssetAtom = atom( } ); +/** Synthetic QR-key prefix for quick-checkin of INDIVIDUAL assets. */ +export const QUICK_CHECKIN_INDIVIDUAL_PREFIX = "ind-checkin:"; + +/** Synthetic QR-key prefix for quick-checkout of INDIVIDUAL assets. */ +export const QUICK_CHECKOUT_INDIVIDUAL_PREFIX = "ind-checkout:"; + +/** Inserts a synthetic scanned-item entry for a pending INDIVIDUAL asset. */ +export const quickCheckinIndividualAssetAtom = atom( + null, + (get, set, asset: Extract) => { + const key = `${QUICK_CHECKIN_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, + }); + } +); + +/** Inserts a synthetic scanned-item entry for a pending INDIVIDUAL asset during partial checkout. */ +export const quickCheckoutIndividualAssetAtom = atom( + null, + (get, set, asset: Extract) => { + 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(