From b5a957c1000d5ff2dc8d2c9be6e7bcdea2b9a062 Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Fri, 12 Jul 2024 07:56:43 +0530 Subject: [PATCH 01/53] feat(booking-asset-scan): add callback in ZXingScanner --- app/components/zxing-scanner.tsx | 26 ++++++++++++-------------- app/routes/_layout+/scanner.tsx | 21 +++++++++++++++++++-- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/app/components/zxing-scanner.tsx b/app/components/zxing-scanner.tsx index 750844800..a484b4cc0 100644 --- a/app/components/zxing-scanner.tsx +++ b/app/components/zxing-scanner.tsx @@ -1,4 +1,4 @@ -import { useFetcher, useLoaderData, useNavigate } from "@remix-run/react"; +import { useFetcher, useLoaderData } from "@remix-run/react"; import { useZxing } from "react-zxing"; import { useClientNotification } from "~/hooks/use-client-notification"; import type { loader } from "~/routes/_layout+/scanner"; @@ -6,19 +6,22 @@ import { ShelfError } from "~/utils/error"; import { isFormProcessing } from "~/utils/form"; import { Spinner } from "./shared/spinner"; +type ZXingScannerProps = { + onQrDetectionSuccess: (qrId: string) => void | Promise; + videoMediaDevices?: MediaDeviceInfo[]; +}; + export const ZXingScanner = ({ videoMediaDevices, -}: { - videoMediaDevices: MediaDeviceInfo[] | undefined; -}) => { + onQrDetectionSuccess, +}: ZXingScannerProps) => { const [sendNotification] = useClientNotification(); - const navigate = useNavigate(); const fetcher = useFetcher(); const { scannerCameraId } = useLoaderData(); const isProcessing = isFormProcessing(fetcher.state); // Function to decode the QR code - const decodeQRCodes = (result: string) => { + const decodeQRCodes = async (result: string) => { // console.log("QR code detected", result); if (result != null) { const regex = /^(https?:\/\/)([^/:]+)(:\d+)?\/qr\/([a-zA-Z0-9]+)$/; @@ -34,13 +37,8 @@ export const ZXingScanner = ({ return; } - sendNotification({ - title: "Shelf's QR Code detected", - message: "Redirecting to mapped asset", - icon: { name: "success", variant: "success" }, - }); const qrId = match[4]; // Get the last segment of the URL as the QR id - navigate(`/qr/${qrId}`); + onQrDetectionSuccess && (await onQrDetectionSuccess(qrId)); } }; @@ -48,9 +46,9 @@ export const ZXingScanner = ({ deviceId: scannerCameraId, constraints: { video: true, audio: false }, timeBetweenDecodingAttempts: 100, - onDecodeResult(result) { + async onDecodeResult(result) { // console.log(result.getText()); - decodeQRCodes(result.getText()); + await decodeQRCodes(result.getText()); }, onError(cause) { throw new ShelfError({ diff --git a/app/routes/_layout+/scanner.tsx b/app/routes/_layout+/scanner.tsx index 641b6f23c..1db68da78 100644 --- a/app/routes/_layout+/scanner.tsx +++ b/app/routes/_layout+/scanner.tsx @@ -4,12 +4,13 @@ import type { MetaFunction, } from "@remix-run/node"; import { json } from "@remix-run/node"; -import { Link } from "@remix-run/react"; +import { Link, useNavigate } from "@remix-run/react"; import { ErrorContent } from "~/components/errors"; import Header from "~/components/layout/header"; import type { HeaderData } from "~/components/layout/header/types"; import { Spinner } from "~/components/shared/spinner"; import { ZXingScanner } from "~/components/zxing-scanner"; +import { useClientNotification } from "~/hooks/use-client-notification"; import { useQrScanner } from "~/hooks/use-qr-scanner"; import { useViewportHeight } from "~/hooks/use-viewport-height"; import scannerCss from "~/styles/scanner.css?url"; @@ -48,10 +49,23 @@ export const meta: MetaFunction = () => [ ]; const QRScanner = () => { + const [sendNotification] = useClientNotification(); + const navigate = useNavigate(); + const { videoMediaDevices } = useQrScanner(); const { vh, isMd } = useViewportHeight(); const height = isMd ? vh - 132 : vh - 167; + function handleQrDetectionSuccess(qrId: string) { + sendNotification({ + title: "Shelf's QR Code detected", + message: "Redirecting to mapped asset", + icon: { name: "success", variant: "success" }, + }); + + navigate(`/qr/${qrId}`); + } + return ( <>
@@ -62,7 +76,10 @@ const QRScanner = () => { }} > {videoMediaDevices && videoMediaDevices.length > 0 ? ( - + ) : (
Waiting for permission to access camera. From 199702b62f54a74f1a92e67c96d011107059338e Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Fri, 12 Jul 2024 08:10:27 +0530 Subject: [PATCH 02/53] feat(booking-asset-scan): create focus area in zxing-scanner --- app/components/zxing-scanner.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/components/zxing-scanner.tsx b/app/components/zxing-scanner.tsx index a484b4cc0..50387bbec 100644 --- a/app/components/zxing-scanner.tsx +++ b/app/components/zxing-scanner.tsx @@ -77,6 +77,12 @@ export const ZXingScanner = ({ playsInline={true} className={`pointer-events-none size-full object-cover object-center`} /> +
+
+
+
+
+ Date: Fri, 12 Jul 2024 08:31:42 +0530 Subject: [PATCH 03/53] feat(booking-asset-scan): create route and button for scan assets for booking --- .../booking/booking-assets-column.tsx | 54 ++++++++++--------- app/components/icons/library.tsx | 18 +++++++ app/components/shared/icons-map.tsx | 5 +- .../bookings.$bookingId_.scan-assets.tsx | 3 ++ 4 files changed, 55 insertions(+), 25 deletions(-) create mode 100644 app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx diff --git a/app/components/booking/booking-assets-column.tsx b/app/components/booking/booking-assets-column.tsx index 11135f56d..75ca846dd 100644 --- a/app/components/booking/booking-assets-column.tsx +++ b/app/components/booking/booking-assets-column.tsx @@ -73,30 +73,36 @@ export function BookingAssetsColumn() {
Assets
{totalItems} items
- +
+ + + +
diff --git a/app/components/icons/library.tsx b/app/components/icons/library.tsx index a0a57d811..756b510ae 100644 --- a/app/components/icons/library.tsx +++ b/app/components/icons/library.tsx @@ -1548,3 +1548,21 @@ export const LockIcon = (props: SVGProps) => ( /> ); + +export const ScanIcon = (props: SVGProps) => ( + + + +); diff --git a/app/components/shared/icons-map.tsx b/app/components/shared/icons-map.tsx index 50073aba0..b5189ac6e 100644 --- a/app/components/shared/icons-map.tsx +++ b/app/components/shared/icons-map.tsx @@ -42,6 +42,7 @@ import { AssetLabel, LockIcon, ActiveSwitchIcon, + ScanIcon, } from "../icons/library"; /** The possible options for icons to be rendered in the button */ @@ -89,7 +90,8 @@ export type IconType = | "asset-label" | "lock" | "activate" - | "deactivate"; + | "deactivate" + | "scan"; type IconsMap = { [key in IconType]: JSX.Element; @@ -140,6 +142,7 @@ export const iconsMap: IconsMap = { lock: , activate: , deactivate: , + scan: , }; export default iconsMap; diff --git a/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx b/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx new file mode 100644 index 000000000..7acf0329c --- /dev/null +++ b/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx @@ -0,0 +1,3 @@ +export default function ScanAssetsForBookings() { + return
Scan assets to add into booking
; +} From b8a48d65bf93436c357021935542045be7216bb4 Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Fri, 12 Jul 2024 08:54:07 +0530 Subject: [PATCH 04/53] feat(booking-asset-scan): create loader and add scanner on scan-assets page --- .../booking/booking-assets-column.tsx | 22 +-- .../bookings.$bookingId_.scan-assets.tsx | 130 +++++++++++++++++- 2 files changed, 143 insertions(+), 9 deletions(-) diff --git a/app/components/booking/booking-assets-column.tsx b/app/components/booking/booking-assets-column.tsx index 75ca846dd..b1591a0d3 100644 --- a/app/components/booking/booking-assets-column.tsx +++ b/app/components/booking/booking-assets-column.tsx @@ -61,6 +61,13 @@ export function BookingAssetsColumn() { [items] ); + const canManageAssets = + !!booking.from && + !!booking.to && + !isCompleted && + !isArchived && + !canManageAssetsAsSelfService; + return (
@@ -74,18 +81,17 @@ export function BookingAssetsColumn() {
{totalItems} items
- = ({ data }) => [ + { title: data ? appendToMetaTitle(data.header.title) : "" }, +]; + +export const handle = { + breadcrumb: () => "single", +}; + export default function ScanAssetsForBookings() { - return
Scan assets to add into booking
; + const { booking } = useLoaderData(); + + const { videoMediaDevices } = useQrScanner(); + const { vh, isMd } = useViewportHeight(); + const height = isMd ? vh - 140 : vh - 167; + + return ( + <> +
+ + + {booking.status} + + +
+ } + /> + +
+ {videoMediaDevices && videoMediaDevices.length > 0 ? ( + + ) : ( +
+ Waiting for permission to access camera. +
+ )} +
+ + ); } From a73bb866a65807b60694479f62929fac5a9c77cc Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Fri, 12 Jul 2024 13:04:25 +0530 Subject: [PATCH 05/53] feat(booking-asset-scan): add drawer component --- app/components/shared/drawer.tsx | 115 +++++++++++++++++++++++++++++++ package-lock.json | 13 ++++ package.json | 1 + 3 files changed, 129 insertions(+) create mode 100644 app/components/shared/drawer.tsx diff --git a/app/components/shared/drawer.tsx b/app/components/shared/drawer.tsx new file mode 100644 index 000000000..220d3c52a --- /dev/null +++ b/app/components/shared/drawer.tsx @@ -0,0 +1,115 @@ +import { forwardRef } from "react"; +import { Drawer as DrawerPrimitive } from "vaul"; +import { tw } from "~/utils/tw"; + +const Drawer = ({ + shouldScaleBackground = true, + ...props +}: React.ComponentProps) => ( + +); +Drawer.displayName = "Drawer"; + +const DrawerTrigger = DrawerPrimitive.Trigger; + +const DrawerPortal = DrawerPrimitive.Portal; + +const DrawerClose = DrawerPrimitive.Close; + +const DrawerOverlay = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName; + +const DrawerContent = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + +
+ {children} + + +)); +DrawerContent.displayName = "DrawerContent"; + +const DrawerHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DrawerHeader.displayName = "DrawerHeader"; + +const DrawerFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DrawerFooter.displayName = "DrawerFooter"; + +const DrawerTitle = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerTitle.displayName = DrawerPrimitive.Title.displayName; + +const DrawerDescription = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerDescription.displayName = DrawerPrimitive.Description.displayName; + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +}; diff --git a/package-lock.json b/package-lock.json index 5d90c9fbd..23028d0ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,6 +80,7 @@ "tailwindcss-animate": "^1.0.7", "tiny-invariant": "^1.3.1", "ua-parser-js": "^1.0.37", + "vaul": "^0.9.1", "zod": "^3.22.4" }, "devDependencies": { @@ -22143,6 +22144,18 @@ "node": ">= 0.8" } }, + "node_modules/vaul": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/vaul/-/vaul-0.9.1.tgz", + "integrity": "sha512-fAhd7i4RNMinx+WEm6pF3nOl78DFkAazcN04ElLPFF9BMCNGbY/kou8UMhIcicm0rJCNePJP0Yyza60gGOD0Jw==", + "dependencies": { + "@radix-ui/react-dialog": "^1.0.4" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, "node_modules/vfile": { "version": "5.3.7", "resolved": "https://registry.npmjs.org/vfile/-/vfile-5.3.7.tgz", diff --git a/package.json b/package.json index 2c646a37b..903e48d01 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,7 @@ "tailwindcss-animate": "^1.0.7", "tiny-invariant": "^1.3.1", "ua-parser-js": "^1.0.37", + "vaul": "^0.9.1", "zod": "^3.22.4" }, "devDependencies": { From f3aa01f17953897c837464bb7eafe720a85088b2 Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Fri, 12 Jul 2024 14:00:46 +0530 Subject: [PATCH 06/53] feat(booking-asset-scan): create API to fetch scanned qr code asset --- app/components/shared/drawer.tsx | 230 +++++++++--------- .../bookings.$bookingId_.scan-assets.tsx | 59 +++-- app/routes/api+/bookings.get-scanned-asset.ts | 139 +++++++++++ 3 files changed, 297 insertions(+), 131 deletions(-) create mode 100644 app/routes/api+/bookings.get-scanned-asset.ts diff --git a/app/components/shared/drawer.tsx b/app/components/shared/drawer.tsx index 220d3c52a..5a40d2180 100644 --- a/app/components/shared/drawer.tsx +++ b/app/components/shared/drawer.tsx @@ -1,115 +1,115 @@ -import { forwardRef } from "react"; -import { Drawer as DrawerPrimitive } from "vaul"; -import { tw } from "~/utils/tw"; - -const Drawer = ({ - shouldScaleBackground = true, - ...props -}: React.ComponentProps) => ( - -); -Drawer.displayName = "Drawer"; - -const DrawerTrigger = DrawerPrimitive.Trigger; - -const DrawerPortal = DrawerPrimitive.Portal; - -const DrawerClose = DrawerPrimitive.Close; - -const DrawerOverlay = forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName; - -const DrawerContent = forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - -
- {children} - - -)); -DrawerContent.displayName = "DrawerContent"; - -const DrawerHeader = ({ - className, - ...props -}: React.HTMLAttributes) => ( -
-); -DrawerHeader.displayName = "DrawerHeader"; - -const DrawerFooter = ({ - className, - ...props -}: React.HTMLAttributes) => ( -
-); -DrawerFooter.displayName = "DrawerFooter"; - -const DrawerTitle = forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DrawerTitle.displayName = DrawerPrimitive.Title.displayName; - -const DrawerDescription = forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DrawerDescription.displayName = DrawerPrimitive.Description.displayName; - -export { - Drawer, - DrawerPortal, - DrawerOverlay, - DrawerTrigger, - DrawerClose, - DrawerContent, - DrawerHeader, - DrawerFooter, - DrawerTitle, - DrawerDescription, -}; +import { forwardRef } from "react"; +import { Drawer as DrawerPrimitive } from "vaul"; +import { tw } from "~/utils/tw"; + +const Drawer = ({ + shouldScaleBackground = true, + ...props +}: React.ComponentProps) => ( + +); +Drawer.displayName = "Drawer"; + +const DrawerTrigger = DrawerPrimitive.Trigger; + +const DrawerPortal = DrawerPrimitive.Portal; + +const DrawerClose = DrawerPrimitive.Close; + +const DrawerOverlay = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName; + +const DrawerContent = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + +
+ {children} + + +)); +DrawerContent.displayName = "DrawerContent"; + +const DrawerHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DrawerHeader.displayName = "DrawerHeader"; + +const DrawerFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DrawerFooter.displayName = "DrawerFooter"; + +const DrawerTitle = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerTitle.displayName = DrawerPrimitive.Title.displayName; + +const DrawerDescription = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerDescription.displayName = DrawerPrimitive.Description.displayName; + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +}; diff --git a/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx b/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx index 39eddddad..82c5dbb6f 100644 --- a/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx +++ b/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx @@ -1,3 +1,5 @@ +import { useEffect, useState } from "react"; +import type { Asset } from "@prisma/client"; import { BookingStatus, OrganizationRoles } from "@prisma/client"; import { json } from "@remix-run/node"; import type { MetaFunction, LoaderFunctionArgs } from "@remix-run/node"; @@ -5,13 +7,15 @@ import { useLoaderData } from "@remix-run/react"; import { z } from "zod"; import Header from "~/components/layout/header"; import type { HeaderData } from "~/components/layout/header/types"; -import { Badge } from "~/components/shared/badge"; import { Spinner } from "~/components/shared/spinner"; import { ZXingScanner } from "~/components/zxing-scanner"; +import { useClientNotification } from "~/hooks/use-client-notification"; +import useFetcherWithReset from "~/hooks/use-fetcher-with-reset"; import { useQrScanner } from "~/hooks/use-qr-scanner"; import { useViewportHeight } from "~/hooks/use-viewport-height"; import { getBooking } from "~/modules/booking/service.server"; import { appendToMetaTitle } from "~/utils/append-to-meta-title"; +import { userPrefs } from "~/utils/cookies.server"; import { makeShelfError, ShelfError } from "~/utils/error"; import { data, error, getParams } from "~/utils/http.server"; import { @@ -19,7 +23,6 @@ import { PermissionEntity, } from "~/utils/permissions/permission.validator.server"; import { requirePermission } from "~/utils/roles.server"; -import { bookingStatusColorMap } from "./bookings"; export async function loader({ context, request, params }: LoaderFunctionArgs) { const authSession = context.getSession(); @@ -68,11 +71,17 @@ export async function loader({ context, request, params }: LoaderFunctionArgs) { }); } + /** We get the userPrefs cookie so we can see if there is already a default camera */ + const cookieHeader = request.headers.get("Cookie"); + const cookie = (await userPrefs.parse(cookieHeader)) || {}; + const header: HeaderData = { title: `Scan assets for booking | ${booking.name}`, }; - return json(data({ header, booking })); + return json( + data({ header, booking, scannerCameraId: cookie.scannerCameraId }) + ); } catch (cause) { const reason = makeShelfError(cause, { userId, bookingId }); throw json(error(reason), { status: reason.status }); @@ -90,24 +99,41 @@ export const handle = { export default function ScanAssetsForBookings() { const { booking } = useLoaderData(); + const [fetchedAssets, setFetchedAssets] = useState([]); + + const fetcher = useFetcherWithReset<{ asset: Asset }>(); + const [sendNotification] = useClientNotification(); + const { videoMediaDevices } = useQrScanner(); const { vh, isMd } = useViewportHeight(); const height = isMd ? vh - 140 : vh - 167; + function handleQrDetectionSuccess(qrId: string) { + sendNotification({ + title: "Shelf's QR Code detected", + message: "Fetching mapped asset details...", + icon: { name: "success", variant: "success" }, + }); + + fetcher.submit( + { qrId, bookingId: booking.id }, + { method: "POST", action: "/api/bookings/get-scanned-asset" } + ); + } + + useEffect( + function handleFetcherSuccess() { + if (fetcher.data && fetcher.data?.asset) { + setFetchedAssets((prev) => [...prev, fetcher.data.asset]); + fetcher.reset(); + } + }, + [fetcher, fetcher.data] + ); + return ( <> -
- - - {booking.status} - - -
- } - /> +
+ {JSON.stringify(fetchedAssets, null, 2)} {videoMediaDevices && videoMediaDevices.length > 0 ? ( ) : (
diff --git a/app/routes/api+/bookings.get-scanned-asset.ts b/app/routes/api+/bookings.get-scanned-asset.ts new file mode 100644 index 000000000..9e64a7e03 --- /dev/null +++ b/app/routes/api+/bookings.get-scanned-asset.ts @@ -0,0 +1,139 @@ +import { AssetStatus, BookingStatus } from "@prisma/client"; +import { json, type ActionFunctionArgs } from "@remix-run/node"; +import { z } from "zod"; +import { getAsset } from "~/modules/asset/service.server"; +import { getQr } from "~/modules/qr/service.server"; +import { makeShelfError, ShelfError } from "~/utils/error"; +import { assertIsPost, data, error, parseData } from "~/utils/http.server"; + +export async function action({ request, context }: ActionFunctionArgs) { + const authSession = context.getSession(); + const { userId } = authSession; + + try { + assertIsPost(request); + + const formData = await request.formData(); + + const { qrId, bookingId } = parseData( + formData, + z.object({ qrId: z.string(), bookingId: z.string() }), + { + additionalData: { userId }, + } + ); + + const qr = await getQr(qrId); + + if (!qr.assetId || !qr.organizationId) { + throw new ShelfError({ + cause: null, + message: "QR is not associated with any asset yet.", + label: "Booking", + shouldBeCaptured: false, + }); + } + + const asset = await getAsset({ + id: qr.assetId, + organizationId: qr.organizationId, + include: { + custody: true, + bookings: { + where: { + status: { + notIn: [ + BookingStatus.ARCHIVED, + BookingStatus.CANCELLED, + BookingStatus.COMPLETE, + ], + }, + }, + select: { id: true, status: true }, + }, + }, + }); + + const isPartOfCurrentBooking = asset.bookings.some( + (b) => b.id === bookingId + ); + + /** Asset is already added in the booking */ + if (isPartOfCurrentBooking) { + throw new ShelfError({ + cause: null, + title: "Already added", + message: "This asset is already added in the current booking.", + label: "Booking", + shouldBeCaptured: false, + }); + } + + /** Asset is not available for booking */ + if (!asset.availableToBook) { + throw new ShelfError({ + cause: null, + title: "Unavailable", + message: + "This asset is marked as unavailable for bookings by administrator.", + label: "Booking", + shouldBeCaptured: false, + }); + } + + /** Asset is in custody */ + if (asset.custody) { + throw new ShelfError({ + cause: null, + title: "In custody", + message: + "This asset is in custody of team member making it currently unavailable for bookings.", + label: "Booking", + shouldBeCaptured: false, + }); + } + + /** Is booked for period */ + if ( + asset.bookings.length > 0 && + asset.bookings.some((b) => b.id !== bookingId) + ) { + throw new ShelfError({ + cause: null, + title: "Already booked", + message: + "This asset is added to a booking that is overlapping the selected time period.", + label: "Booking", + shouldBeCaptured: false, + }); + } + + /** If currently checked out */ + if (asset.status === AssetStatus.CHECKED_OUT) { + throw new ShelfError({ + cause: null, + title: "Checked out", + message: + "This asset is currently checked out as part of another booking and should be available for your selected date range period", + label: "Booking", + shouldBeCaptured: false, + }); + } + + /** Asset is part of a kit */ + if (asset.kitId) { + throw new ShelfError({ + cause: null, + title: "Part of kit", + message: "Remove the asset from the kit to add it individually.", + label: "Booking", + shouldBeCaptured: false, + }); + } + + return json(data({ asset })); + } catch (cause) { + const reason = makeShelfError(cause, { userId }); + return json(error(reason), { status: reason.status }); + } +} From cf841444d178caa3f840d38ce52437786129e4ff Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Sat, 13 Jul 2024 08:38:50 +0530 Subject: [PATCH 07/53] feat(booking-asset-scan): add dialog on scan-assets page to show list of scanned assets --- app/components/shared/drawer.tsx | 4 +- .../bookings.$bookingId_.scan-assets.tsx | 78 ++++- app/routes/api+/bookings.get-scanned-asset.ts | 278 +++++++++--------- 3 files changed, 211 insertions(+), 149 deletions(-) diff --git a/app/components/shared/drawer.tsx b/app/components/shared/drawer.tsx index 5a40d2180..f1c399e00 100644 --- a/app/components/shared/drawer.tsx +++ b/app/components/shared/drawer.tsx @@ -40,12 +40,12 @@ const DrawerContent = forwardRef< -
+
{children} diff --git a/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx b/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx index 82c5dbb6f..e593d04c0 100644 --- a/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx +++ b/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx @@ -5,9 +5,23 @@ import { json } from "@remix-run/node"; import type { MetaFunction, LoaderFunctionArgs } from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; import { z } from "zod"; +import { AssetLabel } from "~/components/icons/library"; import Header from "~/components/layout/header"; import type { HeaderData } from "~/components/layout/header/types"; +import { ListHeader } from "~/components/list/list-header"; +import { Button } from "~/components/shared/button"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTrigger, +} from "~/components/shared/drawer"; import { Spinner } from "~/components/shared/spinner"; +import { Table, Th } from "~/components/table"; +import When from "~/components/when/when"; import { ZXingScanner } from "~/components/zxing-scanner"; import { useClientNotification } from "~/hooks/use-client-notification"; import useFetcherWithReset from "~/hooks/use-fetcher-with-reset"; @@ -135,20 +149,68 @@ export default function ScanAssetsForBookings() { <>
-
- {JSON.stringify(fetchedAssets, null, 2)} + + + + + + +
+ + + {fetchedAssets.length} assets scanned + + + + +
+
+
+ +
+
+ +
+
+ List is empty +
+

+ Fill list by scanning codes... +

+
+
+
+ + 0}> + + + + +
Name
+
+ + 0}> + + + + + + + +
+
+
+ +
{videoMediaDevices && videoMediaDevices.length > 0 ? ( ) : ( -
+
Waiting for permission to access camera.
)} diff --git a/app/routes/api+/bookings.get-scanned-asset.ts b/app/routes/api+/bookings.get-scanned-asset.ts index 9e64a7e03..8a5228e71 100644 --- a/app/routes/api+/bookings.get-scanned-asset.ts +++ b/app/routes/api+/bookings.get-scanned-asset.ts @@ -1,139 +1,139 @@ -import { AssetStatus, BookingStatus } from "@prisma/client"; -import { json, type ActionFunctionArgs } from "@remix-run/node"; -import { z } from "zod"; -import { getAsset } from "~/modules/asset/service.server"; -import { getQr } from "~/modules/qr/service.server"; -import { makeShelfError, ShelfError } from "~/utils/error"; -import { assertIsPost, data, error, parseData } from "~/utils/http.server"; - -export async function action({ request, context }: ActionFunctionArgs) { - const authSession = context.getSession(); - const { userId } = authSession; - - try { - assertIsPost(request); - - const formData = await request.formData(); - - const { qrId, bookingId } = parseData( - formData, - z.object({ qrId: z.string(), bookingId: z.string() }), - { - additionalData: { userId }, - } - ); - - const qr = await getQr(qrId); - - if (!qr.assetId || !qr.organizationId) { - throw new ShelfError({ - cause: null, - message: "QR is not associated with any asset yet.", - label: "Booking", - shouldBeCaptured: false, - }); - } - - const asset = await getAsset({ - id: qr.assetId, - organizationId: qr.organizationId, - include: { - custody: true, - bookings: { - where: { - status: { - notIn: [ - BookingStatus.ARCHIVED, - BookingStatus.CANCELLED, - BookingStatus.COMPLETE, - ], - }, - }, - select: { id: true, status: true }, - }, - }, - }); - - const isPartOfCurrentBooking = asset.bookings.some( - (b) => b.id === bookingId - ); - - /** Asset is already added in the booking */ - if (isPartOfCurrentBooking) { - throw new ShelfError({ - cause: null, - title: "Already added", - message: "This asset is already added in the current booking.", - label: "Booking", - shouldBeCaptured: false, - }); - } - - /** Asset is not available for booking */ - if (!asset.availableToBook) { - throw new ShelfError({ - cause: null, - title: "Unavailable", - message: - "This asset is marked as unavailable for bookings by administrator.", - label: "Booking", - shouldBeCaptured: false, - }); - } - - /** Asset is in custody */ - if (asset.custody) { - throw new ShelfError({ - cause: null, - title: "In custody", - message: - "This asset is in custody of team member making it currently unavailable for bookings.", - label: "Booking", - shouldBeCaptured: false, - }); - } - - /** Is booked for period */ - if ( - asset.bookings.length > 0 && - asset.bookings.some((b) => b.id !== bookingId) - ) { - throw new ShelfError({ - cause: null, - title: "Already booked", - message: - "This asset is added to a booking that is overlapping the selected time period.", - label: "Booking", - shouldBeCaptured: false, - }); - } - - /** If currently checked out */ - if (asset.status === AssetStatus.CHECKED_OUT) { - throw new ShelfError({ - cause: null, - title: "Checked out", - message: - "This asset is currently checked out as part of another booking and should be available for your selected date range period", - label: "Booking", - shouldBeCaptured: false, - }); - } - - /** Asset is part of a kit */ - if (asset.kitId) { - throw new ShelfError({ - cause: null, - title: "Part of kit", - message: "Remove the asset from the kit to add it individually.", - label: "Booking", - shouldBeCaptured: false, - }); - } - - return json(data({ asset })); - } catch (cause) { - const reason = makeShelfError(cause, { userId }); - return json(error(reason), { status: reason.status }); - } -} +import { AssetStatus, BookingStatus } from "@prisma/client"; +import { json, type ActionFunctionArgs } from "@remix-run/node"; +import { z } from "zod"; +import { getAsset } from "~/modules/asset/service.server"; +import { getQr } from "~/modules/qr/service.server"; +import { makeShelfError, ShelfError } from "~/utils/error"; +import { assertIsPost, data, error, parseData } from "~/utils/http.server"; + +export async function action({ request, context }: ActionFunctionArgs) { + const authSession = context.getSession(); + const { userId } = authSession; + + try { + assertIsPost(request); + + const formData = await request.formData(); + + const { qrId, bookingId } = parseData( + formData, + z.object({ qrId: z.string(), bookingId: z.string() }), + { + additionalData: { userId }, + } + ); + + const qr = await getQr(qrId); + + if (!qr.assetId || !qr.organizationId) { + throw new ShelfError({ + cause: null, + message: "QR is not associated with any asset yet.", + label: "Booking", + shouldBeCaptured: false, + }); + } + + const asset = await getAsset({ + id: qr.assetId, + organizationId: qr.organizationId, + include: { + custody: true, + bookings: { + where: { + status: { + notIn: [ + BookingStatus.ARCHIVED, + BookingStatus.CANCELLED, + BookingStatus.COMPLETE, + ], + }, + }, + select: { id: true, status: true }, + }, + }, + }); + + const isPartOfCurrentBooking = asset.bookings.some( + (b) => b.id === bookingId + ); + + /** Asset is already added in the booking */ + if (isPartOfCurrentBooking) { + throw new ShelfError({ + cause: null, + title: "Already added", + message: "This asset is already added in the current booking.", + label: "Booking", + shouldBeCaptured: false, + }); + } + + /** Asset is not available for booking */ + if (!asset.availableToBook) { + throw new ShelfError({ + cause: null, + title: "Unavailable", + message: + "This asset is marked as unavailable for bookings by administrator.", + label: "Booking", + shouldBeCaptured: false, + }); + } + + /** Asset is in custody */ + if (asset.custody) { + throw new ShelfError({ + cause: null, + title: "In custody", + message: + "This asset is in custody of team member making it currently unavailable for bookings.", + label: "Booking", + shouldBeCaptured: false, + }); + } + + /** Is booked for period */ + if ( + asset.bookings.length > 0 && + asset.bookings.some((b) => b.id !== bookingId) + ) { + throw new ShelfError({ + cause: null, + title: "Already booked", + message: + "This asset is added to a booking that is overlapping the selected time period.", + label: "Booking", + shouldBeCaptured: false, + }); + } + + /** If currently checked out */ + if (asset.status === AssetStatus.CHECKED_OUT) { + throw new ShelfError({ + cause: null, + title: "Checked out", + message: + "This asset is currently checked out as part of another booking and should be available for your selected date range period", + label: "Booking", + shouldBeCaptured: false, + }); + } + + /** Asset is part of a kit */ + if (asset.kitId) { + throw new ShelfError({ + cause: null, + title: "Part of kit", + message: "Remove the asset from the kit to add it individually.", + label: "Booking", + shouldBeCaptured: false, + }); + } + + return json(data({ asset })); + } catch (cause) { + const reason = makeShelfError(cause, { userId }); + return json(error(reason), { status: reason.status }); + } +} From 399706f63f49d1d553c52f296987222b87947a31 Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Sat, 13 Jul 2024 08:43:04 +0530 Subject: [PATCH 08/53] Merge branch 'main' of github.com:Shelf-nu/shelf.nu into feature/add-asset-to-booking-by-scan --- app/atoms/list.ts | 7 +- .../assets/bulk-actions-dropdown.tsx | 13 +- app/components/assets/bulk-delete-dialog.tsx | 8 +- .../assets/custom-fields-inputs.tsx | 37 +- app/components/assets/import-content.tsx | 14 +- .../booking/bulk-actions-dropdown.tsx | 12 +- .../booking/bulk-archive-dialog.tsx | 4 +- app/components/booking/bulk-cancel-dialog.tsx | 4 +- app/components/booking/bulk-delete-dialog.tsx | 4 +- .../bulk-update-dialog/bulk-update-dialog.tsx | 48 ++- .../category/bulk-delete-dialog.tsx | 4 +- .../custom-fields/bulk-actions-dropdown.tsx | 12 +- .../custom-fields/bulk-activate-dialog.tsx | 4 +- .../custom-fields/bulk-deactivate-dialog.tsx | 4 +- app/components/kits/bulk-actions-dropdown.tsx | 20 +- app/components/kits/bulk-delete-dialog.tsx | 4 +- app/components/kits/form.tsx | 6 + .../layout/horizontal-tabs/index.tsx | 13 +- .../layout/horizontal-tabs/types.ts | 1 + .../layout/sidebar/organization-select.tsx | 5 +- .../list/bulk-actions/bulk-list-header.tsx | 8 +- .../bulk-actions/bulk-list-item-checkbox.tsx | 4 +- app/components/list/index.tsx | 12 +- .../location/bulk-delete-dialog.tsx | 4 +- app/components/nrm/bulk-actions-dropdown.tsx | 9 +- app/components/nrm/bulk-delete-dialog.tsx | 4 +- .../asset-qr.tsx => qr/qr-preview.tsx} | 58 +-- app/components/tag/bulk-delete-dialog.tsx | 4 +- app/components/zxing-scanner.tsx | 10 +- .../migration.sql | 5 + .../migration.sql | 6 + .../migration.sql | 8 + .../migration.sql | 2 + app/database/schema.prisma | 36 +- app/modules/asset/service.server.ts | 7 +- app/modules/auth/mappers.server.ts | 1 - app/modules/auth/service.server.ts | 56 ++- app/modules/booking/pdf-helpers.ts | 3 + app/modules/custom-field/service.server.ts | 4 +- app/modules/invite/service.server.ts | 1 + app/modules/kit/service.server.ts | 147 ++++++- app/modules/qr/service.server.ts | 49 ++- app/modules/qr/types.ts | 0 app/modules/qr/utils.server.ts | 70 ++++ app/modules/report-found/service.server.ts | 54 ++- app/modules/tier/service.server.ts | 10 +- app/modules/user/service.server.ts | 7 + app/routes/_auth+/reset-password.tsx | 13 +- .../_layout+/account-details.subscription.tsx | 18 +- .../_layout+/admin-dashboard+/$userId.tsx | 24 +- .../org.$organizationId.qr-codes.tsx | 136 ++++-- app/routes/_layout+/admin-dashboard+/qrs.tsx | 12 + .../_layout+/assets.$assetId.overview.tsx | 44 +- ...bookingId.generate-pdf.$fileName[.pdf].tsx | 8 +- .../_layout+/kits.$kitId.assign-custody.tsx | 4 +- app/routes/_layout+/kits.$kitId.tsx | 39 +- app/routes/_layout+/kits.new.tsx | 6 +- app/routes/_welcome+/onboarding.tsx | 25 +- .../api+/$organizationId.qr-codes[.zip].ts | 19 +- app/routes/qr+/$qrId.tsx | 67 +-- app/routes/qr+/$qrId_.claim.tsx | 163 ++++++++ app/routes/qr+/$qrId_.contact-owner.tsx | 24 +- ...isting-asset.tsx => $qrId_.link.asset.tsx} | 18 +- app/routes/qr+/$qrId_.link.kit.tsx | 387 ++++++++++++++++++ app/routes/qr+/$qrId_.link.tsx | 198 ++++----- app/routes/qr+/$qrId_.not-logged-in.tsx | 4 +- app/routes/qr+/$qrId_.successful-link.tsx | 32 +- app/utils/list.ts | 5 + app/utils/qr.ts | 34 ++ server/middleware.ts | 23 +- 70 files changed, 1592 insertions(+), 514 deletions(-) rename app/components/{assets/asset-qr.tsx => qr/qr-preview.tsx} (68%) create mode 100644 app/database/migrations/20240705125333_add_qr_to_kit_relation/migration.sql create mode 100644 app/database/migrations/20240710071325_update_report_found_relations/migration.sql create mode 100644 app/database/migrations/20240711140019_add_created_with_invite_to_user/migration.sql create mode 100644 app/database/migrations/20240712085058_add_is_enterprise_flag_to_custom_tier_limit/migration.sql create mode 100644 app/modules/qr/types.ts create mode 100644 app/routes/qr+/$qrId_.claim.tsx rename app/routes/qr+/{$qrId_.link-existing-asset.tsx => $qrId_.link.asset.tsx} (95%) create mode 100644 app/routes/qr+/$qrId_.link.kit.tsx create mode 100644 app/utils/qr.ts diff --git a/app/atoms/list.ts b/app/atoms/list.ts index fc802680b..cab972f64 100644 --- a/app/atoms/list.ts +++ b/app/atoms/list.ts @@ -1,6 +1,7 @@ import { atom } from "jotai"; +import type { ListItemData } from "~/components/list/list-item"; -export const selectedBulkItemsAtom = atom([]); +export const selectedBulkItemsAtom = atom([]); /** Reset the atom when it mounts */ selectedBulkItemsAtom.onMount = (setAtom) => { @@ -15,7 +16,7 @@ export const selectedBulkItemsCountAtom = atom( /** * Set an item in selectedBulkItems */ -export const setSelectedBulkItemAtom = atom( +export const setSelectedBulkItemAtom = atom( null, // it's a convention to pass `null` for the first argument (_, set, update) => { set(selectedBulkItemsAtom, (prev) => { @@ -31,7 +32,7 @@ export const setSelectedBulkItemAtom = atom( /** * Set multiple items at once in selectedBulkItems */ -export const setSelectedBulkItemsAtom = atom( +export const setSelectedBulkItemsAtom = atom( null, (_, set, update) => { set(selectedBulkItemsAtom, update); diff --git a/app/components/assets/bulk-actions-dropdown.tsx b/app/components/assets/bulk-actions-dropdown.tsx index 46cd484b8..2a15bf17c 100644 --- a/app/components/assets/bulk-actions-dropdown.tsx +++ b/app/components/assets/bulk-actions-dropdown.tsx @@ -1,8 +1,7 @@ -import { useLoaderData, useNavigation } from "@remix-run/react"; +import { useNavigation } from "@remix-run/react"; import { useAtomValue } from "jotai"; import { useHydrated } from "remix-utils/use-hydrated"; import { selectedBulkItemsAtom } from "~/atoms/list"; -import type { loader } from "~/routes/_layout+/assets._index"; import { isFormProcessing } from "~/utils/form"; import { tw } from "~/utils/tw"; import { useControlledDropdownMenu } from "~/utils/use-controlled-dropdown-menu"; @@ -42,8 +41,6 @@ export default function BulkActionsDropdown() { } function ConditionalDropdown() { - const { items } = useLoaderData(); - const navigation = useNavigation(); const isLoading = isFormProcessing(navigation.state); @@ -55,13 +52,9 @@ function ConditionalDropdown() { setOpen, } = useControlledDropdownMenu(); - const selectedAssetIds = useAtomValue(selectedBulkItemsAtom); - - const selectedAssets = items.filter((item) => - selectedAssetIds.includes(item.id) - ); + const selectedAssets = useAtomValue(selectedBulkItemsAtom); - const disabled = selectedAssetIds.length === 0; + const disabled = selectedAssets.length === 0; const allAssetsAreInCustody = selectedAssets.every( (asset) => asset.status === "IN_CUSTODY" diff --git a/app/components/assets/bulk-delete-dialog.tsx b/app/components/assets/bulk-delete-dialog.tsx index b2d5da036..1fe8517f8 100644 --- a/app/components/assets/bulk-delete-dialog.tsx +++ b/app/components/assets/bulk-delete-dialog.tsx @@ -4,7 +4,7 @@ import { useZorm } from "react-zorm"; import { z } from "zod"; import { selectedBulkItemsAtom } from "~/atoms/list"; import type { loader } from "~/routes/_layout+/assets._index"; -import { ALL_SELECTED_KEY } from "~/utils/list"; +import { isSelectingAllItems } from "~/utils/list"; import { BulkUpdateDialogContent } from "../bulk-update-dialog/bulk-update-dialog"; import { Button } from "../shared/button"; @@ -18,7 +18,7 @@ export default function BulkDeleteDialog() { const selectedAssets = useAtomValue(selectedBulkItemsAtom); - const totalSelected = selectedAssets.includes(ALL_SELECTED_KEY) + const totalSelected = isSelectingAllItems(selectedAssets) ? totalItems : selectedAssets.length; @@ -26,8 +26,8 @@ export default function BulkDeleteDialog() { diff --git a/app/components/assets/custom-fields-inputs.tsx b/app/components/assets/custom-fields-inputs.tsx index f1690dba9..7c3f3caca 100644 --- a/app/components/assets/custom-fields-inputs.tsx +++ b/app/components/assets/custom-fields-inputs.tsx @@ -109,7 +109,7 @@ export default function AssetCustomFields({ ), OPTION: (field) => { const val = getCustomFieldVal(field.id); - + const options = field.options.filter((o) => o !== null && o !== ""); return ( <>
@@ -245,12 +263,12 @@ const BulkUpdateDialogContent = forwardRef< value={searchParams.toString()} /> - {selectedAssets.map((assetId, i) => ( + {selectedItems.map((item, i) => ( ))}
diff --git a/app/components/category/bulk-delete-dialog.tsx b/app/components/category/bulk-delete-dialog.tsx index 5f209c57d..dc3e42465 100644 --- a/app/components/category/bulk-delete-dialog.tsx +++ b/app/components/category/bulk-delete-dialog.tsx @@ -4,7 +4,7 @@ import { useZorm } from "react-zorm"; import { z } from "zod"; import { selectedBulkItemsAtom } from "~/atoms/list"; import { type loader } from "~/routes/_layout+/categories"; -import { ALL_SELECTED_KEY } from "~/utils/list"; +import { isSelectingAllItems } from "~/utils/list"; import { BulkUpdateDialogContent } from "../bulk-update-dialog/bulk-update-dialog"; import { Button } from "../shared/button"; @@ -19,7 +19,7 @@ export default function BulkDeleteDialog() { const selectedCategories = useAtomValue(selectedBulkItemsAtom); - const totalSelected = selectedCategories.includes(ALL_SELECTED_KEY) + const totalSelected = isSelectingAllItems(selectedCategories) ? totalItems : selectedCategories.length; diff --git a/app/components/custom-fields/bulk-actions-dropdown.tsx b/app/components/custom-fields/bulk-actions-dropdown.tsx index 0339acf90..5122dda79 100644 --- a/app/components/custom-fields/bulk-actions-dropdown.tsx +++ b/app/components/custom-fields/bulk-actions-dropdown.tsx @@ -1,8 +1,7 @@ -import { useLoaderData, useNavigation } from "@remix-run/react"; +import { useNavigation } from "@remix-run/react"; import { useAtomValue } from "jotai"; import { useHydrated } from "remix-utils/use-hydrated"; import { selectedBulkItemsAtom } from "~/atoms/list"; -import { type loader } from "~/routes/_layout+/settings.custom-fields.index"; import { isFormProcessing } from "~/utils/form"; import { tw } from "~/utils/tw"; import { useControlledDropdownMenu } from "~/utils/use-controlled-dropdown-menu"; @@ -39,17 +38,12 @@ export default function BulkActionsDropdown() { } function ConditionalDropdown() { - const { items } = useLoaderData(); - const navigation = useNavigation(); const isLoading = isFormProcessing(navigation.state); - const selectedCustomFieldIds = useAtomValue(selectedBulkItemsAtom); - const selectedCustomFields = items.filter((item) => - selectedCustomFieldIds.includes(item.id) - ); + const selectedCustomFields = useAtomValue(selectedBulkItemsAtom); - const disabled = selectedCustomFieldIds.length === 0; + const disabled = selectedCustomFields.length === 0; const someFieldsActivated = selectedCustomFields.some((cf) => cf.active); const someFieldsDeactivated = selectedCustomFields.some((cf) => !cf.active); diff --git a/app/components/custom-fields/bulk-activate-dialog.tsx b/app/components/custom-fields/bulk-activate-dialog.tsx index 6bd3ede68..a5778ed65 100644 --- a/app/components/custom-fields/bulk-activate-dialog.tsx +++ b/app/components/custom-fields/bulk-activate-dialog.tsx @@ -4,7 +4,7 @@ import { useZorm } from "react-zorm"; import { z } from "zod"; import { selectedBulkItemsAtom } from "~/atoms/list"; import { type loader } from "~/routes/_layout+/settings.custom-fields.index"; -import { ALL_SELECTED_KEY } from "~/utils/list"; +import { isSelectingAllItems } from "~/utils/list"; import { BulkUpdateDialogContent } from "../bulk-update-dialog/bulk-update-dialog"; import { Button } from "../shared/button"; @@ -19,7 +19,7 @@ export default function BulkActivateDialog() { const selectedCustomFields = useAtomValue(selectedBulkItemsAtom); - const totalSelected = selectedCustomFields.includes(ALL_SELECTED_KEY) + const totalSelected = isSelectingAllItems(selectedCustomFields) ? totalItems : selectedCustomFields.length; diff --git a/app/components/custom-fields/bulk-deactivate-dialog.tsx b/app/components/custom-fields/bulk-deactivate-dialog.tsx index a8cfd188f..f608cc40a 100644 --- a/app/components/custom-fields/bulk-deactivate-dialog.tsx +++ b/app/components/custom-fields/bulk-deactivate-dialog.tsx @@ -4,7 +4,7 @@ import { useZorm } from "react-zorm"; import { z } from "zod"; import { selectedBulkItemsAtom } from "~/atoms/list"; import { type loader } from "~/routes/_layout+/settings.custom-fields.index"; -import { ALL_SELECTED_KEY } from "~/utils/list"; +import { isSelectingAllItems } from "~/utils/list"; import { BulkUpdateDialogContent } from "../bulk-update-dialog/bulk-update-dialog"; import { Button } from "../shared/button"; @@ -22,7 +22,7 @@ export default function BulkDeactivateDialog() { const selectedCustomFields = useAtomValue(selectedBulkItemsAtom); - const totalSelected = selectedCustomFields.includes(ALL_SELECTED_KEY) + const totalSelected = isSelectingAllItems(selectedCustomFields) ? totalItems : selectedCustomFields.length; diff --git a/app/components/kits/bulk-actions-dropdown.tsx b/app/components/kits/bulk-actions-dropdown.tsx index e391318a9..6d4386683 100644 --- a/app/components/kits/bulk-actions-dropdown.tsx +++ b/app/components/kits/bulk-actions-dropdown.tsx @@ -1,8 +1,8 @@ -import { useLoaderData, useNavigation } from "@remix-run/react"; +import type { AssetStatus } from "@prisma/client"; +import { useNavigation } from "@remix-run/react"; import { useAtomValue } from "jotai"; import { useHydrated } from "remix-utils/use-hydrated"; import { selectedBulkItemsAtom } from "~/atoms/list"; -import type { loader } from "~/routes/_layout+/kits._index"; import { isFormProcessing } from "~/utils/form"; import { tw } from "~/utils/tw"; import { useControlledDropdownMenu } from "~/utils/use-controlled-dropdown-menu"; @@ -40,8 +40,6 @@ export default function BulkActionsDropdown() { } function ConditionalDropdown() { - const { items } = useLoaderData(); - const { ref: dropdownRef, defaultApplied, @@ -53,9 +51,7 @@ function ConditionalDropdown() { const navigation = useNavigation(); const isLoading = isFormProcessing(navigation.state); - const selectedKitIds = useAtomValue(selectedBulkItemsAtom); - - const selectedKits = items.filter((item) => selectedKitIds.includes(item.id)); + const selectedKits = useAtomValue(selectedBulkItemsAtom); const allKitsInCustody = selectedKits.every( (kit) => kit.status === "IN_CUSTODY" @@ -71,11 +67,15 @@ function ConditionalDropdown() { const someAssetsInsideKitsCheckedOutOrInCustody = selectedKits.some( (kit) => - kit.assets.some((asset) => asset.status === "CHECKED_OUT") || - kit.assets.some((asset) => asset.status === "IN_CUSTODY") + kit.assets?.some( + (asset: { status: AssetStatus }) => asset.status === "CHECKED_OUT" + ) || + kit.assets?.some( + (asset: { status: AssetStatus }) => asset.status === "IN_CUSTODY" + ) ); - const disabled = selectedKitIds.length === 0; + const disabled = selectedKits.length === 0; function closeMenu() { setOpen(false); diff --git a/app/components/kits/bulk-delete-dialog.tsx b/app/components/kits/bulk-delete-dialog.tsx index c74d6854e..b2d097758 100644 --- a/app/components/kits/bulk-delete-dialog.tsx +++ b/app/components/kits/bulk-delete-dialog.tsx @@ -4,7 +4,7 @@ import { useZorm } from "react-zorm"; import { z } from "zod"; import { selectedBulkItemsAtom } from "~/atoms/list"; import type { loader } from "~/routes/_layout+/assets._index"; -import { ALL_SELECTED_KEY } from "~/utils/list"; +import { isSelectingAllItems } from "~/utils/list"; import { BulkUpdateDialogContent } from "../bulk-update-dialog/bulk-update-dialog"; import { Button } from "../shared/button"; @@ -19,7 +19,7 @@ export default function BulkDeleteDialog() { const selectedKits = useAtomValue(selectedBulkItemsAtom); - const totalSelected = selectedKits.includes(ALL_SELECTED_KEY) + const totalSelected = isSelectingAllItems(selectedKits) ? totalItems : selectedKits.length; diff --git a/app/components/kits/form.tsx b/app/components/kits/form.tsx index 1fe7a672f..2dca28716 100644 --- a/app/components/kits/form.tsx +++ b/app/components/kits/form.tsx @@ -24,6 +24,7 @@ export const NewKitFormSchema = z.object({ .string() .optional() .transform((value) => value?.trim()), + qrId: z.string().optional(), }); type KitFormProps = { @@ -31,6 +32,7 @@ type KitFormProps = { name?: Kit["name"]; description?: Kit["description"]; saveButtonLabel?: string; + qrId?: string | null; }; export default function KitsForm({ @@ -38,6 +40,7 @@ export default function KitsForm({ name, description, saveButtonLabel = "Add", + qrId, }: KitFormProps) { const navigation = useNavigation(); const disabled = isFormProcessing(navigation.state); @@ -68,6 +71,9 @@ export default function KitsForm({ {saveButtonLabel} + {qrId ? ( + + ) : null} +
{items.map((item, index) => ( ; + /** Class applied inside the SelectContent */ + className?: string; }) => { const { organizations, currentOrganizationId } = useLoaderData(); @@ -27,7 +30,7 @@ export const OrganizationSelect = ({ -
+
{organizations.map((org) => ( (); const setSelectedBulkItems = useSetAtom(setSelectedBulkItemsAtom); - const itemsSelected = useAtomValue(selectedBulkItemsCountAtom); + const totalItemsSelected = useAtomValue(selectedBulkItemsCountAtom); const partialItemsSelected = - itemsSelected > 0 && itemsSelected < items.length; + totalItemsSelected > 0 && totalItemsSelected < items.length; - const allItemsSelected = itemsSelected >= items.length; + const allItemsSelected = totalItemsSelected >= items.length; function handleSelectAllIncomingItems() { - setSelectedBulkItems(allItemsSelected ? [] : items.map((item) => item.id)); + setSelectedBulkItems(allItemsSelected ? [] : items); } return ( diff --git a/app/components/list/bulk-actions/bulk-list-item-checkbox.tsx b/app/components/list/bulk-actions/bulk-list-item-checkbox.tsx index 1680cb56a..56e247087 100644 --- a/app/components/list/bulk-actions/bulk-list-item-checkbox.tsx +++ b/app/components/list/bulk-actions/bulk-list-item-checkbox.tsx @@ -15,7 +15,7 @@ export default function BulkListItemCheckbox({ const selectedBulkItems = useAtomValue(selectedBulkItemsAtom); const setSelectedBulkItem = useSetAtom(setSelectedBulkItemAtom); - const checked = selectedBulkItems.includes(item.id); + const checked = !!selectedBulkItems.find((i) => i.id === item.id); function handleBulkItemSelection( e: React.MouseEvent @@ -23,7 +23,7 @@ export default function BulkListItemCheckbox({ e.preventDefault(); e.stopPropagation(); - setSelectedBulkItem(item.id); + setSelectedBulkItem(item); } return ( diff --git a/app/components/list/index.tsx b/app/components/list/index.tsx index c47123068..ab69974cd 100644 --- a/app/components/list/index.tsx +++ b/app/components/list/index.tsx @@ -7,7 +7,7 @@ import { setSelectedBulkItemsAtom, } from "~/atoms/list"; -import { ALL_SELECTED_KEY } from "~/utils/list"; +import { ALL_SELECTED_KEY, isSelectingAllItems } from "~/utils/list"; import { tw } from "~/utils/tw"; import BulkListItemCheckbox from "./bulk-actions/bulk-list-item-checkbox"; import { EmptyState } from "./empty-state"; @@ -95,8 +95,10 @@ export const List = ({ const selectedBulkItemsCount = useAtomValue(selectedBulkItemsCountAtom); const setSelectedBulkItems = useSetAtom(setSelectedBulkItemsAtom); const selectedBulkItems = useAtomValue(selectedBulkItemsAtom); - const hasSelectedAllItems = selectedBulkItems.includes(ALL_SELECTED_KEY); - const hasSelectedItems = selectedBulkItems.length > 0; + + const hasSelectedAllItems = isSelectingAllItems(selectedBulkItems); + + const hasSelectedItems = selectedBulkItemsCount > 0; /** * We can select all the incoming items and we can add ALL_SELECTED_KEY @@ -104,7 +106,7 @@ export const List = ({ * then we do operation on all items of organization */ function handleSelectAllItems() { - setSelectedBulkItems([...items.map((item) => item.id), ALL_SELECTED_KEY]); + setSelectedBulkItems([...items, { id: ALL_SELECTED_KEY }]); } return ( @@ -125,7 +127,7 @@ export const List = ({
-
{title || plural}
+
{title || plural}
{hasSelectedItems ? (
diff --git a/app/components/location/bulk-delete-dialog.tsx b/app/components/location/bulk-delete-dialog.tsx index 13ed94025..f09a12b10 100644 --- a/app/components/location/bulk-delete-dialog.tsx +++ b/app/components/location/bulk-delete-dialog.tsx @@ -4,7 +4,7 @@ import { useZorm } from "react-zorm"; import { z } from "zod"; import { selectedBulkItemsAtom } from "~/atoms/list"; import { type loader } from "~/routes/_layout+/locations._index"; -import { ALL_SELECTED_KEY } from "~/utils/list"; +import { isSelectingAllItems } from "~/utils/list"; import { BulkUpdateDialogContent } from "../bulk-update-dialog/bulk-update-dialog"; import { Button } from "../shared/button"; @@ -19,7 +19,7 @@ export default function BulkDeleteDialog() { const selectedLocations = useAtomValue(selectedBulkItemsAtom); - const totalSelected = selectedLocations.includes(ALL_SELECTED_KEY) + const totalSelected = isSelectingAllItems(selectedLocations) ? totalItems : selectedLocations.length; diff --git a/app/components/nrm/bulk-actions-dropdown.tsx b/app/components/nrm/bulk-actions-dropdown.tsx index e9d8dc410..f32d28430 100644 --- a/app/components/nrm/bulk-actions-dropdown.tsx +++ b/app/components/nrm/bulk-actions-dropdown.tsx @@ -1,8 +1,6 @@ -import { useLoaderData } from "@remix-run/react"; import { useAtomValue } from "jotai"; import { useHydrated } from "remix-utils/use-hydrated"; import { selectedBulkItemsAtom } from "~/atoms/list"; -import { type loader } from "~/routes/_layout+/settings.team.nrm"; import { tw } from "~/utils/tw"; import { useControlledDropdownMenu } from "~/utils/use-controlled-dropdown-menu"; import BulkDeleteDialog from "./bulk-delete-dialog"; @@ -37,12 +35,9 @@ export default function BulkActionsDropdown() { } function ConditionalDropdown() { - const { items } = useLoaderData(); + const selectedNRMs = useAtomValue(selectedBulkItemsAtom); - const selectedNRMIds = useAtomValue(selectedBulkItemsAtom); - const selectedNRMs = items.filter((item) => selectedNRMIds.includes(item.id)); - - const actionsButtonDisabled = selectedNRMIds.length === 0; + const actionsButtonDisabled = selectedNRMs.length === 0; const someNRMHasCustody = selectedNRMs.some( (nrm) => nrm._count.custodies > 0 diff --git a/app/components/nrm/bulk-delete-dialog.tsx b/app/components/nrm/bulk-delete-dialog.tsx index a9e3c8d00..1f32f184c 100644 --- a/app/components/nrm/bulk-delete-dialog.tsx +++ b/app/components/nrm/bulk-delete-dialog.tsx @@ -4,7 +4,7 @@ import { useZorm } from "react-zorm"; import { z } from "zod"; import { selectedBulkItemsAtom } from "~/atoms/list"; import { type loader } from "~/routes/_layout+/settings.team.nrm"; -import { ALL_SELECTED_KEY } from "~/utils/list"; +import { isSelectingAllItems } from "~/utils/list"; import { BulkUpdateDialogContent } from "../bulk-update-dialog/bulk-update-dialog"; import { Button } from "../shared/button"; @@ -19,7 +19,7 @@ export default function BulkDeleteDialog() { const selectedNRMs = useAtomValue(selectedBulkItemsAtom); - const totalSelected = selectedNRMs.includes(ALL_SELECTED_KEY) + const totalSelected = isSelectingAllItems(selectedNRMs) ? totalItems : selectedNRMs.length; diff --git a/app/components/assets/asset-qr.tsx b/app/components/qr/qr-preview.tsx similarity index 68% rename from app/components/assets/asset-qr.tsx rename to app/components/qr/qr-preview.tsx index 1afa10d28..7c4b552fe 100644 --- a/app/components/assets/asset-qr.tsx +++ b/app/components/qr/qr-preview.tsx @@ -6,9 +6,10 @@ import { Button } from "~/components/shared/button"; import { slugify } from "~/utils/slugify"; type SizeKeys = "cable" | "small" | "medium" | "large"; -interface AssetType { - asset: { - title: string; +interface ObjectType { + item: { + name: string; + type: "asset" | "kit"; }; qrObj?: { qr?: { @@ -19,21 +20,17 @@ interface AssetType { }; } -const AssetQR = ({ qrObj, asset }: AssetType) => { +export const QrPreview = ({ qrObj, item }: ObjectType) => { const captureDivRef = useRef(null); const downloadQrBtnRef = useRef(null); const fileName = useMemo( () => - `${slugify(asset?.title || "asset")}-${qrObj?.qr + `${slugify(item.name || item.type)}-${qrObj?.qr ?.size}-shelf-qr-code-${qrObj?.qr?.id}.png`, - [asset, qrObj?.qr?.id, qrObj?.qr?.size] + [item, qrObj?.qr?.id, qrObj?.qr?.size] ); - // const handleSizeChange = () => { - // submit(formRef.current); - // }; - function downloadQr(e: React.MouseEvent) { const captureDiv = captureDivRef.current; const downloadBtn = downloadQrBtnRef.current; @@ -72,49 +69,15 @@ const AssetQR = ({ qrObj, asset }: AssetType) => { return (
- +
-
    - {/*
  • - - -
    - -
    -
    -
  • */} - {/*
  • - - File - - - PNG - -
  • */} -
+ {/* using this button to convert html to png and download image using the a tag below */}

You’re currently using the{" "} - ENTERPRISE version of Shelf. + {isEnterprise ? ( + <> + ENTERPRISE version + + ) : ( + <> + CUSTOM plan + + )}{" "} + of Shelf.
- That means you have a custom plan. To get more information about your - plan, please{" "} + {isEnterprise && <>That means you have a custom plan. } + To get more information about your plan, please{" "} contact support diff --git a/app/routes/_layout+/admin-dashboard+/$userId.tsx b/app/routes/_layout+/admin-dashboard+/$userId.tsx index 8bbf2050d..6dde5c03b 100644 --- a/app/routes/_layout+/admin-dashboard+/$userId.tsx +++ b/app/routes/_layout+/admin-dashboard+/$userId.tsx @@ -13,6 +13,7 @@ import { z } from "zod"; import { Form } from "~/components/custom-form"; import FormRow from "~/components/forms/form-row"; import Input from "~/components/forms/input"; +import { Switch } from "~/components/forms/switch"; import { Button } from "~/components/shared/button"; import { DateS } from "~/components/shared/date"; import { Spinner } from "~/components/shared/spinner"; @@ -160,10 +161,14 @@ export const action = async ({ break; } case "updateCustomTierDetails": { - const { maxOrganizations } = parseData( + const { maxOrganizations, isEnterprise } = parseData( await request.formData(), z.object({ maxOrganizations: z.string().transform((val) => +val), + isEnterprise: z + .string() + .optional() + .transform((val) => (val === "on" ? true : false)), }) ); @@ -172,9 +177,11 @@ export const action = async ({ create: { userId: shelfUserId, maxOrganizations, + isEnterprise, }, update: { maxOrganizations, + isEnterprise, }, }); @@ -292,6 +299,7 @@ function TierUpdateForm({ tierId }: { tierId: TierId }) { className="inline-flex items-center gap-2" > + - - - - {qrCode.id} - - - {qrCode?.assetId || "Orphaned"} - {qrCode?.asset?.title || "Orphaned"} - {qrCode.createdAt} - - ))} - - +

+ + + + + + + + + + + + {codes.map((qrCode) => ( + + + + + + + ))} + +
+ QR code id + + Asset + + Kit + + Created At +
+ + {qrCode.id} + + + {!qrCode?.assetId + ? "N/A" + : `${qrCode?.asset?.title} (${qrCode?.asset?.id})`} + + {!qrCode?.kitId + ? "N/A" + : `${qrCode?.kit?.name} (${qrCode?.kit?.id})`} + + +
+
); } diff --git a/app/routes/_layout+/admin-dashboard+/qrs.tsx b/app/routes/_layout+/admin-dashboard+/qrs.tsx index cd6afaf27..3b3516934 100644 --- a/app/routes/_layout+/admin-dashboard+/qrs.tsx +++ b/app/routes/_layout+/admin-dashboard+/qrs.tsx @@ -130,6 +130,7 @@ export default function Area51() { <> QR id Asset + Kit Organization ID User ID @@ -156,6 +157,12 @@ const ListUserContent = ({ title: true; }; }; + kit: { + select: { + id: true; + name: true; + }; + }; organization: { select: { id: true; @@ -191,6 +198,11 @@ const ListUserContent = ({ {item.asset ? {item.asset.title} : "N/A"}
+ +
+ {item.kit ? {item.kit.name} : "N/A"} +
+
{item.organization ? ( diff --git a/app/routes/_layout+/assets.$assetId.overview.tsx b/app/routes/_layout+/assets.$assetId.overview.tsx index bcf6eda47..c6e8a85e2 100644 --- a/app/routes/_layout+/assets.$assetId.overview.tsx +++ b/app/routes/_layout+/assets.$assetId.overview.tsx @@ -8,13 +8,13 @@ import { useFetcher, useLoaderData } from "@remix-run/react"; import { useZorm } from "react-zorm"; import { z } from "zod"; import { CustodyCard } from "~/components/assets/asset-custody-card"; -import AssetQR from "~/components/assets/asset-qr"; import { Switch } from "~/components/forms/switch"; import Icon from "~/components/icons/icon"; import ContextualModal from "~/components/layout/contextual-modal"; import ContextualSidebar from "~/components/layout/contextual-sidebar"; import type { HeaderData } from "~/components/layout/header/types"; import { ScanDetails } from "~/components/location/scan-details"; +import { QrPreview } from "~/components/qr/qr-preview"; import { Badge } from "~/components/shared/badge"; import { Button } from "~/components/shared/button"; @@ -29,11 +29,8 @@ import { updateAssetBookingAvailability, } from "~/modules/asset/service.server"; import type { ShelfAssetCustomFieldValueType } from "~/modules/asset/types"; -import { - createQr, - generateCode, - getQrByAssetId, -} from "~/modules/qr/service.server"; + +import { generateQrObj } from "~/modules/qr/utils.server"; import { getScanByQrId } from "~/modules/scan/service.server"; import { parseScanData } from "~/modules/scan/utils.server"; import { appendToMetaTitle } from "~/utils/append-to-meta-title"; @@ -83,21 +80,6 @@ export async function loader({ context, request, params }: LoaderFunctionArgs) { include: ASSET_OVERVIEW_FIELDS, }); - let qr = await getQrByAssetId({ assetId: id }); - - /** If for some reason there is no QR, we create one and return it */ - if (!qr) { - qr = await createQr({ assetId: id, userId, organizationId }); - } - - /** Create a QR code with a URL */ - const { sizes, code } = await generateCode({ - version: qr.version as TypeNumber, - errorCorrection: qr.errorCorrection as ErrorCorrectionLevel, - size: "medium", - qr, - }); - /** * We get the first QR code(for now we can only have 1) * And using the ID of tha qr code, we find the latest scan @@ -124,11 +106,11 @@ export async function loader({ context, request, params }: LoaderFunctionArgs) { }; } - const qrObj = { - qr: code, - sizes, - showSidebar: true, - }; + const qrObj = await generateQrObj({ + assetId: asset.id, + userId, + organizationId, + }); const booking = asset.bookings.length > 0 ? asset.bookings[0] : undefined; let currentBooking: any = null; @@ -464,7 +446,15 @@ export default function AssetOverview() { isSelfService={isSelfService} /> - {asset && } + {asset && ( + + )} {!isSelfService ? : null}
diff --git a/app/routes/_layout+/bookings.$bookingId.generate-pdf.$fileName[.pdf].tsx b/app/routes/_layout+/bookings.$bookingId.generate-pdf.$fileName[.pdf].tsx index 6f589ea04..9210b5be7 100644 --- a/app/routes/_layout+/bookings.$bookingId.generate-pdf.$fileName[.pdf].tsx +++ b/app/routes/_layout+/bookings.$bookingId.generate-pdf.$fileName[.pdf].tsx @@ -203,6 +203,7 @@ const BookingPDFPreview = ({ pdfMeta }: { pdfMeta: PdfDbResult }) => { Name + Kit Category Location Code @@ -230,9 +231,10 @@ const BookingPDFPreview = ({ pdfMeta }: { pdfMeta: PdfDbResult }) => { />
- {asset?.title} - {asset?.category?.name} - {asset?.location?.name} + {asset?.title || ""} + {asset?.kit?.name || ""} + {asset?.category?.name || ""} + {asset?.location?.name || ""}
({ - content: `**${user.firstName?.trim()} ${user.lastName?.trim()}** has given **${custodianName.trim()}** custody over **${asset.title.trim()}**`, + content: `**${user.firstName?.trim()} ${user.lastName?.trim()}** has given **${custodianName.trim()}** custody over **${asset.title.trim()}** via Kit assignment **[${ + kit.name + }](/kits/${kit.id})**`, type: "UPDATE", userId, assetId: asset.id, diff --git a/app/routes/_layout+/kits.$kitId.tsx b/app/routes/_layout+/kits.$kitId.tsx index 347e3e7dd..bfe3aec0e 100644 --- a/app/routes/_layout+/kits.$kitId.tsx +++ b/app/routes/_layout+/kits.$kitId.tsx @@ -22,6 +22,8 @@ import Header from "~/components/layout/header"; import type { HeaderData } from "~/components/layout/header/types"; import { List } from "~/components/list"; import { Filters } from "~/components/list/filters"; +import { ScanDetails } from "~/components/location/scan-details"; +import { QrPreview } from "~/components/qr/qr-preview"; import { Badge } from "~/components/shared/badge"; import { Button } from "~/components/shared/button"; import { Card } from "~/components/shared/card"; @@ -40,6 +42,10 @@ import { getKit, getKitCurrentBooking, } from "~/modules/kit/service.server"; + +import { generateQrObj } from "~/modules/qr/utils.server"; +import { getScanByQrId } from "~/modules/scan/service.server"; +import { parseScanData } from "~/modules/scan/utils.server"; import { getUserByID } from "~/modules/user/service.server"; import dropdownCss from "~/styles/actions-dropdown.css?url"; import { appendToMetaTitle } from "~/utils/append-to-meta-title"; @@ -119,6 +125,7 @@ export async function loader({ context, request, params }: LoaderFunctionArgs) { }, }, }, + qrCodes: true, }, }), getAssetsForKits({ @@ -141,6 +148,23 @@ export async function loader({ context, request, params }: LoaderFunctionArgs) { }; } + const qrObj = await generateQrObj({ + kitId, + userId, + organizationId, + }); + + /** + * We get the first QR code(for now we can only have 1) + * And using the ID of tha qr code, we find the latest scan + */ + const lastScan = kit.qrCodes[0]?.id + ? parseScanData({ + scan: (await getScanByQrId({ qrId: kit.qrCodes[0].id })) || null, + userId, + request, + }) + : null; const currentBooking = getKitCurrentBooking(request, { id: kit.id, assets: kit.assets, @@ -165,6 +189,8 @@ export async function loader({ context, request, params }: LoaderFunctionArgs) { header, ...assets, modelName, + qrObj, + lastScan, }) ); } catch (cause) { @@ -288,10 +314,9 @@ export async function action({ context, request, params }: ActionFunctionArgs) { } export default function KitDetails() { - const { kit, currentBooking } = useLoaderData(); - + const { kit, currentBooking, qrObj, lastScan } = + useLoaderData(); const isSelfService = useUserIsSelfService(); - /** * User can manage assets if * 1. Kit has AVAILABLE status @@ -378,6 +403,14 @@ export default function KitDetails() { ID
{kit.id}
+ + {!isSelfService ? : null}
diff --git a/app/routes/_layout+/kits.new.tsx b/app/routes/_layout+/kits.new.tsx index 6ac800815..4787f9618 100644 --- a/app/routes/_layout+/kits.new.tsx +++ b/app/routes/_layout+/kits.new.tsx @@ -1,5 +1,6 @@ import { json, redirect } from "@remix-run/node"; import type { MetaFunction, LoaderFunctionArgs } from "@remix-run/node"; +import { useSearchParams } from "@remix-run/react"; import { useAtomValue } from "jotai"; import { dynamicTitleAtom } from "~/atoms/dynamic-title-atom"; @@ -104,11 +105,12 @@ export async function action({ context, request }: LoaderFunctionArgs) { export default function CreateNewKit() { const title = useAtomValue(dynamicTitleAtom); - + const [searchParams] = useSearchParams(); + const qrId = searchParams.get("qrId"); return ( <>
- + ); } diff --git a/app/routes/_welcome+/onboarding.tsx b/app/routes/_welcome+/onboarding.tsx index a5d40ca09..c2ff8bf2e 100644 --- a/app/routes/_welcome+/onboarding.tsx +++ b/app/routes/_welcome+/onboarding.tsx @@ -18,7 +18,10 @@ import PasswordInput from "~/components/forms/password-input"; import { Button } from "~/components/shared/button"; import { config } from "~/config/shelf.config"; import { onboardingEmailText } from "~/emails/onboarding-email"; -import { getAuthUserById } from "~/modules/auth/service.server"; +import { + getAuthUserById, + signInWithEmail, +} from "~/modules/auth/service.server"; import { setSelectedOrganizationIdCookie } from "~/modules/organization/context.server"; import { getUserByID, updateUser } from "~/modules/user/service.server"; import { appendToMetaTitle } from "~/utils/append-to-meta-title"; @@ -129,6 +132,21 @@ export async function action({ context, request }: ActionFunctionArgs) { onboarded: true, }); + /** + * When setting password as part of onboarding, the session gets destroyed as part of the normal password reset flow. + * In this case, we need to create a new session for the user. + */ + if (user && userSignedUpWithPassword) { + //making sure new session is created. + const authSession = await signInWithEmail( + user.email, + payload.password as string + ); + if (authSession) { + context.setSession(authSession); + } + } + /** We create the stripe customer when the user gets onboarded. * This is to make sure that we have a stripe customer for the user. * We have to do it at this point, as its the first time we have the user's first and last name @@ -151,11 +169,14 @@ export async function action({ context, request }: ActionFunctionArgs) { }); } + /** If organizationId is passed, that means the user comes from an invite */ const { organizationId } = parseData( formData, z.object({ organizationId: z.string().optional() }) ); + const createdWithInvite = !!organizationId || user.createdWithInvite; + const headers = []; if (organizationId) { @@ -164,7 +185,7 @@ export async function action({ context, request }: ActionFunctionArgs) { ); } - return redirect(organizationId ? `/assets` : `/welcome`, { + return redirect(createdWithInvite ? `/assets` : `/welcome`, { headers, }); } catch (cause) { diff --git a/app/routes/api+/$organizationId.qr-codes[.zip].ts b/app/routes/api+/$organizationId.qr-codes[.zip].ts index 18f36ac68..f253697a0 100644 --- a/app/routes/api+/$organizationId.qr-codes[.zip].ts +++ b/app/routes/api+/$organizationId.qr-codes[.zip].ts @@ -27,11 +27,22 @@ export async function loader({ context, request, params }: LoaderFunctionArgs) { .findMany({ where: { organizationId, - assetId: onlyOrphaned - ? null + ...(onlyOrphaned + ? { assetId: null, kitId: null } : { - not: null, - }, + OR: [ + { + assetId: { + not: null, + }, + }, + { + kitId: { + not: null, + }, + }, + ], + }), }, }) .catch((cause) => { diff --git a/app/routes/qr+/$qrId.tsx b/app/routes/qr+/$qrId.tsx index 5fb2dffae..92d69c0ca 100644 --- a/app/routes/qr+/$qrId.tsx +++ b/app/routes/qr+/$qrId.tsx @@ -8,7 +8,7 @@ import { getUserOrganizations } from "~/modules/organization/service.server"; import { getQr } from "~/modules/qr/service.server"; import { createScan, updateScan } from "~/modules/scan/service.server"; import { setCookie } from "~/utils/cookies.server"; -import { makeShelfError } from "~/utils/error"; +import { makeShelfError, ShelfError } from "~/utils/error"; import { assertIsPost, data, @@ -29,12 +29,8 @@ export async function loader({ context, request, params }: LoaderFunctionArgs) { try { /* Find the QR in the database */ const qr = await getQr(id); - /** If the QR doesn't exist, getQR will throw a 404 - * - * AFTER MVP: Here we have to consider a deleted User which will - * delete all the connected QRs. - * However, in real life there could be a physical QR code - * that is still there. Will we allow someone to claim it? + /** + * If the QR doesn't exist, getQR will throw a 404 */ /** Record the scan in the DB using the QR id @@ -67,8 +63,8 @@ export async function loader({ context, request, params }: LoaderFunctionArgs) { * Does the QR code belong to any user or is it unclaimed? */ if (!qr.organizationId) { - /** We redirect to link where we handle the rest of the logic */ - return redirect(`link?scanId=${scan.id}`); + /** We redirect to claim where we handle the linking of the code to an organization */ + return redirect(`claim?scanId=${scan.id}`); } /** @@ -78,45 +74,62 @@ export async function loader({ context, request, params }: LoaderFunctionArgs) { /** There could be a case when you get removed from an organization while browsing it. * In this case what we do is we set the current organization to the first one in the list */ - const userOrganizations = ( - await getUserOrganizations({ - userId: authSession.userId, - }) - ).map((uo) => uo.organization); - const userOrganizationIds = userOrganizations.map((org) => org.id); - const personalOrganization = userOrganizations.find( + const userOrganizations = await getUserOrganizations({ + userId: authSession.userId, + }); + const organizations = userOrganizations.map((uo) => uo.organization); + const organizationsIds = organizations.map((org) => org.id); + const personalOrganization = organizations.find( (org) => org.type === "PERSONAL" ) as Pick; - if (!userOrganizationIds.includes(qr.organizationId)) { + if (!organizationsIds.includes(qr.organizationId)) { return redirect(`contact-owner?scanId=${scan.id}`); } const headers = [ setCookie( await setSelectedOrganizationIdCookie( - userOrganizationIds.find((orgId) => orgId === qr.organizationId) || + organizationsIds.find((orgId) => orgId === qr.organizationId) || personalOrganization.id ) ), ]; /** - * When there is no assetId that means that the asset was deleted so the QR code is orphaned. - * Here we redirect to a page where the user has the option to link to existing asset or create a new one. + * When there is no assetId or qrId that means that the asset or kit was deleted or the Qr was generated as unlinked. + * Here we redirect to a page where the user has the option to link to existing asset or kit create a new one. */ - if (!qr.assetId) { + if (!qr.assetId && !qr.kitId) { return redirect(`link?scanId=${scan.id}`, { headers, }); } - return redirect( - `/assets/${qr.assetId}/overview?ref=qr&scanId=${scan.id}&qrId=${qr.id}`, - { - headers, - } - ); + /** If its linked to an asset, redirect to the asset */ + if (qr.assetId) { + return redirect( + `/assets/${qr.assetId}/overview?ref=qr&scanId=${scan.id}&qrId=${qr.id}`, + { + headers, + } + ); + } else if (qr.kitId) { + /** If its linked to a kit, redirect to the kit */ + return redirect( + `/kits/${qr.kitId}?ref=qr&scanId=${scan.id}&qrId=${qr.id}`, + { + headers, + } + ); + } else { + throw new ShelfError({ + cause: null, + message: + "Something went wrong with handling this QR code. This should not happen. Please try again or contact support.", + label: "QR", + }); + } } catch (cause) { const reason = makeShelfError(cause, { userId, id }); throw json(error(reason), { status: reason.status }); diff --git a/app/routes/qr+/$qrId_.claim.tsx b/app/routes/qr+/$qrId_.claim.tsx new file mode 100644 index 000000000..008afc96a --- /dev/null +++ b/app/routes/qr+/$qrId_.claim.tsx @@ -0,0 +1,163 @@ +import type { + MetaFunction, + LoaderFunctionArgs, + ActionFunctionArgs, +} from "@remix-run/node"; +import { json, redirect } from "@remix-run/node"; +import { useNavigation } from "@remix-run/react"; +import { z } from "zod"; +import { Form } from "~/components/custom-form"; +import { UnlinkIcon } from "~/components/icons/library"; +import { OrganizationSelect } from "~/components/layout/sidebar/organization-select"; +import { Button } from "~/components/shared/button"; + +import { db } from "~/database/db.server"; +import { setSelectedOrganizationIdCookie } from "~/modules/organization/context.server"; +import { claimQrCode } from "~/modules/qr/service.server"; +import { appendToMetaTitle } from "~/utils/append-to-meta-title"; +import { setCookie } from "~/utils/cookies.server"; +import { makeShelfError, notAllowedMethod } from "~/utils/error"; +import { isFormProcessing } from "~/utils/form"; +import { + data, + error, + getActionMethod, + getParams, + parseData, +} from "~/utils/http.server"; +import { + PermissionAction, + PermissionEntity, +} from "~/utils/permissions/permission.validator.server"; +import { requirePermission } from "~/utils/roles.server"; + +export async function loader({ context, request, params }: LoaderFunctionArgs) { + const authSession = context.getSession(); + const { userId } = authSession; + const { qrId } = getParams(params, z.object({ qrId: z.string() })); + try { + const { organizations, currentOrganization } = await requirePermission({ + userId, + request, + entity: PermissionEntity.qr, + action: PermissionAction.update, + }); + + const qr = await db.qr.findUnique({ + where: { + id: qrId, + }, + }); + + /** If for some reason its already claimed, redirect to link */ + if (qr?.organizationId) { + return redirect(`/qr/${qrId}/link`); + } + + return json( + data({ + header: { + title: "Claim QR code for your organization", + }, + qrId, + organizations, + currentOrganizationId: currentOrganization.id, + }) + ); + } catch (cause) { + const reason = makeShelfError(cause, { userId }); + throw json(error(reason), { status: reason.status }); + } +} + +export async function action({ context, request, params }: ActionFunctionArgs) { + const authSession = context.getSession(); + const { userId } = authSession; + const { qrId } = getParams(params, z.object({ qrId: z.string() })); + + try { + const method = getActionMethod(request); + + switch (method) { + case "POST": { + const { organizationId } = parseData( + await request.formData(), + z.object({ + organizationId: z.string(), + }) + ); + + await claimQrCode({ + id: qrId, + organizationId, + userId, + }); + + /** Redirect to the relevant action. We also set the current org to the one selected, as the user could select */ + return redirect(`/qr/${qrId}/link?ref=claim`, { + headers: [ + setCookie(await setSelectedOrganizationIdCookie(organizationId)), + ], + }); + } + } + + throw notAllowedMethod(method); + } catch (cause) { + const reason = makeShelfError(cause); + return json(error(reason), { status: reason.status }); + } +} + +export const meta: MetaFunction = ({ data }) => [ + { title: appendToMetaTitle(data?.header.title) }, +]; + +export default function QrLink() { + const navigation = useNavigation(); + const disabled = isFormProcessing(navigation.state); + return ( + <> +
+
+
+ +
+
+

+ Unclaimed QR Code +

+

+ Select the workspace for which you want to claim the QR code. +

+
+
+
+
+ + + +
+
+
+
+
+ + ); +} diff --git a/app/routes/qr+/$qrId_.contact-owner.tsx b/app/routes/qr+/$qrId_.contact-owner.tsx index 8b463b45f..272a3f5d2 100644 --- a/app/routes/qr+/$qrId_.contact-owner.tsx +++ b/app/routes/qr+/$qrId_.contact-owner.tsx @@ -44,10 +44,9 @@ export async function action({ request, params }: ActionFunctionArgs) { where: { id: qrId, }, - select: { + include: { asset: true, - userId: true, - organizationId: true, + kit: true, }, }) .catch((cause) => { @@ -60,16 +59,6 @@ export async function action({ request, params }: ActionFunctionArgs) { }); }); - if (!qr || !qr.asset || !qr.asset.id) { - throw new ShelfError({ - cause: null, - message: "QR code doesn't exist.", - additionalData: { qrId }, - label: "QR", - status: 400, - }); - } - /** * This should not happen as the user will be redirected to claim the code before they ever land on this page. * We still handle it just in case also to keep TS happy. @@ -77,9 +66,9 @@ export async function action({ request, params }: ActionFunctionArgs) { if (!qr.organizationId || !qr?.userId) { throw new ShelfError({ cause: null, + title: "Unclaimed QR code", message: - "This QR doesn't belong to any user or organization so it cannot be reported as found. If this issue persists, please contact support.", - title: "QR is not claimed", + "This QR doesn't belong to any user or organization so it cannot be reported as found. If you think this is a mistake, please contact support.", label: "QR", }); } @@ -92,7 +81,8 @@ export async function action({ request, params }: ActionFunctionArgs) { const report = await createReport({ email, content, - assetId: qr.asset.id, + assetId: qr?.asset?.id, + kitId: qr?.kit?.id, }); /** @@ -102,7 +92,7 @@ export async function action({ request, params }: ActionFunctionArgs) { */ await sendReportEmails({ owner, - asset: qr.asset, + qr, message: report.content, reporterEmail: report.email, }); diff --git a/app/routes/qr+/$qrId_.link-existing-asset.tsx b/app/routes/qr+/$qrId_.link.asset.tsx similarity index 95% rename from app/routes/qr+/$qrId_.link-existing-asset.tsx rename to app/routes/qr+/$qrId_.link.asset.tsx index f2367497a..91704a7cf 100644 --- a/app/routes/qr+/$qrId_.link-existing-asset.tsx +++ b/app/routes/qr+/$qrId_.link.asset.tsx @@ -69,9 +69,9 @@ export const loader = async ({ try { const qr = await getQr(qrId); - if (qr?.assetId) { + if (qr?.assetId || qr?.kitId) { throw new ShelfError({ - message: "This QR code is already linked to an asset.", + message: "This QR code is already linked to an asset or a kit.", title: "QR already linked", label: "QR", status: 403, @@ -130,7 +130,6 @@ export const loader = async ({ title: "Link with existing asset", subHeading: "Choose an asset to link with this QR tag.", }, - showModal: true, qrId, items: assets, categories, @@ -190,7 +189,7 @@ export const action = async ({ organizationId, }); - return redirect(`/qr/${qrId}/successful-link`); + return redirect(`/qr/${qrId}/successful-link?type=asset`); } catch (cause) { const reason = makeShelfError(cause); return json(error(reason), { status: reason.status }); @@ -220,7 +219,7 @@ export default function QrLinkExisting() { let isHydrated = useHydrated(); const { vh } = useViewportHeight(); - const maxHeight = isHydrated ? vh - 12 + "px" : "100%"; // We need to handle SSR and we are also substracting 12px to properly handle spacing on the bottom + const maxHeight = isHydrated ? vh - 12 - 45 + "px" : "100%"; // We need to handle SSR and we are also substracting 12px to properly handle spacing on the bottom and 45px to handle the horizontal tabs return (
@@ -306,10 +305,10 @@ export default function QrLinkExisting() { customEmptyStateContent={{ title: "You haven't added any assets yet.", text: "What are you waiting for? Create your first asset now!", - newButtonRoute: "/assets/new", - newButtonContent: "New asset", + newButtonRoute: `/assets/new?qrId=${qrId}`, + newButtonContent: "Create new asset and link", }} - className="border-t-0" + className="h-full border-t-0" />
- + + +
+ ); +} + +const RowComponent = ({ item }: { item: Kit }) => ( + <> + +
+
+
+ +
+
+

+ {item.name} +

+
+
+
+ + + + + + +); + +export const ConfirmLinkingKitModal = ({ + kitId, + open = false, + onCancel, +}: { + kitId: string; + open: boolean; + /** + * Runs when the modal is closed + */ + onCancel: () => void; +}) => { + const { items: kits } = useLoaderData(); + const kit = kits.find((a) => a.id === kitId); + const fetcher = useFetcher(); + const { data, state } = fetcher; + const disabled = isFormProcessing(state); + + return kit ? ( + (!v ? onCancel() : null)} + > + + + + + + + Link QR code with ‘{kit.name}’ + + + Are you sure that you want to do this? The current QR code that is + linked to this kit will be unlinked. You can always re-link it with + the old QR code. + + + + + + + + + + + + {data?.error ? ( +
+
+ {data.error.message} +
+
+ ) : null} +
+
+
+ ) : null; +}; + +export const ErrorBoundary = () => ; diff --git a/app/routes/qr+/$qrId_.link.tsx b/app/routes/qr+/$qrId_.link.tsx index 60ccb1da9..47f071bce 100644 --- a/app/routes/qr+/$qrId_.link.tsx +++ b/app/routes/qr+/$qrId_.link.tsx @@ -4,23 +4,18 @@ import type { ActionFunctionArgs, } from "@remix-run/node"; import { json, redirect } from "@remix-run/node"; -import { useLoaderData } from "@remix-run/react"; +import { + Outlet, + useLoaderData, + useMatches, + useSearchParams, +} from "@remix-run/react"; import { z } from "zod"; -import { Form } from "~/components/custom-form"; -import Icon from "~/components/icons/icon"; import { UnlinkIcon } from "~/components/icons/library"; -import ContextualModal from "~/components/layout/contextual-modal"; -import { OrganizationSelect } from "~/components/layout/sidebar/organization-select"; -import type { ButtonProps } from "~/components/shared/button"; +import HorizontalTabs from "~/components/layout/horizontal-tabs"; + import { Button } from "~/components/shared/button"; -import { - AlertDialog, - AlertDialogCancel, - AlertDialogContent, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTrigger, -} from "~/components/shared/modal"; + import { db } from "~/database/db.server"; import { setSelectedOrganizationIdCookie } from "~/modules/organization/context.server"; import { claimQrCode } from "~/modules/qr/service.server"; @@ -44,7 +39,6 @@ export async function loader({ context, request, params }: LoaderFunctionArgs) { const authSession = context.getSession(); const { userId } = authSession; const { qrId } = getParams(params, z.object({ qrId: z.string() })); - let claimed = false; try { const { organizationId, organizations, currentOrganization } = await requirePermission({ @@ -60,9 +54,12 @@ export async function loader({ context, request, params }: LoaderFunctionArgs) { }, }); - /** We set claimed to true if the qr already belongs to an organization */ - if (qr?.organizationId) { - claimed = true; + /** + * If for some reason this code doesnt have an org(shouldnt happen in this view) + * we redirect to the claim page + */ + if (!qr?.organizationId) { + return redirect(`/qr/${qrId}/claim`); } if (qr?.organizationId && qr.organizationId !== organizationId) { @@ -78,12 +75,9 @@ export async function loader({ context, request, params }: LoaderFunctionArgs) { return json( data({ header: { - title: claimed - ? "Link QR with asset" - : "Claim QR code for your organization", + title: "Link QR with asset", }, qrId, - claimed, organizations, currentOrganizationId: currentOrganization.id, }) @@ -143,126 +137,82 @@ export const meta: MetaFunction = ({ data }) => [ ]; export default function QrLink() { - const { qrId, claimed } = useLoaderData(); + const { qrId } = useLoaderData(); + const [searchParams] = useSearchParams(); + const comesFromClaim = searchParams.get("ref") === "claim"; + const matches = useMatches(); + const currentRoute = matches[matches.length - 1]; + + const isLinkPage = currentRoute?.id === "routes/qr+/$qrId_.link"; return ( <> -
-
-
- -
-
-

- {claimed ? "Unlinked QR Code" : "Unclaimed QR Code"} -

-

- {claimed - ? "This code is part of your Shelf environment but is not linked with an asset. Would you like to link it?" - : "This code is an unclaimed code which is not part of any organization. Would you like to claim it?"} -

-
-
- {claimed ? ( + {isLinkPage ? ( +
+
+
+ +
+
+

+ Unlinked QR Code +

+

+ {comesFromClaim + ? "Thanks for claiming the code. Now its time to link it to a kit or asset." + : "This code is part of your Shelf environment but is not linked with an asset. Would you like to link it?"} +

+
+
+ - ) : ( - - )} - {claimed ? ( - ) : ( - - )} - + +
+
+
+ ) : ( +
+ +
+
-
- + )} ); } - -type LinkTo = "new" | "existing"; - -const ConfirmOrganizationClaim = ({ - buttonText, - buttonProps, - linkTo, -}: { - buttonText: string; - buttonProps?: ButtonProps; - linkTo: LinkTo; -}) => ( - - - - - - -
- -
- - - -
-
-

Claim QR code for your organization

-

- Please select an organization to which you would like to claim - this QR -

-
-
-
- -
- - -
- - - - - -
-
-
-
-
-); diff --git a/app/routes/qr+/$qrId_.not-logged-in.tsx b/app/routes/qr+/$qrId_.not-logged-in.tsx index d14a4a8b3..4dcac54e0 100644 --- a/app/routes/qr+/$qrId_.not-logged-in.tsx +++ b/app/routes/qr+/$qrId_.not-logged-in.tsx @@ -27,10 +27,10 @@ export default function QrNotLoggedIn() {

- Thank you for Scanning + Thank you for scanning

- Log in if you own this asset. Contact the owner to report it found + Log in if you own this item. Contact the owner to report it found if it's lost.

diff --git a/app/routes/qr+/$qrId_.successful-link.tsx b/app/routes/qr+/$qrId_.successful-link.tsx index 7e89db1c5..7a81bdce5 100644 --- a/app/routes/qr+/$qrId_.successful-link.tsx +++ b/app/routes/qr+/$qrId_.successful-link.tsx @@ -12,6 +12,7 @@ import { db } from "~/database/db.server"; import { appendToMetaTitle } from "~/utils/append-to-meta-title"; import { makeShelfError, ShelfError } from "~/utils/error"; import { data, error, getParams } from "~/utils/http.server"; +import { normalizeQrData } from "~/utils/qr"; export const loader = async ({ context, params }: LoaderFunctionArgs) => { const authSession = context.getSession(); @@ -22,13 +23,19 @@ export const loader = async ({ context, params }: LoaderFunctionArgs) => { const qr = await db.qr .findUniqueOrThrow({ where: { id: qrId }, - select: { + include: { asset: { select: { id: true, title: true, }, }, + kit: { + select: { + id: true, + name: true, + }, + }, }, }) .catch((cause) => { @@ -46,7 +53,7 @@ export const loader = async ({ context, params }: LoaderFunctionArgs) => { header: { title: "Successfully linked asset to QR code", }, - asset: qr.asset, + qr, }) ); } catch (cause) { @@ -60,25 +67,30 @@ export const meta: MetaFunction = ({ data }) => [ ]; export default function QrSuccessfullLink() { - const { asset } = useLoaderData(); - return asset ? ( + const { qr } = useLoaderData(); + const { item, type, normalizedName } = normalizeQrData(qr); + + if (!item || !type) { + return null; + } + + return ( <>
-

Succesfully linked Item

+

Succesfully linked

- Your asset {asset.title} has been linked with this QR code. + Your {type} {normalizedName} has been linked with this QR code.

- ) : null; + ); } export const ErrorBoundary = () => ; diff --git a/app/utils/list.ts b/app/utils/list.ts index d8bd8e335..fae59ac81 100644 --- a/app/utils/list.ts +++ b/app/utils/list.ts @@ -3,6 +3,7 @@ import type { SortingDirection, SortingOptions, } from "~/components/list/filters/sort-by"; +import type { ListItemData } from "~/components/list/list-item"; export const getParamsValues = (searchParams: URLSearchParams) => ({ page: Number(searchParams.get("page") || "1"), @@ -38,3 +39,7 @@ export const getParamsValues = (searchParams: URLSearchParams) => ({ }); export const ALL_SELECTED_KEY = "all-selected"; + +export function isSelectingAllItems(selectedItems: ListItemData[]) { + return !!selectedItems.find((item) => item.id === ALL_SELECTED_KEY); +} diff --git a/app/utils/qr.ts b/app/utils/qr.ts new file mode 100644 index 000000000..595de21ea --- /dev/null +++ b/app/utils/qr.ts @@ -0,0 +1,34 @@ +import type { Asset, Kit } from "@prisma/client"; +/** This function takes a QR and normalizes the related object data + */ +export function normalizeQrData(qr: { + id: string; + assetId?: string | null; + kitId?: string | null; + asset?: Partial> | null; // Use Partial and Pick to relax requirements + kit?: Partial> | null; // Use Partial and Pick to relax requirements +}): { + item: Asset | Kit | null; + type: "asset" | "kit" | null; + normalizedName: string; +} { + let item: Asset | Kit | null = null; + let type: "asset" | "kit" | null = null; + let normalizedName = ""; + + if (qr.assetId) { + type = "asset"; + item = qr.asset as Asset; + normalizedName = item.title; + } else if (qr.kitId && qr.kit) { + type = "kit"; + item = qr.kit as Kit; + normalizedName = item.name; + } + + return { + item, + type, + normalizedName, + }; +} diff --git a/server/middleware.ts b/server/middleware.ts index 40cfe93b5..6c1fc8a25 100644 --- a/server/middleware.ts +++ b/server/middleware.ts @@ -2,7 +2,12 @@ import { createMiddleware } from "hono/factory"; import { pathToRegexp } from "path-to-regexp"; import { getSession } from "remix-hono/session"; -import { refreshAccessToken } from "~/modules/auth/service.server"; +import { + refreshAccessToken, + validateSession, +} from "~/modules/auth/service.server"; +import { ShelfError } from "~/utils/error"; +import { Logger } from "~/utils/logger"; import type { FlashData } from "./session"; import { authSessionKey } from "./session"; @@ -37,7 +42,23 @@ export function protect({ return c.redirect(`${onFailRedirectTo}?redirectTo=${c.req.path}`); } + let isValidSession = await validateSession(auth.refreshToken); + if (!isValidSession) { + session.flash( + "errorMessage", + "Session might have expired. Please log in again." + ); + session.unset(authSessionKey); + Logger.error( + new ShelfError({ + cause: null, + message: "Session might have expired. Please log in again.", + label: "Auth", + }) + ); + return c.redirect(`${onFailRedirectTo}?redirectTo=${c.req.path}`); + } return next(); }); } From 7cbc17f35109d2dae751370eb51362d9654d38ab Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Sat, 13 Jul 2024 09:46:38 +0530 Subject: [PATCH 09/53] feat(booking-asset-scan): showing list of assets in drawer --- app/components/booking/availability-label.tsx | 13 ++ app/components/zxing-scanner.tsx | 5 +- .../bookings.$bookingId_.scan-assets.tsx | 106 +++++++++++-- app/routes/api+/bookings.get-scanned-asset.ts | 144 +++++++++--------- 4 files changed, 181 insertions(+), 87 deletions(-) diff --git a/app/components/booking/availability-label.tsx b/app/components/booking/availability-label.tsx index fd70f0121..1a5714097 100644 --- a/app/components/booking/availability-label.tsx +++ b/app/components/booking/availability-label.tsx @@ -25,11 +25,13 @@ export function AvailabilityLabel({ isCheckedOut, showKitStatus, isAddedThroughKit, + isAlreadyAdded, }: { asset: AssetWithBooking; isCheckedOut: boolean; showKitStatus?: boolean; isAddedThroughKit?: boolean; + isAlreadyAdded?: boolean; }) { const isPartOfKit = !!asset.kitId; @@ -148,6 +150,17 @@ export function AvailabilityLabel({ ); } + /** User scanned the asset and it is already in booking */ + if (isAlreadyAdded) { + return ( + + ); + } + return null; } diff --git a/app/components/zxing-scanner.tsx b/app/components/zxing-scanner.tsx index cfcb498cf..354504135 100644 --- a/app/components/zxing-scanner.tsx +++ b/app/components/zxing-scanner.tsx @@ -9,11 +9,13 @@ import { Spinner } from "./shared/spinner"; type ZXingScannerProps = { onQrDetectionSuccess: (qrId: string) => void | Promise; videoMediaDevices?: MediaDeviceInfo[]; + isLoading?: boolean; }; export const ZXingScanner = ({ videoMediaDevices, onQrDetectionSuccess, + isLoading: incomingIsLoading, }: ZXingScannerProps) => { const [sendNotification] = useClientNotification(); const navigation = useNavigation(); @@ -24,7 +26,7 @@ export const ZXingScanner = ({ // Function to decode the QR code const decodeQRCodes = async (result: string) => { - if (result != null && !isLoading) { + if (result != null && !isLoading && !incomingIsLoading) { const regex = /^(https?:\/\/)([^/:]+)(:\d+)?\/qr\/([a-zA-Z0-9]+)$/; /** We make sure the value of the QR code matches the structure of Shelf qr codes */ const match = result.match(regex); @@ -62,6 +64,7 @@ export const ZXingScanner = ({ return (
+ {`${incomingIsLoading}`} {isProcessing ? (
Switching cameras... diff --git a/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx b/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx index e593d04c0..2fb856d7d 100644 --- a/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx +++ b/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx @@ -1,10 +1,10 @@ import { useEffect, useState } from "react"; -import type { Asset } from "@prisma/client"; import { BookingStatus, OrganizationRoles } from "@prisma/client"; import { json } from "@remix-run/node"; import type { MetaFunction, LoaderFunctionArgs } from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; import { z } from "zod"; +import { AvailabilityLabel } from "~/components/booking/availability-label"; import { AssetLabel } from "~/components/icons/library"; import Header from "~/components/layout/header"; import type { HeaderData } from "~/components/layout/header/types"; @@ -20,7 +20,7 @@ import { DrawerTrigger, } from "~/components/shared/drawer"; import { Spinner } from "~/components/shared/spinner"; -import { Table, Th } from "~/components/table"; +import { Table, Td, Th } from "~/components/table"; import When from "~/components/when/when"; import { ZXingScanner } from "~/components/zxing-scanner"; import { useClientNotification } from "~/hooks/use-client-notification"; @@ -31,12 +31,14 @@ import { getBooking } from "~/modules/booking/service.server"; import { appendToMetaTitle } from "~/utils/append-to-meta-title"; import { userPrefs } from "~/utils/cookies.server"; import { makeShelfError, ShelfError } from "~/utils/error"; +import { isFormProcessing } from "~/utils/form"; import { data, error, getParams } from "~/utils/http.server"; import { PermissionAction, PermissionEntity, } from "~/utils/permissions/permission.validator.server"; import { requirePermission } from "~/utils/roles.server"; +import type { AssetWithBooking } from "./bookings.$bookingId.add-assets"; export async function loader({ context, request, params }: LoaderFunctionArgs) { const authSession = context.getSession(); @@ -113,9 +115,11 @@ export const handle = { export default function ScanAssetsForBookings() { const { booking } = useLoaderData(); - const [fetchedAssets, setFetchedAssets] = useState([]); + const [fetchedAssets, setFetchedAssets] = useState([]); + + const fetcher = useFetcherWithReset<{ asset: AssetWithBooking }>(); + const isFetchingAsset = isFormProcessing(fetcher.state); - const fetcher = useFetcherWithReset<{ asset: Asset }>(); const [sendNotification] = useClientNotification(); const { videoMediaDevices } = useQrScanner(); @@ -138,11 +142,23 @@ export default function ScanAssetsForBookings() { useEffect( function handleFetcherSuccess() { if (fetcher.data && fetcher.data?.asset) { - setFetchedAssets((prev) => [...prev, fetcher.data.asset]); + setFetchedAssets((prev) => [ + ...prev, + // If asset is already added, then we will not add it again. + ...(prev.some((a) => a.id === fetcher.data.asset.id) + ? [] + : [fetcher.data.asset]), + ]); fetcher.reset(); + + sendNotification({ + title: "Asset scanned", + message: "Asset is scanned and successfully added to the list.", + icon: { name: "success", variant: "success" }, + }); } }, - [fetcher, fetcher.data] + [fetcher, sendNotification] ); return ( @@ -154,7 +170,7 @@ export default function ScanAssetsForBookings() { - +
@@ -182,21 +198,67 @@ export default function ScanAssetsForBookings() { 0}> - - - - -
Name
+
+ + + + + + + + {fetchedAssets.map((asset) => ( + + + + + ))} + +
+
+
+
+

+ {asset.title} +

+ +
+ a.id === asset.id + ) && !!asset.kitId + } + isAlreadyAdded={booking.assets.some( + (a) => a.id === asset.id + )} + showKitStatus + asset={asset} + isCheckedOut={ + asset.status === "CHECKED_OUT" + } + /> +
+
+
+
+
+
+
0}> - - + +
@@ -206,6 +268,7 @@ export default function ScanAssetsForBookings() {
{videoMediaDevices && videoMediaDevices.length > 0 ? ( @@ -218,3 +281,18 @@ export default function ScanAssetsForBookings() { ); } + +function Tr({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/app/routes/api+/bookings.get-scanned-asset.ts b/app/routes/api+/bookings.get-scanned-asset.ts index 8a5228e71..7a5e3f9bb 100644 --- a/app/routes/api+/bookings.get-scanned-asset.ts +++ b/app/routes/api+/bookings.get-scanned-asset.ts @@ -1,4 +1,4 @@ -import { AssetStatus, BookingStatus } from "@prisma/client"; +import { BookingStatus } from "@prisma/client"; import { json, type ActionFunctionArgs } from "@remix-run/node"; import { z } from "zod"; import { getAsset } from "~/modules/asset/service.server"; @@ -15,7 +15,7 @@ export async function action({ request, context }: ActionFunctionArgs) { const formData = await request.formData(); - const { qrId, bookingId } = parseData( + const { qrId } = parseData( formData, z.object({ qrId: z.string(), bookingId: z.string() }), { @@ -54,82 +54,82 @@ export async function action({ request, context }: ActionFunctionArgs) { }, }); - const isPartOfCurrentBooking = asset.bookings.some( - (b) => b.id === bookingId - ); + // const isPartOfCurrentBooking = asset.bookings.some( + // (b) => b.id === bookingId + // ); - /** Asset is already added in the booking */ - if (isPartOfCurrentBooking) { - throw new ShelfError({ - cause: null, - title: "Already added", - message: "This asset is already added in the current booking.", - label: "Booking", - shouldBeCaptured: false, - }); - } + // /** Asset is already added in the booking */ + // if (isPartOfCurrentBooking) { + // throw new ShelfError({ + // cause: null, + // title: "Already added", + // message: "This asset is already added in the current booking.", + // label: "Booking", + // shouldBeCaptured: false, + // }); + // } - /** Asset is not available for booking */ - if (!asset.availableToBook) { - throw new ShelfError({ - cause: null, - title: "Unavailable", - message: - "This asset is marked as unavailable for bookings by administrator.", - label: "Booking", - shouldBeCaptured: false, - }); - } + // /** Asset is not available for booking */ + // if (!asset.availableToBook) { + // throw new ShelfError({ + // cause: null, + // title: "Unavailable", + // message: + // "This asset is marked as unavailable for bookings by administrator.", + // label: "Booking", + // shouldBeCaptured: false, + // }); + // } - /** Asset is in custody */ - if (asset.custody) { - throw new ShelfError({ - cause: null, - title: "In custody", - message: - "This asset is in custody of team member making it currently unavailable for bookings.", - label: "Booking", - shouldBeCaptured: false, - }); - } + // /** Asset is in custody */ + // if (asset.custody) { + // throw new ShelfError({ + // cause: null, + // title: "In custody", + // message: + // "This asset is in custody of team member making it currently unavailable for bookings.", + // label: "Booking", + // shouldBeCaptured: false, + // }); + // } - /** Is booked for period */ - if ( - asset.bookings.length > 0 && - asset.bookings.some((b) => b.id !== bookingId) - ) { - throw new ShelfError({ - cause: null, - title: "Already booked", - message: - "This asset is added to a booking that is overlapping the selected time period.", - label: "Booking", - shouldBeCaptured: false, - }); - } + // /** Is booked for period */ + // if ( + // asset.bookings.length > 0 && + // asset.bookings.some((b) => b.id !== bookingId) + // ) { + // throw new ShelfError({ + // cause: null, + // title: "Already booked", + // message: + // "This asset is added to a booking that is overlapping the selected time period.", + // label: "Booking", + // shouldBeCaptured: false, + // }); + // } - /** If currently checked out */ - if (asset.status === AssetStatus.CHECKED_OUT) { - throw new ShelfError({ - cause: null, - title: "Checked out", - message: - "This asset is currently checked out as part of another booking and should be available for your selected date range period", - label: "Booking", - shouldBeCaptured: false, - }); - } + // /** If currently checked out */ + // if (asset.status === AssetStatus.CHECKED_OUT) { + // throw new ShelfError({ + // cause: null, + // title: "Checked out", + // message: + // "This asset is currently checked out as part of another booking and should be available for your selected date range period", + // label: "Booking", + // shouldBeCaptured: false, + // }); + // } - /** Asset is part of a kit */ - if (asset.kitId) { - throw new ShelfError({ - cause: null, - title: "Part of kit", - message: "Remove the asset from the kit to add it individually.", - label: "Booking", - shouldBeCaptured: false, - }); - } + // /** Asset is part of a kit */ + // if (asset.kitId) { + // throw new ShelfError({ + // cause: null, + // title: "Part of kit", + // message: "Remove the asset from the kit to add it individually.", + // label: "Booking", + // shouldBeCaptured: false, + // }); + // } return json(data({ asset })); } catch (cause) { From 3571e2886f759f7dfa7b88aad7301875238bc8b6 Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Sun, 14 Jul 2024 09:30:03 +0530 Subject: [PATCH 10/53] feat(booking-asset-scan): create separate component to dispaly scanned asset with atom to keep track --- app/atoms/bookings.ts | 37 +++++ .../booking/scanned-assets-drawer.tsx | 156 +++++++++++++++++ app/components/zxing-scanner.tsx | 1 - .../bookings.$bookingId_.scan-assets.tsx | 157 ++---------------- 4 files changed, 211 insertions(+), 140 deletions(-) create mode 100644 app/atoms/bookings.ts create mode 100644 app/components/booking/scanned-assets-drawer.tsx diff --git a/app/atoms/bookings.ts b/app/atoms/bookings.ts new file mode 100644 index 000000000..f64d74f98 --- /dev/null +++ b/app/atoms/bookings.ts @@ -0,0 +1,37 @@ +import { atom } from "jotai"; +import type { AssetWithBooking } from "~/routes/_layout+/bookings.$bookingId.add-assets"; + +/** This atom keeps track of the assets fetched after scanning. */ +export const fetchedScannedAssetsAtom = atom([]); + +/** This atom keeps track of the total fetchedScanned assets */ +export const fetchedScannedAssetsCountAtom = atom( + (get) => get(fetchedScannedAssetsAtom).length +); + +/** + * This atom is used to set multiple assets into fetchedScannedAssetAtom. + * This will replace new items with existing ones. + * */ +export const setFetchedScannedAssetsAtom = atom< + null, + AssetWithBooking[][], + unknown +>(null, (_, set, update) => { + set(fetchedScannedAssetsAtom, update); +}); + +/** + * This atom is used to set a single asset into fetchedScannedAssetAtom + * If `update` asset is already added then it will not be added again in the array. + * */ +export const setFetchedScannedAssetAtom = atom< + null, + AssetWithBooking[], + unknown +>(null, (_, set, update) => { + set(fetchedScannedAssetsAtom, (prev) => [ + ...prev, + ...(prev.some((a) => a.id === update.id) ? [] : [update]), + ]); +}); diff --git a/app/components/booking/scanned-assets-drawer.tsx b/app/components/booking/scanned-assets-drawer.tsx new file mode 100644 index 000000000..e18f911bf --- /dev/null +++ b/app/components/booking/scanned-assets-drawer.tsx @@ -0,0 +1,156 @@ +import { useLoaderData } from "@remix-run/react"; +import { useAtomValue } from "jotai"; +import { + fetchedScannedAssetsAtom, + fetchedScannedAssetsCountAtom, +} from "~/atoms/bookings"; +import { type loader } from "~/routes/_layout+/bookings.$bookingId_.scan-assets"; +import { tw } from "~/utils/tw"; +import { AvailabilityLabel } from "./availability-label"; +import { AssetLabel } from "../icons/library"; +import { ListHeader } from "../list/list-header"; +import { Button } from "../shared/button"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTrigger, +} from "../shared/drawer"; +import { Table, Td, Th } from "../table"; +import When from "../when/when"; + +type ScannedAssetsDrawerProps = { + className?: string; + style?: React.CSSProperties; +}; + +export default function ScannedAssetsDrawer({ + className, + style, +}: ScannedAssetsDrawerProps) { + const { booking } = useLoaderData(); + + const fetchedScannedAssets = useAtomValue(fetchedScannedAssetsAtom); + const fetchedScannedAssetsCount = useAtomValue(fetchedScannedAssetsCountAtom); + + return ( + + + + + + +
+ + + {fetchedScannedAssetsCount} assets scanned + + + + +
+
+
+ +
+
+ +
+
+ List is empty +
+

+ Fill list by scanning codes... +

+
+
+
+ + 0}> +
+ + + + + + + + {fetchedScannedAssets.map((asset) => ( + + + + + ))} + +
+
+
+
+

+ {asset.title} +

+ +
+ a.id === asset.id + ) && !!asset.kitId + } + isAlreadyAdded={booking.assets.some( + (a) => a.id === asset.id + )} + showKitStatus + asset={asset} + isCheckedOut={asset.status === "CHECKED_OUT"} + /> +
+
+
+
+
+
+
+
+ + 0}> + + + + + + + +
+
+
+ ); +} + +function Tr({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/app/components/zxing-scanner.tsx b/app/components/zxing-scanner.tsx index 354504135..f202827fc 100644 --- a/app/components/zxing-scanner.tsx +++ b/app/components/zxing-scanner.tsx @@ -64,7 +64,6 @@ export const ZXingScanner = ({ return (
- {`${incomingIsLoading}`} {isProcessing ? (
Switching cameras... diff --git a/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx b/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx index 2fb856d7d..824a8fca7 100644 --- a/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx +++ b/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx @@ -3,25 +3,13 @@ import { BookingStatus, OrganizationRoles } from "@prisma/client"; import { json } from "@remix-run/node"; import type { MetaFunction, LoaderFunctionArgs } from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; +import { useSetAtom } from "jotai"; import { z } from "zod"; -import { AvailabilityLabel } from "~/components/booking/availability-label"; -import { AssetLabel } from "~/components/icons/library"; +import { setFetchedScannedAssetAtom } from "~/atoms/bookings"; +import ScannedAssetsDrawer from "~/components/booking/scanned-assets-drawer"; import Header from "~/components/layout/header"; import type { HeaderData } from "~/components/layout/header/types"; -import { ListHeader } from "~/components/list/list-header"; -import { Button } from "~/components/shared/button"; -import { - Drawer, - DrawerClose, - DrawerContent, - DrawerDescription, - DrawerFooter, - DrawerHeader, - DrawerTrigger, -} from "~/components/shared/drawer"; import { Spinner } from "~/components/shared/spinner"; -import { Table, Td, Th } from "~/components/table"; -import When from "~/components/when/when"; import { ZXingScanner } from "~/components/zxing-scanner"; import { useClientNotification } from "~/hooks/use-client-notification"; import useFetcherWithReset from "~/hooks/use-fetcher-with-reset"; @@ -114,12 +102,13 @@ export const handle = { export default function ScanAssetsForBookings() { const { booking } = useLoaderData(); - - const [fetchedAssets, setFetchedAssets] = useState([]); + const [fetchedQrIds, setFetchedQrIds] = useState([]); const fetcher = useFetcherWithReset<{ asset: AssetWithBooking }>(); const isFetchingAsset = isFormProcessing(fetcher.state); + const setFetchedScannedAsset = useSetAtom(setFetchedScannedAssetAtom); + const [sendNotification] = useClientNotification(); const { videoMediaDevices } = useQrScanner(); @@ -127,6 +116,15 @@ export default function ScanAssetsForBookings() { const height = isMd ? vh - 140 : vh - 167; function handleQrDetectionSuccess(qrId: string) { + /** + * If a qrId is already fetched then we don't have to fetch it again otherwise it will cause to fetch asset infinitely + * */ + if (fetchedQrIds.includes(qrId)) { + return; + } + + setFetchedQrIds((prev) => [...prev, qrId]); + sendNotification({ title: "Shelf's QR Code detected", message: "Fetching mapped asset details...", @@ -142,13 +140,7 @@ export default function ScanAssetsForBookings() { useEffect( function handleFetcherSuccess() { if (fetcher.data && fetcher.data?.asset) { - setFetchedAssets((prev) => [ - ...prev, - // If asset is already added, then we will not add it again. - ...(prev.some((a) => a.id === fetcher.data.asset.id) - ? [] - : [fetcher.data.asset]), - ]); + setFetchedScannedAsset(fetcher.data.asset); fetcher.reset(); sendNotification({ @@ -158,112 +150,14 @@ export default function ScanAssetsForBookings() { }); } }, - [fetcher, sendNotification] + [fetcher, sendNotification, setFetchedScannedAsset] ); return ( <>
- - - - - - -
- - - {fetchedAssets.length} assets scanned - - - - -
-
-
- -
-
- -
-
- List is empty -
-

- Fill list by scanning codes... -

-
-
-
- - 0}> -
- - - - - - - - {fetchedAssets.map((asset) => ( - - - - - ))} - -
-
-
-
-

- {asset.title} -

- -
- a.id === asset.id - ) && !!asset.kitId - } - isAlreadyAdded={booking.assets.some( - (a) => a.id === asset.id - )} - showKitStatus - asset={asset} - isCheckedOut={ - asset.status === "CHECKED_OUT" - } - /> -
-
-
-
-
-
-
-
- - 0}> - - - - - - - -
-
-
+
{videoMediaDevices && videoMediaDevices.length > 0 ? ( @@ -281,18 +175,3 @@ export default function ScanAssetsForBookings() { ); } - -function Tr({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} From a21d43833d15962e42708637d54554531d926923 Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Sun, 14 Jul 2024 09:46:53 +0530 Subject: [PATCH 11/53] feat(booking-asset-scan): create atoms for removing and clearing fetchedScanned assets --- app/atoms/bookings.ts | 17 +++++++++++++ .../booking/scanned-assets-drawer.tsx | 24 +++++++++++++++---- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/app/atoms/bookings.ts b/app/atoms/bookings.ts index f64d74f98..6658cdeec 100644 --- a/app/atoms/bookings.ts +++ b/app/atoms/bookings.ts @@ -35,3 +35,20 @@ export const setFetchedScannedAssetAtom = atom< ...(prev.some((a) => a.id === update.id) ? [] : [update]), ]); }); + +/** + * This atom is used to remove an asset from the list using the `id` of asset. + */ +export const removeFetchedScannedAssetAtom = atom( + null, + (_, set, update) => { + set(fetchedScannedAssetsAtom, (prev) => + prev.filter((asset) => asset.id !== update) + ); + } +); + +/** This atom clears all the items in fetchedScannedAssetsAtom */ +export const clearFetchedScannedAssetsAtom = atom(null, (_, set) => { + set(fetchedScannedAssetsAtom, []); +}); diff --git a/app/components/booking/scanned-assets-drawer.tsx b/app/components/booking/scanned-assets-drawer.tsx index e18f911bf..166658885 100644 --- a/app/components/booking/scanned-assets-drawer.tsx +++ b/app/components/booking/scanned-assets-drawer.tsx @@ -1,8 +1,10 @@ import { useLoaderData } from "@remix-run/react"; -import { useAtomValue } from "jotai"; +import { useAtomValue, useSetAtom } from "jotai"; import { + clearFetchedScannedAssetsAtom, fetchedScannedAssetsAtom, fetchedScannedAssetsCountAtom, + removeFetchedScannedAssetAtom, } from "~/atoms/bookings"; import { type loader } from "~/routes/_layout+/bookings.$bookingId_.scan-assets"; import { tw } from "~/utils/tw"; @@ -35,6 +37,8 @@ export default function ScannedAssetsDrawer({ const fetchedScannedAssets = useAtomValue(fetchedScannedAssetsAtom); const fetchedScannedAssetsCount = useAtomValue(fetchedScannedAssetsCountAtom); + const removeFetchedScannedAsset = useSetAtom(removeFetchedScannedAssetAtom); + const clearFetchedScannedAssets = useSetAtom(clearFetchedScannedAssetsAtom); return ( @@ -47,10 +51,19 @@ export default function ScannedAssetsDrawer({ style={style} >
- + {fetchedScannedAssetsCount} assets scanned + + 0}> + + Clear list + + @@ -76,8 +89,8 @@ export default function ScannedAssetsDrawer({
- - + + @@ -115,6 +128,9 @@ export default function ScannedAssetsDrawer({ className="border-none" variant="ghost" icon="trash" + onClick={() => { + removeFetchedScannedAsset(asset.id); + }} /> From 27205ba37971078d5f61ce9acbefbb63d3c4e361 Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Sun, 14 Jul 2024 10:32:53 +0530 Subject: [PATCH 12/53] feat(booking-asset-scan): create action for handling adding scanned assets into booking --- .../booking/scanned-assets-drawer.tsx | 57 ++++++++-- app/modules/booking/service.server.ts | 107 ++++++++++++++++++ .../bookings.$bookingId_.scan-assets.tsx | 71 ++++++++++-- app/routes/api+/bookings.get-scanned-asset.ts | 91 +-------------- 4 files changed, 219 insertions(+), 107 deletions(-) diff --git a/app/components/booking/scanned-assets-drawer.tsx b/app/components/booking/scanned-assets-drawer.tsx index 166658885..806c8a003 100644 --- a/app/components/booking/scanned-assets-drawer.tsx +++ b/app/components/booking/scanned-assets-drawer.tsx @@ -1,5 +1,7 @@ -import { useLoaderData } from "@remix-run/react"; +import { Form, useLoaderData } from "@remix-run/react"; import { useAtomValue, useSetAtom } from "jotai"; +import { useZorm } from "react-zorm"; +import { z } from "zod"; import { clearFetchedScannedAssetsAtom, fetchedScannedAssetsAtom, @@ -27,14 +29,25 @@ import When from "../when/when"; type ScannedAssetsDrawerProps = { className?: string; style?: React.CSSProperties; + isLoading?: boolean; }; +export const addScannedAssetsToBookingSchema = z.object({ + assetIds: z.array(z.string()).min(1), +}); + export default function ScannedAssetsDrawer({ className, style, + isLoading, }: ScannedAssetsDrawerProps) { const { booking } = useLoaderData(); + const zo = useZorm( + "AddScannedAssetsToBooking", + addScannedAssetsToBookingSchema + ); + const fetchedScannedAssets = useAtomValue(fetchedScannedAssetsAtom); const fetchedScannedAssetsCount = useAtomValue(fetchedScannedAssetsCountAtom); const removeFetchedScannedAsset = useSetAtom(removeFetchedScannedAssetAtom); @@ -141,14 +154,40 @@ export default function ScannedAssetsDrawer({ 0}> - - - - - - +
+ +

+ {zo.errors.assetIds()?.message} +

+
+ + + + + + +
+ {fetchedScannedAssets.map((asset, i) => ( + + ))} + + + +
+
diff --git a/app/modules/booking/service.server.ts b/app/modules/booking/service.server.ts index 22815140c..fbf51fad3 100644 --- a/app/modules/booking/service.server.ts +++ b/app/modules/booking/service.server.ts @@ -1077,12 +1077,15 @@ export async function getBookingFlags( (asset) => asset.status === AssetStatus.IN_CUSTODY ); + const hasKits = assets.some((asset) => !!asset.kitId); + return { hasAssets, hasUnavailableAssets, hasCheckedOutAssets, hasAlreadyBookedAssets, hasAssetsInCustody, + hasKits, }; } @@ -1459,3 +1462,107 @@ export async function bulkCancelBookings({ }); } } + +export async function addScannedAssetsToBooking({ + assetIds, + bookingId, + organizationId, +}: { + assetIds: Asset["id"][]; + bookingId: Booking["id"]; + organizationId: Booking["organizationId"]; +}) { + try { + const booking = await db.booking.findFirstOrThrow({ + where: { id: bookingId, organizationId }, + }); + + /** We have to make sure all the assets are available for booking */ + const { + hasAlreadyBookedAssets, + hasUnavailableAssets, + hasAssetsInCustody, + hasCheckedOutAssets, + hasKits, + } = await getBookingFlags({ ...booking, assetIds }); + + if (hasAlreadyBookedAssets) { + throw new ShelfError({ + cause: null, + title: "Already booked", + message: + "Some assets are already added in the current booking. Please make sure you have scanned only available assets.", + label: "Booking", + shouldBeCaptured: false, + }); + } + + if (hasUnavailableAssets) { + throw new ShelfError({ + cause: null, + title: "Unavailable", + message: + "Some assets are marked as unavailable for bookings by administrator. Please make sure you have scanned only available assets.", + label: "Booking", + shouldBeCaptured: false, + }); + } + + if (hasAssetsInCustody) { + throw new ShelfError({ + cause: null, + title: "In custody", + message: + "Some assets are in custody of team member making it currently unavailable for bookings. Please make sure you have scanned only available assets.", + label: "Booking", + shouldBeCaptured: false, + }); + } + + if (hasCheckedOutAssets) { + throw new ShelfError({ + cause: null, + title: "Checked out", + message: + "Some assets are currently checked out as part of another booking and should be available for your selected date range period.", + label: "Booking", + shouldBeCaptured: false, + }); + } + + if (hasKits) { + throw new ShelfError({ + cause: null, + title: "Part of kit", + message: + "Some assets are part of kit. Remove the asset from the kit to add it individually.", + label: "Booking", + shouldBeCaptured: false, + }); + } + + /** Adding assets into booking */ + return await db.$transaction(async (tx) => { + await tx.booking.update({ + where: { id: booking.id }, + data: { + assets: { + connect: assetIds.map((id) => ({ id })), + }, + }, + }); + }); + } catch (cause) { + const message = + cause instanceof ShelfError + ? cause.message + : "Something went wrong while adding scanned assets to booking."; + + throw new ShelfError({ + cause, + message, + additionalData: { assetIds, bookingId, organizationId }, + label, + }); + } +} diff --git a/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx b/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx index 824a8fca7..ec725cee3 100644 --- a/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx +++ b/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx @@ -1,12 +1,18 @@ import { useEffect, useState } from "react"; import { BookingStatus, OrganizationRoles } from "@prisma/client"; -import { json } from "@remix-run/node"; -import type { MetaFunction, LoaderFunctionArgs } from "@remix-run/node"; -import { useLoaderData } from "@remix-run/react"; +import { json, redirect } from "@remix-run/node"; +import type { + MetaFunction, + LoaderFunctionArgs, + ActionFunctionArgs, +} from "@remix-run/node"; +import { useLoaderData, useNavigation } from "@remix-run/react"; import { useSetAtom } from "jotai"; import { z } from "zod"; import { setFetchedScannedAssetAtom } from "~/atoms/bookings"; -import ScannedAssetsDrawer from "~/components/booking/scanned-assets-drawer"; +import ScannedAssetsDrawer, { + addScannedAssetsToBookingSchema, +} from "~/components/booking/scanned-assets-drawer"; import Header from "~/components/layout/header"; import type { HeaderData } from "~/components/layout/header/types"; import { Spinner } from "~/components/shared/spinner"; @@ -15,12 +21,22 @@ import { useClientNotification } from "~/hooks/use-client-notification"; import useFetcherWithReset from "~/hooks/use-fetcher-with-reset"; import { useQrScanner } from "~/hooks/use-qr-scanner"; import { useViewportHeight } from "~/hooks/use-viewport-height"; -import { getBooking } from "~/modules/booking/service.server"; +import { + addScannedAssetsToBooking, + getBooking, +} from "~/modules/booking/service.server"; import { appendToMetaTitle } from "~/utils/append-to-meta-title"; import { userPrefs } from "~/utils/cookies.server"; +import { sendNotification } from "~/utils/emitter/send-notification.server"; import { makeShelfError, ShelfError } from "~/utils/error"; import { isFormProcessing } from "~/utils/form"; -import { data, error, getParams } from "~/utils/http.server"; +import { + assertIsPost, + data, + error, + getParams, + parseData, +} from "~/utils/http.server"; import { PermissionAction, PermissionEntity, @@ -92,6 +108,42 @@ export async function loader({ context, request, params }: LoaderFunctionArgs) { } } +export async function action({ context, request, params }: ActionFunctionArgs) { + const authSession = context.getSession(); + const { userId } = authSession; + + const { bookingId } = getParams(params, z.object({ bookingId: z.string() })); + + try { + assertIsPost(request); + + const { organizationId } = await requirePermission({ + userId, + request, + entity: PermissionEntity.booking, + action: PermissionAction.update, + }); + + const formData = await request.formData(); + + const { assetIds } = parseData(formData, addScannedAssetsToBookingSchema); + + await addScannedAssetsToBooking({ bookingId, assetIds, organizationId }); + + sendNotification({ + title: "Assets added", + message: "All the scanned assets has been successfully added to booking.", + icon: { name: "success", variant: "success" }, + senderId: authSession.userId, + }); + + return redirect(`/bookings/${bookingId}`); + } catch (cause) { + const reason = makeShelfError(cause, { userId, bookingId }); + return json(error(reason), { status: reason.status }); + } +} + export const meta: MetaFunction = ({ data }) => [ { title: data ? appendToMetaTitle(data.header.title) : "" }, ]; @@ -104,6 +156,9 @@ export default function ScanAssetsForBookings() { const { booking } = useLoaderData(); const [fetchedQrIds, setFetchedQrIds] = useState([]); + const navigation = useNavigation(); + const isLoading = isFormProcessing(navigation.state); + const fetcher = useFetcherWithReset<{ asset: AssetWithBooking }>(); const isFetchingAsset = isFormProcessing(fetcher.state); @@ -157,12 +212,12 @@ export default function ScanAssetsForBookings() { <>
- +
{videoMediaDevices && videoMediaDevices.length > 0 ? ( diff --git a/app/routes/api+/bookings.get-scanned-asset.ts b/app/routes/api+/bookings.get-scanned-asset.ts index 7a5e3f9bb..19de05ee5 100644 --- a/app/routes/api+/bookings.get-scanned-asset.ts +++ b/app/routes/api+/bookings.get-scanned-asset.ts @@ -1,4 +1,3 @@ -import { BookingStatus } from "@prisma/client"; import { json, type ActionFunctionArgs } from "@remix-run/node"; import { z } from "zod"; import { getAsset } from "~/modules/asset/service.server"; @@ -39,98 +38,10 @@ export async function action({ request, context }: ActionFunctionArgs) { organizationId: qr.organizationId, include: { custody: true, - bookings: { - where: { - status: { - notIn: [ - BookingStatus.ARCHIVED, - BookingStatus.CANCELLED, - BookingStatus.COMPLETE, - ], - }, - }, - select: { id: true, status: true }, - }, + bookings: true, }, }); - // const isPartOfCurrentBooking = asset.bookings.some( - // (b) => b.id === bookingId - // ); - - // /** Asset is already added in the booking */ - // if (isPartOfCurrentBooking) { - // throw new ShelfError({ - // cause: null, - // title: "Already added", - // message: "This asset is already added in the current booking.", - // label: "Booking", - // shouldBeCaptured: false, - // }); - // } - - // /** Asset is not available for booking */ - // if (!asset.availableToBook) { - // throw new ShelfError({ - // cause: null, - // title: "Unavailable", - // message: - // "This asset is marked as unavailable for bookings by administrator.", - // label: "Booking", - // shouldBeCaptured: false, - // }); - // } - - // /** Asset is in custody */ - // if (asset.custody) { - // throw new ShelfError({ - // cause: null, - // title: "In custody", - // message: - // "This asset is in custody of team member making it currently unavailable for bookings.", - // label: "Booking", - // shouldBeCaptured: false, - // }); - // } - - // /** Is booked for period */ - // if ( - // asset.bookings.length > 0 && - // asset.bookings.some((b) => b.id !== bookingId) - // ) { - // throw new ShelfError({ - // cause: null, - // title: "Already booked", - // message: - // "This asset is added to a booking that is overlapping the selected time period.", - // label: "Booking", - // shouldBeCaptured: false, - // }); - // } - - // /** If currently checked out */ - // if (asset.status === AssetStatus.CHECKED_OUT) { - // throw new ShelfError({ - // cause: null, - // title: "Checked out", - // message: - // "This asset is currently checked out as part of another booking and should be available for your selected date range period", - // label: "Booking", - // shouldBeCaptured: false, - // }); - // } - - // /** Asset is part of a kit */ - // if (asset.kitId) { - // throw new ShelfError({ - // cause: null, - // title: "Part of kit", - // message: "Remove the asset from the kit to add it individually.", - // label: "Booking", - // shouldBeCaptured: false, - // }); - // } - return json(data({ asset })); } catch (cause) { const reason = makeShelfError(cause, { userId }); From 5b26ab6f61f143f7253dd5bc14ef7a9c973597bb Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Tue, 16 Jul 2024 07:32:31 +0530 Subject: [PATCH 13/53] feat(booking-asset-scan): update style and position of camera selector --- app/components/forms/select.tsx | 14 +++++++--- app/components/zxing-scanner.tsx | 45 +++++++++++++++++++++++--------- 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/app/components/forms/select.tsx b/app/components/forms/select.tsx index 77a481f2e..41dbe5557 100644 --- a/app/components/forms/select.tsx +++ b/app/components/forms/select.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import * as SelectPrimitive from "@radix-ui/react-select"; import { tw } from "~/utils/tw"; import { CheckIcon, ChevronRight } from "../icons/library"; +import When from "../when/when"; const Select = SelectPrimitive.Root; @@ -11,8 +12,13 @@ const SelectValue = SelectPrimitive.Value; const SelectTrigger = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(function SelectTrigger({ className, children, ...props }, ref) { + React.ComponentPropsWithoutRef & { + hideArrow?: boolean; + } +>(function SelectTrigger( + { className, children, hideArrow = false, ...props }, + ref +) { return ( {children} - + + + ); }); diff --git a/app/components/zxing-scanner.tsx b/app/components/zxing-scanner.tsx index f202827fc..9b048e799 100644 --- a/app/components/zxing-scanner.tsx +++ b/app/components/zxing-scanner.tsx @@ -4,6 +4,14 @@ import { useClientNotification } from "~/hooks/use-client-notification"; import type { loader } from "~/routes/_layout+/scanner"; import { ShelfError } from "~/utils/error"; import { isFormProcessing } from "~/utils/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./forms/select"; +import Icon from "./icons/icon"; import { Spinner } from "./shared/spinner"; type ZXingScannerProps = { @@ -88,24 +96,37 @@ export const ZXingScanner = ({ { const form = e.currentTarget; fetcher.submit(form); }} > {videoMediaDevices && videoMediaDevices?.length > 0 ? ( - + ) : null} From a9a6e6ae658d041e192ffde3c6313fb904683a77 Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Tue, 16 Jul 2024 08:16:12 +0530 Subject: [PATCH 14/53] feat(booking-asset-scan): update camera overlay according to figma design --- app/components/booking/scanned-assets-drawer.tsx | 2 +- app/components/zxing-scanner.tsx | 10 ++++------ tailwind.config.ts | 1 + 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/app/components/booking/scanned-assets-drawer.tsx b/app/components/booking/scanned-assets-drawer.tsx index 806c8a003..e25ab2414 100644 --- a/app/components/booking/scanned-assets-drawer.tsx +++ b/app/components/booking/scanned-assets-drawer.tsx @@ -99,7 +99,7 @@ export default function ScannedAssetsDrawer({ 0}> -
+
diff --git a/app/components/zxing-scanner.tsx b/app/components/zxing-scanner.tsx index 9b048e799..0d9b39111 100644 --- a/app/components/zxing-scanner.tsx +++ b/app/components/zxing-scanner.tsx @@ -85,13 +85,8 @@ export const ZXingScanner = ({ controls={false} muted={true} playsInline={true} - className={`pointer-events-none size-full object-cover object-center`} + className="pointer-events-none size-full object-cover object-center" /> -
-
-
-
-
) : null} + + {/* Overlay */} +
)}
diff --git a/tailwind.config.ts b/tailwind.config.ts index 1dacfafae..6de81b05c 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -239,6 +239,7 @@ export default { "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)", "dark-tremor-dropdown": "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)", + "camera-overlay": "0px 0px 0px 2000px rgb(0 0 0 / 0.6)", }, borderRadius: { "tremor-small": "0.375rem", From 9dda2d6d812420c3932a1ddd15a0dc900cdb9c9e Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Wed, 17 Jul 2024 09:01:47 +0530 Subject: [PATCH 15/53] fix(booking): fix pr feedback issues --- app/atoms/notifications.ts | 2 +- app/components/booking/scanned-assets-drawer.tsx | 14 +++++++++++--- app/components/shared/toast.tsx | 10 +++++++--- app/components/zxing-scanner.tsx | 4 ++-- app/hooks/use-client-notification.ts | 2 +- .../_layout+/bookings.$bookingId_.scan-assets.tsx | 6 ------ app/routes/api+/client-notification.ts | 6 +++++- 7 files changed, 27 insertions(+), 17 deletions(-) diff --git a/app/atoms/notifications.ts b/app/atoms/notifications.ts index c08d06190..ac09f55dc 100644 --- a/app/atoms/notifications.ts +++ b/app/atoms/notifications.ts @@ -12,7 +12,7 @@ export type NotificationIcon = { export interface NotificationType { open: boolean; title: string; - message: string; + message?: string | null; icon: NotificationIcon; time?: number; senderId: User["id"] | null; diff --git a/app/components/booking/scanned-assets-drawer.tsx b/app/components/booking/scanned-assets-drawer.tsx index e25ab2414..dd22cf986 100644 --- a/app/components/booking/scanned-assets-drawer.tsx +++ b/app/components/booking/scanned-assets-drawer.tsx @@ -1,3 +1,4 @@ +import { AssetStatus } from "@prisma/client"; import { Form, useLoaderData } from "@remix-run/react"; import { useAtomValue, useSetAtom } from "jotai"; import { useZorm } from "react-zorm"; @@ -53,6 +54,10 @@ export default function ScannedAssetsDrawer({ const removeFetchedScannedAsset = useSetAtom(removeFetchedScannedAssetAtom); const clearFetchedScannedAssets = useSetAtom(clearFetchedScannedAssetsAtom); + const someAssetsCheckedOut = fetchedScannedAssets.some( + (asset) => asset.status === AssetStatus.CHECKED_OUT + ); + return ( @@ -60,7 +65,7 @@ export default function ScannedAssetsDrawer({
@@ -99,7 +104,7 @@ export default function ScannedAssetsDrawer({ 0}> -
+
@@ -182,7 +187,10 @@ export default function ScannedAssetsDrawer({ /> ))} - diff --git a/app/components/shared/toast.tsx b/app/components/shared/toast.tsx index 16449d3d6..1795be4ff 100644 --- a/app/components/shared/toast.tsx +++ b/app/components/shared/toast.tsx @@ -9,6 +9,7 @@ import { } from "~/atoms/notifications"; import { tw } from "~/utils/tw"; import { iconsMap } from "./icons-map"; +import When from "../when/when"; export const Toaster = () => { const [, clearNotification] = useAtom(clearNotificationAtom); @@ -58,9 +59,12 @@ export const Toaster = () => { {title} - - {message} - + + + + {message} + + +
{isProcessing ? (
Switching cameras... @@ -100,7 +100,7 @@ export const ZXingScanner = ({
diff --git a/app/components/zxing-scanner.tsx b/app/components/zxing-scanner.tsx index a24717389..e2e6c441e 100644 --- a/app/components/zxing-scanner.tsx +++ b/app/components/zxing-scanner.tsx @@ -100,7 +100,7 @@ export const ZXingScanner = ({
- - - - - - - {fetchedScannedAssets.map((asset) => ( - - + + + ))} + +
-
-
-
-

- {asset.title} -

- -
- + 0}> +
+ + + + + + + + {fetchedScannedAssets.map((asset) => ( + + - - - ))} - -
+
+
+
+

+ {asset.title} +

+ +
+ a.id === asset.id + ) && !!asset.kitId + } + isAlreadyAdded={booking.assets.some( (a) => a.id === asset.id - ) && !!asset.kitId - } - isAlreadyAdded={booking.assets.some( - (a) => a.id === asset.id - )} - showKitStatus - asset={asset} - isCheckedOut={asset.status === "CHECKED_OUT"} - /> + )} + showKitStatus + asset={asset} + isCheckedOut={ + asset.status === "CHECKED_OUT" + } + /> +
- -
-
-
-
- - 0}> -
- -

- {zo.errors.assetIds()?.message} -

-
+
+
+
+
+ 0}> +
+ +

+ {zo.errors.assetIds()?.message} +

+
-
-
- -
+
+
+ +
+ +
+ {fetchedScannedAssets.map((asset, i) => ( + + ))} - - {fetchedScannedAssets.map((asset, i) => ( - - ))} - - -
+ + +
-
-
+ +
-
-
+ + ); } From e2e1b498b7910d36b4be2234ad0355a7979e4664 Mon Sep 17 00:00:00 2001 From: Donkoko Date: Tue, 3 Sep 2024 19:05:02 +0300 Subject: [PATCH 33/53] making more changes to custom drawer --- .../booking/scanned-assets-drawer.tsx | 217 ++++++++++-------- 1 file changed, 116 insertions(+), 101 deletions(-) diff --git a/app/components/booking/scanned-assets-drawer.tsx b/app/components/booking/scanned-assets-drawer.tsx index f19af0925..827f55f3c 100644 --- a/app/components/booking/scanned-assets-drawer.tsx +++ b/app/components/booking/scanned-assets-drawer.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { AssetStatus } from "@prisma/client"; import { Form, useLoaderData } from "@remix-run/react"; import { motion } from "framer-motion"; @@ -60,6 +60,7 @@ export default function ScannedAssetsDrawer({ const removeFetchedScannedAsset = useSetAtom(removeFetchedScannedAssetAtom); const clearFetchedScannedAssets = useSetAtom(clearFetchedScannedAssetsAtom); + const hasAssets = fetchedScannedAssetsCount > 0; const displayQrNotification = useSetAtom(displayQrScannerNotificationAtom); const someAssetsCheckedOut = fetchedScannedAssets.some( @@ -69,42 +70,52 @@ export default function ScannedAssetsDrawer({ (asset) => asset.status === AssetStatus.IN_CUSTODY ); + // useEffect(() => { + // if (document) { + // document.body.style.overflow = expanded ? "hidden" : "auto"; + // document.body.style.height = expanded ? "100vh" : "auto"; + // } + // }, [expanded]); + // Handler for the drag end event return ( - { - const shouldExpand = info.offset.y < 0; - setExpanded(shouldExpand); + height: expanded ? vh - TOP_GAP : hasAssets ? 170 : 148, }} > -
+
Add assets to booking via scan
-
-
+ {/* Handle */} + { setExpanded((prev) => !prev); }} + drag="y" + dragConstraints={{ top: 0, bottom: 0 }} + onDragEnd={(_, info) => { + const shouldExpand = info.offset.y < 0; + setExpanded(shouldExpand); + }} > {/* Drag me */} - -
-
+
+ + + {/* Header */} +
{fetchedScannedAssetsCount} assets scanned
- 0}> +
- + + {/* Body */} +
{expanded && (
@@ -136,110 +149,112 @@ export default function ScannedAssetsDrawer({
- 0}> -
- - - - - + +
+ {/* Assets list */} +
+
+ + + + - - {fetchedScannedAssets.map((asset) => ( - - + {fetchedScannedAssets.map((asset) => ( + + - - - ))} - -
-
-
-
-

- {asset.title} -

+
+
+
+
+

+ {asset.title} +

-
- + a.id === asset.id + ) && !!asset.kitId + } + isAlreadyAdded={booking.assets.some( (a) => a.id === asset.id - ) && !!asset.kitId - } - isAlreadyAdded={booking.assets.some( - (a) => a.id === asset.id - )} - showKitStatus - asset={asset} - isCheckedOut={ - asset.status === "CHECKED_OUT" - } - /> + )} + showKitStatus + asset={asset} + isCheckedOut={ + asset.status === "CHECKED_OUT" + } + /> +
- -
-
-
-
- 0}> -
- -

- {zo.errors.assetIds()?.message} -

-
+ + +
+ {/* Actions */} +
+ +

+ {zo.errors.assetIds()?.message} +

+
-
-
+
-
-
- {fetchedScannedAssets.map((asset, i) => ( - - ))} + + {fetchedScannedAssets.map((asset, i) => ( + + ))} - -
+ + +
- +
); } @@ -247,7 +262,7 @@ export default function ScannedAssetsDrawer({ function Tr({ children }: { children: React.ReactNode }) { return ( Date: Wed, 4 Sep 2024 14:54:12 +0300 Subject: [PATCH 34/53] reworking how assets are added to list and fetch to improve performance --- app/atoms/qr-scanner.ts | 16 +- .../drawer.tsx} | 183 ++++++++++-------- .../zxing-scanner/zxing-scanner.tsx | 23 +-- .../bookings.$bookingId_.scan-assets.tsx | 48 ++--- app/styles/global.css | 14 ++ 5 files changed, 151 insertions(+), 133 deletions(-) rename app/components/{booking/scanned-assets-drawer.tsx => scanner/drawer.tsx} (63%) diff --git a/app/atoms/qr-scanner.ts b/app/atoms/qr-scanner.ts index 4e8d82a10..aa45dc83a 100644 --- a/app/atoms/qr-scanner.ts +++ b/app/atoms/qr-scanner.ts @@ -11,7 +11,13 @@ export const scannedQrIdsAtom = atom([]); export const addScannedQrIdAtom = atom( null, (_, set, update) => { - set(scannedQrIdsAtom, (prev) => [...prev, update]); + set(scannedQrIdsAtom, (prev) => { + if (!prev.includes(update)) { + return [...prev, update]; + } else { + return prev; + } + }); } ); @@ -23,6 +29,14 @@ export const removeScannedQrIdAtom = atom( } ); +/** Clears the IDs */ +export const clearScannedQrIdsAtom = atom( + null, + (_, set) => { + set(scannedQrIdsAtom, []); + } +); + /**************************** * QR Scanner Notification * ****************************/ diff --git a/app/components/booking/scanned-assets-drawer.tsx b/app/components/scanner/drawer.tsx similarity index 63% rename from app/components/booking/scanned-assets-drawer.tsx rename to app/components/scanner/drawer.tsx index 827f55f3c..6765f7ba5 100644 --- a/app/components/booking/scanned-assets-drawer.tsx +++ b/app/components/scanner/drawer.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { AssetStatus } from "@prisma/client"; import { Form, useLoaderData } from "@remix-run/react"; import { motion } from "framer-motion"; @@ -12,11 +12,17 @@ import { fetchedScannedAssetsCountAtom, removeFetchedScannedAssetAtom, } from "~/atoms/bookings"; -import { displayQrScannerNotificationAtom } from "~/atoms/qr-scanner"; +import { + clearScannedQrIdsAtom, + displayQrScannerNotificationAtom, + removeScannedQrIdAtom, + scannedQrIdsAtom, +} from "~/atoms/qr-scanner"; import { useViewportHeight } from "~/hooks/use-viewport-height"; +import type { AssetWithBooking } from "~/routes/_layout+/bookings.$bookingId.add-assets"; import { type loader } from "~/routes/_layout+/bookings.$bookingId_.scan-assets"; import { tw } from "~/utils/tw"; -import { AvailabilityLabel } from "./availability-label"; +import { AvailabilityLabel } from "../booking/availability-label"; import { AssetLabel } from "../icons/library"; import { ListHeader } from "../list/list-header"; import { Button } from "../shared/button"; @@ -46,36 +52,20 @@ export default function ScannedAssetsDrawer({ isLoading, }: ScannedAssetsDrawerProps) { const { booking } = useLoaderData(); - const zo = useZorm( "AddScannedAssetsToBooking", addScannedAssetsToBookingSchema ); + const qrIds = useAtomValue(scannedQrIdsAtom); + const assetsLength = qrIds.length; + const hasAssets = assetsLength > 0; + const clearList = useSetAtom(clearScannedQrIdsAtom); + const [expanded, setExpanded] = useState(false); const { vh } = useViewportHeight(); - const fetchedScannedAssets = useAtomValue(fetchedScannedAssetsAtom); - const fetchedScannedAssetsCount = useAtomValue(fetchedScannedAssetsCountAtom); - const removeFetchedScannedAsset = useSetAtom(removeFetchedScannedAssetAtom); - const clearFetchedScannedAssets = useSetAtom(clearFetchedScannedAssetsAtom); - - const hasAssets = fetchedScannedAssetsCount > 0; - const displayQrNotification = useSetAtom(displayQrScannerNotificationAtom); - - const someAssetsCheckedOut = fetchedScannedAssets.some( - (asset) => asset.status === AssetStatus.CHECKED_OUT - ); - const someAssetsInCustody = fetchedScannedAssets.some( - (asset) => asset.status === AssetStatus.IN_CUSTODY - ); - - // useEffect(() => { - // if (document) { - // document.body.style.overflow = expanded ? "hidden" : "auto"; - // document.body.style.height = expanded ? "100vh" : "auto"; - // } - // }, [expanded]); + // const displayQrNotification = useSetAtom(displayQrScannerNotificationAtom); // Handler for the drag end event return ( @@ -111,17 +101,12 @@ export default function ScannedAssetsDrawer({ {/* Header */}
-
- {fetchedScannedAssetsCount} assets scanned -
+
{assetsLength} assets scanned
-
+
+
@@ -160,55 +145,13 @@ export default function ScannedAssetsDrawer({ - {fetchedScannedAssets.map((asset) => ( - - -
-
-
-

- {asset.title} -

- -
- a.id === asset.id - ) && !!asset.kitId - } - isAlreadyAdded={booking.assets.some( - (a) => a.id === asset.id - )} - showKitStatus - asset={asset} - isCheckedOut={ - asset.status === "CHECKED_OUT" - } - /> -
-
-
-
- - -
+ {/* Actions */}
@@ -226,7 +169,7 @@ export default function ScannedAssetsDrawer({ > Close - + {/*
{fetchedScannedAssets.map((asset, i) => ( Confirm -
+ */}
@@ -259,6 +202,82 @@ export default function ScannedAssetsDrawer({ ); } +function AssetRow({ qrId, booking }: { qrId: string; booking: any }) { + const [asset, setAsset] = useState(undefined); + const removeAsset = useSetAtom(removeScannedQrIdAtom); + + const isCheckedOut = useMemo( + () => asset?.status === AssetStatus.CHECKED_OUT, + [asset] + ); + + useEffect(() => { + async function fetchAsset() { + const response = await fetch( + `/api/bookings/get-scanned-asset?qrId=${qrId}&bookingId=${booking.id}` + ); + const { asset } = await response.json(); + setAsset(asset); + } + void fetchAsset(); + }, [qrId, booking.id]); + + return ( + + +
+
+
+ {!asset ? ( +
+

+ QR id: {qrId} +

{" "} + +
+ ) : ( + <> +

+ {asset.title} +

+ +
+ a.id === asset.id) && + !!asset.kitId + } + isAlreadyAdded={booking.assets.some( + (a) => a.id === asset.id + )} + showKitStatus + asset={asset} + isCheckedOut={isCheckedOut} + /> +
+ + )} +
+
+
+ + + - {/* - - {fetchedScannedAssets.map((asset, i) => ( - - ))} + {someAssetsCheckedOut && ( +

+ Some assets in your list are checked out. You need to + resolve that before continuing. +

+ )} + {someAssetsInCustody && ( +

+ Some assets in your list are in custody. You need to + resolve that before continuing. +

+ )} +
+ + - */} +
-
+
@@ -202,25 +219,60 @@ export default function ScannedAssetsDrawer({ ); } -function AssetRow({ qrId, booking }: { qrId: string; booking: any }) { - const [asset, setAsset] = useState(undefined); - const removeAsset = useSetAtom(removeScannedQrIdAtom); +function AssetRow({ + qrId, + assets, + setAssets, + index, +}: { + qrId: string; + booking: any; + index: number; + assets: AssetWithBooking[]; + setAssets: React.Dispatch>; +}) { + const { booking } = useLoaderData(); + + const removeQrId = useSetAtom(removeScannedQrIdAtom); + + /** Find the asset in the assets array */ + const asset = useMemo( + () => assets.find((a) => a.qrScanned === qrId), + [assets, qrId] + ); const isCheckedOut = useMemo( () => asset?.status === AssetStatus.CHECKED_OUT, [asset] ); + /** Adds an asset to the assets array */ + const setAsset = useCallback( + (asset: AssetWithBooking) => { + setAssets((prev) => { + /** Only add it it doesnt exist in the list already */ + if (prev.some((a) => a.id === asset.id)) { + return prev; + } + return [...prev, asset]; + }); + }, + [setAssets] + ); + + /** Fetches asset data based on qrId */ + const fetchAsset = useCallback(async () => { + const response = await fetch( + `/api/bookings/get-scanned-asset?qrId=${qrId}&bookingId=${booking.id}` + ); + const { asset } = await response.json(); + setAsset(asset); + }, [qrId, booking.id, setAsset]); + + /** Fetch the asset when qrId or booking changes */ useEffect(() => { - async function fetchAsset() { - const response = await fetch( - `/api/bookings/get-scanned-asset?qrId=${qrId}&bookingId=${booking.id}` - ); - const { asset } = await response.json(); - setAsset(asset); - } void fetchAsset(); - }, [qrId, booking.id]); + }, [qrId, booking.id, setAsset, fetchAsset]); return ( @@ -240,6 +292,11 @@ function AssetRow({ qrId, booking }: { qrId: string; booking: any }) {
) : ( <> +

{asset.title}

@@ -270,7 +327,7 @@ function AssetRow({ qrId, booking }: { qrId: string; booking: any }) { variant="ghost" icon="trash" onClick={() => { - removeAsset(qrId); + removeQrId(qrId); }} /> diff --git a/app/modules/booking/service.server.ts b/app/modules/booking/service.server.ts index 8f158fc1f..74efe404c 100644 --- a/app/modules/booking/service.server.ts +++ b/app/modules/booking/service.server.ts @@ -1505,69 +1505,9 @@ export async function addScannedAssetsToBooking({ where: { id: bookingId, organizationId }, }); - /** We have to make sure all the assets are available for booking */ - const { - hasAlreadyBookedAssets, - hasUnavailableAssets, - hasAssetsInCustody, - hasCheckedOutAssets, - hasKits, - } = await getBookingFlags({ ...booking, assetIds }); - - if (hasAlreadyBookedAssets) { - throw new ShelfError({ - cause: null, - title: "Already booked", - message: - "Some assets are already added in the current booking. Please make sure you have scanned only available assets.", - label: "Booking", - shouldBeCaptured: false, - }); - } - - if (hasUnavailableAssets) { - throw new ShelfError({ - cause: null, - title: "Unavailable", - message: - "Some assets are marked as unavailable for bookings by administrator. Please make sure you have scanned only available assets.", - label: "Booking", - shouldBeCaptured: false, - }); - } - - if (hasAssetsInCustody) { - throw new ShelfError({ - cause: null, - title: "In custody", - message: - "Some assets are in custody of team member making it currently unavailable for bookings. Please make sure you have scanned only available assets.", - label: "Booking", - shouldBeCaptured: false, - }); - } - - if (hasCheckedOutAssets) { - throw new ShelfError({ - cause: null, - title: "Checked out", - message: - "Some assets are currently checked out as part of another booking and should be available for your selected date range period.", - label: "Booking", - shouldBeCaptured: false, - }); - } - - if (hasKits) { - throw new ShelfError({ - cause: null, - title: "Part of kit", - message: - "Some assets are part of kit. Remove the asset from the kit to add it individually.", - label: "Booking", - shouldBeCaptured: false, - }); - } + /** We just add all the assets to the booking, and let the user manage the list on the booking page. + * If there are already checked out or in custody assets, the user wont be able to check out + */ /** Adding assets into booking */ return await db.$transaction(async (tx) => { From 2b4d2098be996b3dc79fc55eb13741363c5bce3d Mon Sep 17 00:00:00 2001 From: Donkoko Date: Wed, 4 Sep 2024 17:24:18 +0300 Subject: [PATCH 36/53] changing order of list, adding some more details, removing unused drawer package --- app/atoms/qr-scanner.ts | 3 +- .../booking/booking-assets-column.tsx | 5 +- app/components/scanner/drawer.tsx | 46 +++---- app/components/shared/drawer.tsx | 115 ------------------ .../bookings.$bookingId_.scan-assets.tsx | 6 +- package-lock.json | 13 -- package.json | 1 - 7 files changed, 21 insertions(+), 168 deletions(-) delete mode 100644 app/components/shared/drawer.tsx diff --git a/app/atoms/qr-scanner.ts b/app/atoms/qr-scanner.ts index aa45dc83a..2e5c6e5fb 100644 --- a/app/atoms/qr-scanner.ts +++ b/app/atoms/qr-scanner.ts @@ -13,7 +13,8 @@ export const addScannedQrIdAtom = atom( (_, set, update) => { set(scannedQrIdsAtom, (prev) => { if (!prev.includes(update)) { - return [...prev, update]; + /** Notice we add the new item to start of array. This is for showing the proper order in the drawer component */ + return [update, ...prev]; } else { return prev; } diff --git a/app/components/booking/booking-assets-column.tsx b/app/components/booking/booking-assets-column.tsx index 0914dafe7..d897c2564 100644 --- a/app/components/booking/booking-assets-column.tsx +++ b/app/components/booking/booking-assets-column.tsx @@ -6,7 +6,6 @@ import { useBookingStatusHelpers } from "~/hooks/use-booking-status"; import { useUserRoleHelper } from "~/hooks/user-user-role-helper"; import type { BookingWithCustodians } from "~/routes/_layout+/bookings"; import type { AssetWithBooking } from "~/routes/_layout+/bookings.$bookingId.add-assets"; -import { canUserManageBookingAssets } from "~/utils/bookings"; import { groupBy } from "~/utils/utils"; import { AssetRowActionsDropdown } from "./asset-row-actions-dropdown"; import { AvailabilityLabel } from "./availability-label"; @@ -118,7 +117,7 @@ export function BookingAssetsColumn() {
-
+
{!hasItems ? ( ) : ( <> - +
diff --git a/app/components/scanner/drawer.tsx b/app/components/scanner/drawer.tsx index fb677fbe7..38f130033 100644 --- a/app/components/scanner/drawer.tsx +++ b/app/components/scanner/drawer.tsx @@ -60,14 +60,16 @@ export default function ScannedAssetsDrawer({ const { vh } = useViewportHeight(); const [assets, setAssets] = useState([]); - const someAssetsCheckedOut = useMemo( - () => assets.some((asset) => asset.status === AssetStatus.CHECKED_OUT), - [assets] - ); - const someAssetsInCustody = useMemo( - () => assets.some((asset) => asset.status === AssetStatus.IN_CUSTODY), - [assets] + /** + * Clear the list when the component is unmounted + */ + useEffect( + () => () => { + clearList(); + }, + [clearList] ); + return (
+
- {qrIds.map((id, index) => ( + {qrIds.reverse().map((id, index) => ( - {someAssetsCheckedOut && ( -

- Some assets in your list are checked out. You need to - resolve that before continuing. -

- )} - - {someAssetsInCustody && ( -

- Some assets in your list are in custody. You need to - resolve that before continuing. -

- )}
@@ -237,7 +219,7 @@ function AssetRow({ /** Find the asset in the assets array */ const asset = useMemo( - () => assets.find((a) => a.qrScanned === qrId), + () => assets.find((a) => a?.qrScanned === qrId), [assets, qrId] ); @@ -251,7 +233,7 @@ function AssetRow({ (asset: AssetWithBooking) => { setAssets((prev) => { /** Only add it it doesnt exist in the list already */ - if (prev.some((a) => a.id === asset.id)) { + if (asset && prev.some((a) => a && a.id === asset.id)) { return prev; } return [...prev, asset]; diff --git a/app/components/shared/drawer.tsx b/app/components/shared/drawer.tsx deleted file mode 100644 index cca8aece9..000000000 --- a/app/components/shared/drawer.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { forwardRef } from "react"; -import { Drawer as DrawerPrimitive } from "vaul"; -import { tw } from "~/utils/tw"; - -const Drawer = ({ - shouldScaleBackground = true, - ...props -}: React.ComponentProps) => ( - -); -Drawer.displayName = "Drawer"; - -const DrawerTrigger = DrawerPrimitive.Trigger; - -const DrawerPortal = DrawerPrimitive.Portal; - -const DrawerClose = DrawerPrimitive.Close; - -const DrawerOverlay = forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName; - -const DrawerContent = forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - -
- {children} - - -)); -DrawerContent.displayName = "DrawerContent"; - -const DrawerHeader = ({ - className, - ...props -}: React.HTMLAttributes) => ( -
-); -DrawerHeader.displayName = "DrawerHeader"; - -const DrawerFooter = ({ - className, - ...props -}: React.HTMLAttributes) => ( -
-); -DrawerFooter.displayName = "DrawerFooter"; - -const DrawerTitle = forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DrawerTitle.displayName = DrawerPrimitive.Title.displayName; - -const DrawerDescription = forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DrawerDescription.displayName = DrawerPrimitive.Description.displayName; - -export { - Drawer, - DrawerPortal, - DrawerOverlay, - DrawerTrigger, - DrawerClose, - DrawerContent, - DrawerHeader, - DrawerFooter, - DrawerTitle, - DrawerDescription, -}; diff --git a/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx b/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx index d77024d41..da840f5a9 100644 --- a/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx +++ b/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx @@ -5,10 +5,10 @@ import type { LoaderFunctionArgs, ActionFunctionArgs, } from "@remix-run/node"; -import { useLoaderData, useNavigation } from "@remix-run/react"; -import { useAtomValue, useSetAtom } from "jotai"; +import { useNavigation } from "@remix-run/react"; +import { useSetAtom } from "jotai"; import { z } from "zod"; -import { addScannedQrIdAtom, scannedQrIdsAtom } from "~/atoms/qr-scanner"; +import { addScannedQrIdAtom } from "~/atoms/qr-scanner"; import Header from "~/components/layout/header"; import type { HeaderData } from "~/components/layout/header/types"; import ScannedAssetsDrawer, { diff --git a/package-lock.json b/package-lock.json index 82a8a94a5..ae6cf7fdb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -79,7 +79,6 @@ "tailwindcss-animate": "^1.0.7", "tiny-invariant": "^1.3.1", "ua-parser-js": "^1.0.37", - "vaul": "^0.9.1", "zod": "^3.22.4" }, "devDependencies": { @@ -22219,18 +22218,6 @@ "node": ">= 0.8" } }, - "node_modules/vaul": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/vaul/-/vaul-0.9.1.tgz", - "integrity": "sha512-fAhd7i4RNMinx+WEm6pF3nOl78DFkAazcN04ElLPFF9BMCNGbY/kou8UMhIcicm0rJCNePJP0Yyza60gGOD0Jw==", - "dependencies": { - "@radix-ui/react-dialog": "^1.0.4" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - } - }, "node_modules/vfile": { "version": "5.3.7", "resolved": "https://registry.npmjs.org/vfile/-/vfile-5.3.7.tgz", diff --git a/package.json b/package.json index c2db3bee7..6826123d7 100644 --- a/package.json +++ b/package.json @@ -110,7 +110,6 @@ "tailwindcss-animate": "^1.0.7", "tiny-invariant": "^1.3.1", "ua-parser-js": "^1.0.37", - "vaul": "^0.9.1", "zod": "^3.22.4" }, "devDependencies": { From e18ceb7b38b93689cf0d2147e39e3470ee46989b Mon Sep 17 00:00:00 2001 From: Donkoko Date: Wed, 4 Sep 2024 19:15:08 +0300 Subject: [PATCH 37/53] more UX improvements to scanning process --- app/components/booking/availability-label.tsx | 2 +- app/components/scanner/drawer.tsx | 56 +++++++++++++++++-- .../bookings.$bookingId_.scan-assets.tsx | 2 +- 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/app/components/booking/availability-label.tsx b/app/components/booking/availability-label.tsx index 8d9a9e1be..15c5139df 100644 --- a/app/components/booking/availability-label.tsx +++ b/app/components/booking/availability-label.tsx @@ -41,7 +41,7 @@ export function AvailabilityLabel({ if (isAlreadyAdded) { return ( diff --git a/app/components/scanner/drawer.tsx b/app/components/scanner/drawer.tsx index 38f130033..7ebc6ceee 100644 --- a/app/components/scanner/drawer.tsx +++ b/app/components/scanner/drawer.tsx @@ -52,6 +52,7 @@ export default function ScannedAssetsDrawer({ // Get the scanned qrIds const qrIds = useAtomValue(scannedQrIdsAtom); + const removeQrId = useSetAtom(removeScannedQrIdAtom); const assetsLength = qrIds.length; const hasAssets = assetsLength > 0; const clearList = useSetAtom(clearScannedQrIdsAtom); @@ -70,6 +71,15 @@ export default function ScannedAssetsDrawer({ [clearList] ); + /** + * Check which of tha assets are already added in the booking.assets + */ + const assetsAlreadyAdded = assets.filter((asset) => + booking.assets.some((a) => a?.id === asset?.id) + ); + + const hasAssetsAlreadyAdded = assetsAlreadyAdded.length > 0; + return (
+ +
+

+ {assetsAlreadyAdded.length} assets + already added to the booking.{" "} + {" "} + to continue. +

+
+
+

{zo.errors.assetIds()?.message} @@ -186,7 +225,11 @@ export default function ScannedAssetsDrawer({ Close -

@@ -216,6 +259,13 @@ function AssetRow({ const { booking } = useLoaderData(); const removeQrId = useSetAtom(removeScannedQrIdAtom); + /** Remove the asset from the list */ + function removeAssetFromList() { + // Remive the qrId from the list + removeQrId(qrId); + // Remove the asset from the list + setAssets((prev) => prev.filter((a) => a?.qrScanned !== qrId)); + } /** Find the asset in the assets array */ const asset = useMemo( @@ -308,9 +358,7 @@ function AssetRow({ className="border-none" variant="ghost" icon="trash" - onClick={() => { - removeQrId(qrId); - }} + onClick={removeAssetFromList} /> diff --git a/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx b/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx index da840f5a9..cfd7d91e4 100644 --- a/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx +++ b/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx @@ -5,7 +5,7 @@ import type { LoaderFunctionArgs, ActionFunctionArgs, } from "@remix-run/node"; -import { useNavigation } from "@remix-run/react"; +import { useLoaderData, useNavigation } from "@remix-run/react"; import { useSetAtom } from "jotai"; import { z } from "zod"; import { addScannedQrIdAtom } from "~/atoms/qr-scanner"; From 37f02b52e29faffafc01184d09220baba947906f Mon Sep 17 00:00:00 2001 From: Donkoko Date: Wed, 4 Sep 2024 19:19:15 +0300 Subject: [PATCH 38/53] small improvements to code --- app/components/scanner/drawer.tsx | 47 ++++++++++++++++--------------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/app/components/scanner/drawer.tsx b/app/components/scanner/drawer.tsx index 7ebc6ceee..2a2b7421a 100644 --- a/app/components/scanner/drawer.tsx +++ b/app/components/scanner/drawer.tsx @@ -57,19 +57,32 @@ export default function ScannedAssetsDrawer({ const hasAssets = assetsLength > 0; const clearList = useSetAtom(clearScannedQrIdsAtom); + /** Removes an set of assets from the list + * Handles both the qrIds and the assets array + */ + function removeAssetsFromList(assets: AssetWithBooking[]) { + setAssets((prev) => + prev.filter((a) => !assets.some((aa) => aa?.id === a?.id)) + ); + assets.forEach((a) => { + removeQrId(a?.qrScanned); + }); + } + const [expanded, setExpanded] = useState(false); const { vh } = useViewportHeight(); const [assets, setAssets] = useState([]); - /** - * Clear the list when the component is unmounted - */ - useEffect( - () => () => { - clearList(); - }, - [clearList] - ); + // /** + // * Clear the list when the component is unmounted + // */ + // useEffect( + // () => () => { + // clearList(); + // setAssets([]); + // }, + // [clearList] + // ); /** * Check which of tha assets are already added in the booking.assets @@ -188,19 +201,9 @@ export default function ScannedAssetsDrawer({ {" "} From 6a18b3fd86805e441501fbff7e88cdfa35fe3e92 Mon Sep 17 00:00:00 2001 From: Donkoko Date: Thu, 5 Sep 2024 13:08:17 +0300 Subject: [PATCH 39/53] further updating UI and UX --- app/atoms/bookings.ts | 77 ----- app/atoms/qr-scanner.ts | 90 ++++-- app/components/layout/header/types.ts | 2 + app/components/list/index.tsx | 3 +- app/components/list/list-header.tsx | 4 +- app/components/scanner/drawer.tsx | 291 +++++++++--------- app/components/shared/button.tsx | 7 + .../zxing-scanner/zxing-scanner.tsx | 2 - .../bookings.$bookingId_.scan-assets.tsx | 14 +- 9 files changed, 241 insertions(+), 249 deletions(-) delete mode 100644 app/atoms/bookings.ts diff --git a/app/atoms/bookings.ts b/app/atoms/bookings.ts deleted file mode 100644 index 9188bb998..000000000 --- a/app/atoms/bookings.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { atom } from "jotai"; -import type { AssetWithBooking } from "~/routes/_layout+/bookings.$bookingId.add-assets"; -import { ShelfError } from "~/utils/error"; -import { scannedQrIdsAtom } from "./qr-scanner"; - -/** This atom keeps track of the assets fetched after scanning. */ -export const fetchedScannedAssetsAtom = atom([]); - -/** This atom keeps track of the total fetchedScanned assets */ -export const fetchedScannedAssetsCountAtom = atom( - (get) => get(fetchedScannedAssetsAtom).length -); - -/** - * This atom is used to set multiple assets into fetchedScannedAssetAtom. - * This will replace new items with existing ones. - * */ -export const setFetchedScannedAssetsAtom = atom< - null, - AssetWithBooking[][], - unknown ->(null, (_, set, update) => { - set(fetchedScannedAssetsAtom, update); -}); - -/** - * This atom is used to set a single asset into fetchedScannedAssetAtom - * If `update` asset is already added then it will not be added again in the array. - * */ -export const setFetchedScannedAssetAtom = atom< - null, - AssetWithBooking[], - unknown ->(null, (_, set, update) => { - set(fetchedScannedAssetsAtom, (prev) => [ - ...prev, - ...(prev.some((a) => a.id === update.id) ? [] : [update]), - ]); -}); - -/** - * This atom is used to remove an asset from the list using the `id` of asset. - */ -export const removeFetchedScannedAssetAtom = atom( - null, - (get, set, update) => { - const removedAsset = get(fetchedScannedAssetsAtom).find( - (asset) => asset.id === update - ); - - /** This case should not happen */ - if (!removedAsset) { - throw new ShelfError({ - cause: null, - message: "Asset not found", - label: "Booking", - }); - } - - set(fetchedScannedAssetsAtom, (prev) => - prev.filter((asset) => asset.id !== update) - ); - - /** If an asset is removed from the list then we also have to remove the qr of that asset, so user can refetch it */ - set(scannedQrIdsAtom, (prev) => - prev.filter((qr) => qr !== removedAsset.qrScanned) - ); - } -); - -/** This atom clears all the items in fetchedScannedAssetsAtom */ -export const clearFetchedScannedAssetsAtom = atom(null, (_, set) => { - set(fetchedScannedAssetsAtom, []); - - // If we are clearing the atom from list then we also have to remove scanned qrIds so that user can scan them again - set(scannedQrIdsAtom, []); -}); diff --git a/app/atoms/qr-scanner.ts b/app/atoms/qr-scanner.ts index 2e5c6e5fb..47b3ac21b 100644 --- a/app/atoms/qr-scanner.ts +++ b/app/atoms/qr-scanner.ts @@ -1,43 +1,93 @@ import { atom } from "jotai"; +import type { AssetWithBooking } from "~/routes/_layout+/bookings.$bookingId.add-assets"; /*********************** * Scanned QR Id Atom * + * + * The data is structured in a object where: + * - key: qrId + * - value: asset + * ***********************/ -/** This atom keeps track of the qrIds scanned */ -export const scannedQrIdsAtom = atom([]); +export const scannedItemsAtom = atom<{ + [key: string]: AssetWithBooking | undefined; +}>({}); -/** This atom adds a qrId in scannedQrIdsAtom */ -export const addScannedQrIdAtom = atom( +/** Get an array of the scanned items ids */ +export const scannedItemsIdsAtom = atom((get) => + Object.values(get(scannedItemsAtom)).map((item) => item?.id) +); + +// Add item to object with value `undefined` (just receives the key) +export const addScannedItemAtom = atom(null, (get, set, qrId: string) => { + const currentItems = get(scannedItemsAtom); + set(scannedItemsAtom, { + [qrId]: undefined, // Add the new entry at the start + ...currentItems, // Spread the rest of the existing items + }); +}); + +// Update item based on key +export const updateScannedItemAtom = atom( null, - (_, set, update) => { - set(scannedQrIdsAtom, (prev) => { - if (!prev.includes(update)) { - /** Notice we add the new item to start of array. This is for showing the proper order in the drawer component */ - return [update, ...prev]; - } else { - return prev; - } + (get, set, { qrId, asset }: { qrId: string; asset: AssetWithBooking }) => { + const currentItems = get(scannedItemsAtom); + + // Check if the item already exists; if it does, skip the update + if (currentItems[qrId]) { + return; // Skip the update if the item is already present + } + + set(scannedItemsAtom, { + ...currentItems, + [qrId]: asset, }); } ); -/** This atom is used to remove a qrId from scannedQrIdsAtom */ -export const removeScannedQrIdAtom = atom( +// Remove item based on key +export const removeScannedItemAtom = atom(null, (get, set, qrId: string) => { + const currentItems = get(scannedItemsAtom); + const { [qrId]: _, ...rest } = currentItems; // Removes the key + set(scannedItemsAtom, rest); +}); + +// Remove multiple items based on key array +export const removeMultipleScannedItemsAtom = atom( null, - (_, set, update) => { - set(scannedQrIdsAtom, (prev) => prev.filter((qr) => qr !== update)); + (get, set, qrIds: string[]) => { + const currentItems = get(scannedItemsAtom); + const updatedItems = { ...currentItems }; + qrIds.forEach((qrId) => { + delete updatedItems[qrId]; + }); + set(scannedItemsAtom, updatedItems); } ); -/** Clears the IDs */ -export const clearScannedQrIdsAtom = atom( +// Remove items based on asset id +export const removeScannedItemsByAssetIdAtom = atom( null, - (_, set) => { - set(scannedQrIdsAtom, []); + (get, set, assetIds: string[]) => { + const currentItems = get(scannedItemsAtom); + const updatedItems = { ...currentItems }; + Object.entries(currentItems).forEach(([qrId, asset]) => { + if (asset && assetIds.includes(asset?.id)) { + delete updatedItems[qrId]; + } + }); + set(scannedItemsAtom, updatedItems); } ); +// Clear all items +export const clearScannedItemsAtom = atom(null, (_get, set) => { + set(scannedItemsAtom, {}); // Resets the atom to an empty object +}); + +/*******************************/ + /**************************** * QR Scanner Notification * ****************************/ diff --git a/app/components/layout/header/types.ts b/app/components/layout/header/types.ts index 58de9e234..88291cdeb 100644 --- a/app/components/layout/header/types.ts +++ b/app/components/layout/header/types.ts @@ -50,6 +50,8 @@ export type ButtonVariant = | "secondary" | "tertiary" | "link" + | "block-link" + | "block-link-gray" | "danger"; /** Width of the button. Default is auto */ diff --git a/app/components/list/index.tsx b/app/components/list/index.tsx index 4c239c724..fa8eaa928 100644 --- a/app/components/list/index.tsx +++ b/app/components/list/index.tsx @@ -170,8 +170,7 @@ export const List = React.forwardRef(function List( selectedBulkItemsCount < totalItems && ( diff --git a/app/components/list/list-header.tsx b/app/components/list/list-header.tsx index ed5b0290d..c2c319af1 100644 --- a/app/components/list/list-header.tsx +++ b/app/components/list/list-header.tsx @@ -8,14 +8,16 @@ type ListHeaderProps = { hideFirstColumn?: boolean; bulkActions?: ListProps["bulkActions"]; title?: string; + className?: string; }; export const ListHeader = ({ children, hideFirstColumn = false, bulkActions, + className, }: ListHeaderProps) => ( -
+ {bulkActions ? : null} {hideFirstColumn ? null : ( diff --git a/app/components/scanner/drawer.tsx b/app/components/scanner/drawer.tsx index 2a2b7421a..89e25f053 100644 --- a/app/components/scanner/drawer.tsx +++ b/app/components/scanner/drawer.tsx @@ -7,9 +7,12 @@ import { createPortal } from "react-dom"; import { useZorm } from "react-zorm"; import { z } from "zod"; import { - clearScannedQrIdsAtom, - removeScannedQrIdAtom, - scannedQrIdsAtom, + clearScannedItemsAtom, + removeScannedItemAtom, + removeScannedItemsByAssetIdAtom, + scannedItemsAtom, + scannedItemsIdsAtom, + updateScannedItemAtom, } from "~/atoms/qr-scanner"; import { useViewportHeight } from "~/hooks/use-viewport-height"; import type { AssetWithBooking } from "~/routes/_layout+/bookings.$bookingId.add-assets"; @@ -51,48 +54,40 @@ export default function ScannedAssetsDrawer({ ); // Get the scanned qrIds - const qrIds = useAtomValue(scannedQrIdsAtom); - const removeQrId = useSetAtom(removeScannedQrIdAtom); - const assetsLength = qrIds.length; - const hasAssets = assetsLength > 0; - const clearList = useSetAtom(clearScannedQrIdsAtom); - - /** Removes an set of assets from the list - * Handles both the qrIds and the assets array - */ - function removeAssetsFromList(assets: AssetWithBooking[]) { - setAssets((prev) => - prev.filter((a) => !assets.some((aa) => aa?.id === a?.id)) - ); - assets.forEach((a) => { - removeQrId(a?.qrScanned); - }); - } + const items = useAtomValue(scannedItemsAtom); + const assets = Object.values(items).filter( + (asset): asset is AssetWithBooking => !!asset + ); + const clearList = useSetAtom(clearScannedItemsAtom); + const assetsIds = useAtomValue(scannedItemsIdsAtom); + const removeAssetsFromList = useSetAtom(removeScannedItemsByAssetIdAtom); + + const itemsLength = Object.keys(items).length; + const hasItems = itemsLength > 0; const [expanded, setExpanded] = useState(false); const { vh } = useViewportHeight(); - const [assets, setAssets] = useState([]); - - // /** - // * Clear the list when the component is unmounted - // */ - // useEffect( - // () => () => { - // clearList(); - // setAssets([]); - // }, - // [clearList] - // ); /** * Check which of tha assets are already added in the booking.assets + * Returns an array of the assetIDs that are already added */ - const assetsAlreadyAdded = assets.filter((asset) => - booking.assets.some((a) => a?.id === asset?.id) - ); - + const assetsAlreadyAdded: string[] = assetsIds + .filter((assetId): assetId is string => !!assetId) + .filter((assetId) => booking.assets.some((a) => a?.id === assetId)); const hasAssetsAlreadyAdded = assetsAlreadyAdded.length > 0; + const assetsPartOfKit = Object.values(items) + .filter((asset): asset is AssetWithBooking => !!asset) + .filter((asset) => asset?.kitId && asset.id) + .map((asset) => asset.id); + const hasAssetsPartOfKit = assetsPartOfKit.length > 0; + + const hasConflictsToResolve = hasAssetsAlreadyAdded || hasAssetsPartOfKit; + function resolveAllConflicts() { + removeAssetsFromList([...assetsAlreadyAdded, ...assetsPartOfKit]); + } + return (
@@ -110,7 +105,7 @@ export default function ScannedAssetsDrawer({
{/* Handle */} { setExpanded((prev) => !prev); }} @@ -126,18 +121,24 @@ export default function ScannedAssetsDrawer({ {/* Header */} -
-
{assetsLength} assets scanned
- - -
{/* Body */} - +
{expanded && (
@@ -161,64 +162,105 @@ export default function ScannedAssetsDrawer({
- - -
- {/* Assets list */} -
-
Name
- - - - - - - {qrIds.reverse().map((id, index) => ( - - ))} - -
-
+ +
+ {/* Assets list */} +
+ + + + + + + + {Object.entries(items).map(([qrId, asset]) => ( + + ))} + +
+
- {/* Actions */} -
- -
-

- {assetsAlreadyAdded.length} assets - already added to the booking.{" "} - {" "} - to continue. -

+ {/* Actions */} +
+ +
+
+
+

+ Unresolved blockers +

+

Resolve the issues below to continue

+
+ +
- - -

- {zo.errors.assetIds()?.message} -

-
+
+
    + +
  • + {assetsAlreadyAdded.length} assets + already added to the booking.{" "} + {" "} + to continue. +
  • +
    + +
  • + {assetsPartOfKit.length} assets Are + part of a kit.{" "} + {" "} + to continue. +
  • +
    +
+
+
+ + +

+ {zo.errors.assetIds()?.message} +

+
+ +
+ {assets.map((asset, index) => ( + + ))} -
-
+
- +
@@ -249,59 +291,27 @@ export default function ScannedAssetsDrawer({ function AssetRow({ qrId, - assets, - setAssets, - index, + asset, }: { qrId: string; - booking: any; - index: number; - assets: AssetWithBooking[]; - setAssets: React.Dispatch>; + asset: AssetWithBooking | undefined; }) { const { booking } = useLoaderData(); - - const removeQrId = useSetAtom(removeScannedQrIdAtom); - /** Remove the asset from the list */ - function removeAssetFromList() { - // Remive the qrId from the list - removeQrId(qrId); - // Remove the asset from the list - setAssets((prev) => prev.filter((a) => a?.qrScanned !== qrId)); - } - - /** Find the asset in the assets array */ - const asset = useMemo( - () => assets.find((a) => a?.qrScanned === qrId), - [assets, qrId] - ); + const setAsset = useSetAtom(updateScannedItemAtom); + const removeAsset = useSetAtom(removeScannedItemAtom); const isCheckedOut = useMemo( () => asset?.status === AssetStatus.CHECKED_OUT, [asset] ); - /** Adds an asset to the assets array */ - const setAsset = useCallback( - (asset: AssetWithBooking) => { - setAssets((prev) => { - /** Only add it it doesnt exist in the list already */ - if (asset && prev.some((a) => a && a.id === asset.id)) { - return prev; - } - return [...prev, asset]; - }); - }, - [setAssets] - ); - /** Fetches asset data based on qrId */ const fetchAsset = useCallback(async () => { const response = await fetch( `/api/bookings/get-scanned-asset?qrId=${qrId}&bookingId=${booking.id}` ); const { asset } = await response.json(); - setAsset(asset); + setAsset({ qrId, asset }); }, [qrId, booking.id, setAsset]); /** Fetch the asset when qrId or booking changes */ @@ -327,11 +337,6 @@ function AssetRow({
) : ( <> -

{asset.title}

@@ -361,7 +366,7 @@ function AssetRow({ className="border-none" variant="ghost" icon="trash" - onClick={removeAssetFromList} + onClick={() => removeAsset(qrId)} /> diff --git a/app/components/shared/button.tsx b/app/components/shared/button.tsx index ba023c3b5..7b3fc3af4 100644 --- a/app/components/shared/button.tsx +++ b/app/components/shared/button.tsx @@ -64,6 +64,13 @@ export const Button = React.forwardRef( link: tw( `border-none p-0 text-text-sm font-semibold text-primary-700 hover:text-primary-800` ), + "block-link": tw( + "-mt-1 border-none px-2 py-1 text-[14px] font-normal hover:bg-primary-50 hover:text-primary-600" + ), + + "block-link-gray": tw( + "-mt-1 border-none px-2 py-1 text-[14px] font-normal hover:bg-gray-50 hover:text-gray-600" + ), danger: tw( `border-error-600 bg-error-600 text-white focus:ring-2`, disabled ? "border-error-300 bg-error-300" : "hover:bg-error-800" diff --git a/app/components/zxing-scanner/zxing-scanner.tsx b/app/components/zxing-scanner/zxing-scanner.tsx index 1109bee84..44f22c86c 100644 --- a/app/components/zxing-scanner/zxing-scanner.tsx +++ b/app/components/zxing-scanner/zxing-scanner.tsx @@ -3,10 +3,8 @@ import { useAtomValue, useSetAtom } from "jotai"; import { useZxing } from "react-zxing"; import { addQrIdToErrorShownAtom, - addScannedQrIdAtom, displayQrScannerNotificationAtom, errorShownQrIdsAtom, - scannedQrIdsAtom, } from "~/atoms/qr-scanner"; import type { loader } from "~/routes/_layout+/scanner"; import { ShelfError } from "~/utils/error"; diff --git a/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx b/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx index cfd7d91e4..53cd5be39 100644 --- a/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx +++ b/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx @@ -4,11 +4,12 @@ import type { MetaFunction, LoaderFunctionArgs, ActionFunctionArgs, + LinksFunction, } from "@remix-run/node"; -import { useLoaderData, useNavigation } from "@remix-run/react"; +import { useNavigation } from "@remix-run/react"; import { useSetAtom } from "jotai"; import { z } from "zod"; -import { addScannedQrIdAtom } from "~/atoms/qr-scanner"; +import { addScannedItemAtom } from "~/atoms/qr-scanner"; import Header from "~/components/layout/header"; import type { HeaderData } from "~/components/layout/header/types"; import ScannedAssetsDrawer, { @@ -22,6 +23,7 @@ import { addScannedAssetsToBooking, getBooking, } from "~/modules/booking/service.server"; +import scannerCss from "~/styles/scanner.css?url"; import { appendToMetaTitle } from "~/utils/append-to-meta-title"; import { canUserManageBookingAssets } from "~/utils/bookings"; import { userPrefs } from "~/utils/cookies.server"; @@ -41,6 +43,10 @@ import { } from "~/utils/permissions/permission.data"; import { requirePermission } from "~/utils/roles.server"; +export const links: LinksFunction = () => [ + { rel: "stylesheet", href: scannerCss }, +]; + export async function loader({ context, request, params }: LoaderFunctionArgs) { const authSession = context.getSession(); const { userId } = authSession; @@ -138,7 +144,7 @@ export const handle = { }; export default function ScanAssetsForBookings() { - const addQrId = useSetAtom(addScannedQrIdAtom); + const addItem = useSetAtom(addScannedItemAtom); const navigation = useNavigation(); const isLoading = isFormProcessing(navigation.state); @@ -149,7 +155,7 @@ export default function ScanAssetsForBookings() { function handleQrDetectionSuccess(qrId: string) { /** If the asset is not already in the list */ - addQrId(qrId); + addItem(qrId); } return ( From 97ab31641ddfc2293366a7092d2dda14fb0ab69a Mon Sep 17 00:00:00 2001 From: Donkoko Date: Thu, 5 Sep 2024 19:40:41 +0300 Subject: [PATCH 40/53] handling kits as well --- app/atoms/qr-scanner.ts | 49 +- .../booking/asset-row-actions-dropdown.tsx | 2 +- .../booking/kit-row-actions-dropdown.tsx | 2 +- app/components/scanner/drawer.tsx | 458 ++++++++++++++---- app/components/shared/button.tsx | 2 +- .../zxing-scanner/zxing-scanner.tsx | 2 +- app/modules/asset/service.server.ts | 2 +- app/modules/kit/service.server.ts | 2 +- app/modules/qr/service.server.ts | 17 +- app/modules/scan/service.server.ts | 2 +- app/routes/api+/bookings.get-scanned-asset.ts | 58 --- app/routes/api+/get-scanned-item.$qrId.ts | 94 ++++ app/routes/qr+/$qrId.tsx | 2 +- app/routes/qr+/$qrId_.link.asset.tsx | 2 +- app/routes/qr+/$qrId_.link.kit.tsx | 2 +- app/utils/http.server.ts | 6 +- 16 files changed, 507 insertions(+), 195 deletions(-) delete mode 100644 app/routes/api+/bookings.get-scanned-asset.ts create mode 100644 app/routes/api+/get-scanned-item.$qrId.ts diff --git a/app/atoms/qr-scanner.ts b/app/atoms/qr-scanner.ts index 47b3ac21b..20c3cf45d 100644 --- a/app/atoms/qr-scanner.ts +++ b/app/atoms/qr-scanner.ts @@ -1,5 +1,18 @@ import { atom } from "jotai"; import type { AssetWithBooking } from "~/routes/_layout+/bookings.$bookingId.add-assets"; +import type { KitForBooking } from "~/routes/_layout+/bookings.$bookingId.add-kits"; + +export type ScanListItems = { + [key: string]: ScanListItem; +}; + +export type ScanListItem = + | { + data?: AssetWithBooking | KitForBooking; + error?: string; + type?: "asset" | "kit"; + } + | undefined; /*********************** * Scanned QR Id Atom * @@ -10,39 +23,41 @@ import type { AssetWithBooking } from "~/routes/_layout+/bookings.$bookingId.add * ***********************/ -export const scannedItemsAtom = atom<{ - [key: string]: AssetWithBooking | undefined; -}>({}); +export const scannedItemsAtom = atom({}); /** Get an array of the scanned items ids */ export const scannedItemsIdsAtom = atom((get) => - Object.values(get(scannedItemsAtom)).map((item) => item?.id) + Object.values(get(scannedItemsAtom)).map((item) => item?.data?.id) ); // Add item to object with value `undefined` (just receives the key) export const addScannedItemAtom = atom(null, (get, set, qrId: string) => { const currentItems = get(scannedItemsAtom); - set(scannedItemsAtom, { - [qrId]: undefined, // Add the new entry at the start - ...currentItems, // Spread the rest of the existing items - }); + if (!currentItems[qrId]) { + set(scannedItemsAtom, { + [qrId]: undefined, // Add the new entry at the start + ...currentItems, // Spread the rest of the existing items + }); + } }); // Update item based on key export const updateScannedItemAtom = atom( null, - (get, set, { qrId, asset }: { qrId: string; asset: AssetWithBooking }) => { + (get, set, { qrId, item }: { qrId: string; item: ScanListItem }) => { const currentItems = get(scannedItemsAtom); // Check if the item already exists; if it does, skip the update - if (currentItems[qrId]) { + if (!item || currentItems[qrId]) { return; // Skip the update if the item is already present } - set(scannedItemsAtom, { - ...currentItems, - [qrId]: asset, - }); + if ((item && item?.data && item?.type) || item?.error) { + set(scannedItemsAtom, { + ...currentItems, + [qrId]: item, + }); + } } ); @@ -69,11 +84,11 @@ export const removeMultipleScannedItemsAtom = atom( // Remove items based on asset id export const removeScannedItemsByAssetIdAtom = atom( null, - (get, set, assetIds: string[]) => { + (get, set, ids: string[]) => { const currentItems = get(scannedItemsAtom); const updatedItems = { ...currentItems }; - Object.entries(currentItems).forEach(([qrId, asset]) => { - if (asset && assetIds.includes(asset?.id)) { + Object.entries(currentItems).forEach(([qrId, item]) => { + if (item?.data?.id && ids.includes(item?.data?.id)) { delete updatedItems[qrId]; } }); diff --git a/app/components/booking/asset-row-actions-dropdown.tsx b/app/components/booking/asset-row-actions-dropdown.tsx index 1a3dfb4ff..b1e3b3a0a 100644 --- a/app/components/booking/asset-row-actions-dropdown.tsx +++ b/app/components/booking/asset-row-actions-dropdown.tsx @@ -19,7 +19,7 @@ export const AssetRowActionsDropdown = ({ asset, fullWidth }: Props) => ( - + diff --git a/app/components/booking/kit-row-actions-dropdown.tsx b/app/components/booking/kit-row-actions-dropdown.tsx index a39a2a1a2..30e8cc4e6 100644 --- a/app/components/booking/kit-row-actions-dropdown.tsx +++ b/app/components/booking/kit-row-actions-dropdown.tsx @@ -33,7 +33,7 @@ export default function KitRowActionsDropdown({ - + diff --git a/app/components/scanner/drawer.tsx b/app/components/scanner/drawer.tsx index 89e25f053..25dbbf8e0 100644 --- a/app/components/scanner/drawer.tsx +++ b/app/components/scanner/drawer.tsx @@ -1,24 +1,29 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { AssetStatus } from "@prisma/client"; +import { useCallback, useEffect, useRef, useState } from "react"; +import type { Asset, Kit, Prisma } from "@prisma/client"; import { Form, useLoaderData } from "@remix-run/react"; import { motion } from "framer-motion"; import { useAtomValue, useSetAtom } from "jotai"; import { createPortal } from "react-dom"; import { useZorm } from "react-zorm"; import { z } from "zod"; +import type { ScanListItem } from "~/atoms/qr-scanner"; import { clearScannedItemsAtom, + removeMultipleScannedItemsAtom, removeScannedItemAtom, removeScannedItemsByAssetIdAtom, scannedItemsAtom, - scannedItemsIdsAtom, updateScannedItemAtom, } from "~/atoms/qr-scanner"; import { useViewportHeight } from "~/hooks/use-viewport-height"; import type { AssetWithBooking } from "~/routes/_layout+/bookings.$bookingId.add-assets"; +import type { KitForBooking } from "~/routes/_layout+/bookings.$bookingId.add-kits"; import { type loader } from "~/routes/_layout+/bookings.$bookingId_.scan-assets"; import { tw } from "~/utils/tw"; -import { AvailabilityLabel } from "../booking/availability-label"; +import { + AvailabilityBadge, + KitAvailabilityLabel, +} from "../booking/availability-label"; import { AssetLabel } from "../icons/library"; import { ListHeader } from "../list/list-header"; import { Button } from "../shared/button"; @@ -55,12 +60,19 @@ export default function ScannedAssetsDrawer({ // Get the scanned qrIds const items = useAtomValue(scannedItemsAtom); - const assets = Object.values(items).filter( - (asset): asset is AssetWithBooking => !!asset - ); + const assets = Object.values(items) + .filter((item) => !!item && item.data && item.type === "asset") + .map((item) => item?.data as AssetWithBooking); + + const kits = Object.values(items) + .filter((item) => !!item && item.data && item.type === "kit") + .map((item) => item?.data as KitForBooking); + + const errors = Object.entries(items).filter(([, item]) => !!item?.error); + const clearList = useSetAtom(clearScannedItemsAtom); - const assetsIds = useAtomValue(scannedItemsIdsAtom); const removeAssetsFromList = useSetAtom(removeScannedItemsByAssetIdAtom); + const removeItemsFromList = useSetAtom(removeMultipleScannedItemsAtom); const itemsLength = Object.keys(items).length; const hasItems = itemsLength > 0; @@ -68,26 +80,78 @@ export default function ScannedAssetsDrawer({ const [expanded, setExpanded] = useState(false); const { vh } = useViewportHeight(); + const itemsListRef = useRef(null); + const prevItemsLengthRef = useRef(itemsLength); + + useEffect(() => { + /** When the items.length increases, scroll the item list div to the top */ + if (itemsListRef.current && itemsLength > prevItemsLengthRef.current) { + itemsListRef.current.scrollTo({ + top: 0, + behavior: "smooth", + }); + } + prevItemsLengthRef.current = itemsLength; + }, [expanded, itemsLength]); + /** * Check which of tha assets are already added in the booking.assets * Returns an array of the assetIDs that are already added */ - const assetsAlreadyAdded: string[] = assetsIds - .filter((assetId): assetId is string => !!assetId) - .filter((assetId) => booking.assets.some((a) => a?.id === assetId)); - const hasAssetsAlreadyAdded = assetsAlreadyAdded.length > 0; - - const assetsPartOfKit = Object.values(items) - .filter((asset): asset is AssetWithBooking => !!asset) - .filter((asset) => asset?.kitId && asset.id) + const assetsAlreadyAddedIds: string[] = assets + .filter((asset) => !!asset) + .filter((asset) => booking.assets.some((a) => a?.id === asset.id)) + .map((a) => !!a && a.id); + const hasAssetsAlreadyAdded = assetsAlreadyAddedIds.length > 0; + + /** Get list of ids of the assets that are part of kit */ + const assetsPartOfKitIds: string[] = assets + .filter((asset) => !!asset && asset.kitId && asset.id) .map((asset) => asset.id); - const hasAssetsPartOfKit = assetsPartOfKit.length > 0; + const hasAssetsPartOfKit = assetsPartOfKitIds.length > 0; + + /** Get assets marked as unavailable to book */ + const unavailableAssetsIds = assets + .filter((asset) => !asset.availableToBook) + .map((a) => !!a && a.id); + const hasUnavailableAssets = unavailableAssetsIds.length > 0; + + /** QR codes that were scanned but are not valid to be added */ + const hasErrors = errors.length > 0; + + const hasConflictsToResolve = + hasAssetsAlreadyAdded || + hasAssetsPartOfKit || + hasErrors || + hasUnavailableAssets; + + const totalUnresolvedConflicts = + unavailableAssetsIds.length + + assetsAlreadyAddedIds.length + + assetsPartOfKitIds.length + + errors.length; - const hasConflictsToResolve = hasAssetsAlreadyAdded || hasAssetsPartOfKit; function resolveAllConflicts() { - removeAssetsFromList([...assetsAlreadyAdded, ...assetsPartOfKit]); + removeAssetsFromList([ + ...assetsAlreadyAddedIds, + ...assetsPartOfKitIds, + ...unavailableAssetsIds, + ]); + removeItemsFromList(errors.map(([qrId]) => qrId)); } + /** List of ids from: + * - all items with type asset + * - all assets attached to items with type kit + * */ + + const assetIdsForBooking = Array.from( + new Set([ + ...assets.map((a) => a.id), + ...kits.flatMap((k) => k.assets.map((a) => a.id)), + ]) + ); + return (
-
Add assets to booking via scan
+
Add assets and kits to booking via scan
{/* Handle */} @@ -122,7 +186,7 @@ export default function ScannedAssetsDrawer({ {/* Header */}
-
{`${itemsLength} asset${ +
{`${itemsLength} item${ itemsLength > 1 ? "s" : "" } scanned`}
@@ -163,7 +227,10 @@ export default function ScannedAssetsDrawer({ -
+
{/* Assets list */}
@@ -173,8 +240,8 @@ export default function ScannedAssetsDrawer({ - {Object.entries(items).map(([qrId, asset]) => ( - + {Object.entries(items).map(([qrId, item]) => ( + ))}
@@ -187,32 +254,64 @@ export default function ScannedAssetsDrawer({

- Unresolved blockers + Unresolved blockers ({totalUnresolvedConflicts}) +

+

+ Resolve the issues below to continue. They are + currently blocking you from being able to confirm.

-

Resolve the issues below to continue


    + +
  • + + {`${unavailableAssetsIds.length} asset${ + unavailableAssetsIds.length > 1 + ? "s are" + : " is" + }`} + {" "} + marked as unavailable.{" "} + {" "} + to continue. +
  • +
  • - {assetsAlreadyAdded.length} assets + + {`${assetsAlreadyAddedIds.length} asset${ + assetsAlreadyAddedIds.length > 1 ? "s" : "" + }`} + {" "} already added to the booking.{" "} {" "} - to continue. + to continue.{" "} +

    + Note: Scan Kit QR to add the full kit +

    +
  • +
    + + +
  • + {`${errors.length} QR codes `} + are invalid.{" "} + {" "} + to continue.{" "} +

    + Note: Scan Kit QR to add the full kit +

@@ -251,13 +378,13 @@ export default function ScannedAssetsDrawer({ className="flex max-h-full w-full" method="POST" > -
- {assets.map((asset, index) => ( +
+ {assetIdsForBooking.map((assetId, index) => ( ))} @@ -289,84 +416,84 @@ export default function ScannedAssetsDrawer({ ); } -function AssetRow({ - qrId, - asset, -}: { - qrId: string; - asset: AssetWithBooking | undefined; -}) { - const { booking } = useLoaderData(); - const setAsset = useSetAtom(updateScannedItemAtom); - const removeAsset = useSetAtom(removeScannedItemAtom); - - const isCheckedOut = useMemo( - () => asset?.status === AssetStatus.CHECKED_OUT, - [asset] - ); - - /** Fetches asset data based on qrId */ - const fetchAsset = useCallback(async () => { - const response = await fetch( - `/api/bookings/get-scanned-asset?qrId=${qrId}&bookingId=${booking.id}` - ); - const { asset } = await response.json(); - setAsset({ qrId, asset }); - }, [qrId, booking.id, setAsset]); +function ItemRow({ qrId, item }: { qrId: string; item: ScanListItem }) { + const setItem = useSetAtom(updateScannedItemAtom); + const removeItem = useSetAtom(removeScannedItemAtom); + + /** Fetches item data based on qrId */ + const fetchItem = useCallback(async () => { + const request = await fetch(`/api/get-scanned-item/${qrId}`); + const response = await request.json(); + + /** */ + if (response.error) { + setItem({ + qrId, + item: { error: response.error.message }, + }); + return; + } + + const qr: Prisma.QrGetPayload<{ + include: { + asset: true; + kit: true; + }; + }> & { + type: "asset" | "kit" | undefined; + } = response.qr; + + const itemWithType = + qr && qr.type === "asset" + ? { data: qr.asset, type: "asset" } + : { data: qr.kit, type: "kit" }; + + if (itemWithType && itemWithType?.data) { + setItem({ + qrId, + item: itemWithType as ScanListItem, + }); + } + }, [qrId, setItem]); /** Fetch the asset when qrId or booking changes */ useEffect(() => { - void fetchAsset(); - }, [qrId, booking.id, setAsset, fetchAsset]); + void fetchItem(); + }, [qrId, setItem, fetchItem]); + + const hasItem = !!item && !!item.data; + const isAsset = item?.type === "asset"; + const isKit = item?.type === "kit"; + const itemData = isAsset + ? (item?.data as Asset) + : isKit + ? (item?.data as Kit) + : undefined; return (
-
-
- {!asset ? ( -
-

- QR id: {qrId} -

{" "} - -
- ) : ( - <> -

- {asset.title} -

- -
- a.id === asset.id) && - !!asset.kitId - } - isAlreadyAdded={booking.assets.some( - (a) => a.id === asset.id - )} - showKitStatus - asset={asset} - isCheckedOut={isCheckedOut} - /> -
- - )} -
-
+ + + + + {/* Render asset row */} + + + + + + +
; } + +function RowLoadingState({ qrId, error }: { qrId: string; error?: string }) { + return ( +
+

+ QR id: {qrId} +

{" "} + {error ? ( +

{error}

+ ) : ( + + )} +
+ ); +} + +function AssetRow({ asset }: { asset: AssetWithBooking }) { + const { booking } = useLoaderData(); + return ( +
+

+ {asset.title} +

+ +
+ + asset + + a?.id === asset.id)} + isMarkedAsUnavailable={!asset.availableToBook} + /> +
+
+ ); +} + +function KitRow({ kit }: { kit: KitForBooking }) { + return ( +
+

+ {kit.name}{" "} + + ({kit._count.assets} assets) + +

+ +
+ + kit + + +
+
+ ); +} + +/** The global one considers a lot of states that are not relevant for this UI. + * Here we should show only the labels are blockers: + * - asset is part of kit + * - asset is already in the booking + * */ +const LocalAvailabilityLabel = ({ + isPartOfKit, + isAlreadyAdded, + isMarkedAsUnavailable, +}: { + isPartOfKit: boolean; + isAlreadyAdded: boolean; + isMarkedAsUnavailable: boolean; +}) => { + if (isMarkedAsUnavailable) { + return ( + + ); + } + if (isAlreadyAdded) { + return ( + + ); + } + + /** + * Asset is part of a kit + */ + if (isPartOfKit) { + return ( + + ); + } + + return null; +}; diff --git a/app/components/shared/button.tsx b/app/components/shared/button.tsx index 7b3fc3af4..02949fd35 100644 --- a/app/components/shared/button.tsx +++ b/app/components/shared/button.tsx @@ -12,7 +12,7 @@ export interface ButtonProps { className?: string; variant?: ButtonVariant; width?: ButtonWidth; - size?: "sm" | "md"; + size?: "xs" | "sm" | "md"; icon?: IconType; /** Disabled can be a boolean */ disabled?: diff --git a/app/components/zxing-scanner/zxing-scanner.tsx b/app/components/zxing-scanner/zxing-scanner.tsx index 44f22c86c..cf332fc67 100644 --- a/app/components/zxing-scanner/zxing-scanner.tsx +++ b/app/components/zxing-scanner/zxing-scanner.tsx @@ -166,7 +166,7 @@ export const ZXingScanner = ({ {/* Overlay */} -
+
)}
diff --git a/app/modules/asset/service.server.ts b/app/modules/asset/service.server.ts index 05b200bd5..da7b22b0f 100644 --- a/app/modules/asset/service.server.ts +++ b/app/modules/asset/service.server.ts @@ -740,7 +740,7 @@ export async function createAsset({ * 2. If the qr code belongs to the current organization * 3. If the qr code is not linked to an asset or a kit */ - const qr = qrId ? await getQr(qrId) : null; + const qr = qrId ? await getQr({ id: qrId }) : null; const qrCodes = qr && qr.organizationId === organizationId && diff --git a/app/modules/kit/service.server.ts b/app/modules/kit/service.server.ts index bb6c70674..cd7569d35 100644 --- a/app/modules/kit/service.server.ts +++ b/app/modules/kit/service.server.ts @@ -72,7 +72,7 @@ export async function createKit({ * 2. If the qr code belongs to the current organization * 3. If the qr code is not linked to an asset */ - const qr = qrId ? await getQr(qrId) : null; + const qr = qrId ? await getQr({ id: qrId }) : null; const qrCodes = qr && qr.organizationId === organizationId && diff --git a/app/modules/qr/service.server.ts b/app/modules/qr/service.server.ts index face61035..2e97c5c92 100644 --- a/app/modules/qr/service.server.ts +++ b/app/modules/qr/service.server.ts @@ -52,11 +52,22 @@ export async function getQrByKitId({ kitId }: Pick) { } } -export async function getQr(id: Qr["id"]) { +type QrWithInclude = + T extends Prisma.QrInclude ? Prisma.QrGetPayload<{ include: T }> : Qr; + +export async function getQr({ + id, + include, +}: Pick & { + include?: T; +}): Promise> { try { - return await db.qr.findUniqueOrThrow({ + const qr = await db.qr.findUniqueOrThrow({ where: { id }, + include: { ...include }, }); + + return qr as QrWithInclude; } catch (cause) { throw new ShelfError({ cause, @@ -401,7 +412,7 @@ export async function claimQrCode({ }) { try { /** First, just in case we check whether its claimed */ - const qr = await getQr(id); + const qr = await getQr({ id }); if (qr.organizationId) { throw new ShelfError({ message: diff --git a/app/modules/scan/service.server.ts b/app/modules/scan/service.server.ts index 0351ba7c9..b7981635e 100644 --- a/app/modules/scan/service.server.ts +++ b/app/modules/scan/service.server.ts @@ -168,7 +168,7 @@ export async function createScanNote({ }) { try { let message = ""; - const { assetId, organizationId } = await getQr(qrId); + const { assetId, organizationId } = await getQr({ id: qrId }); if (assetId) { if (userId && userId != "anonymous") { const { firstName, lastName } = await getUserByID(userId); diff --git a/app/routes/api+/bookings.get-scanned-asset.ts b/app/routes/api+/bookings.get-scanned-asset.ts deleted file mode 100644 index 7dc8aeed8..000000000 --- a/app/routes/api+/bookings.get-scanned-asset.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { json } from "@remix-run/node"; -import type { LoaderFunctionArgs } from "@remix-run/node"; -import { getAsset } from "~/modules/asset/service.server"; -import { getQr } from "~/modules/qr/service.server"; -import { makeShelfError, ShelfError } from "~/utils/error"; -import { data, error } from "~/utils/http.server"; - -export async function loader({ request, context }: LoaderFunctionArgs) { - const authSession = context.getSession(); - const { userId } = authSession; - - try { - const searchParams = new URL(request.url).searchParams; - const qrId = searchParams.get("qrId"); - const bookingId = searchParams.get("bookingId"); - - if (!qrId || !bookingId) { - throw new ShelfError({ - cause: null, - message: "Insufficient parameters", - shouldBeCaptured: false, - label: "Booking", - }); - } - - const qr = await getQr(qrId); - - if (!qr.assetId || !qr.organizationId) { - throw new ShelfError({ - cause: null, - message: "QR is not associated with any asset yet.", - label: "Booking", - shouldBeCaptured: false, - }); - } - - const asset = await getAsset({ - id: qr.assetId, - organizationId: qr.organizationId, - include: { - custody: true, - bookings: true, - }, - }); - - return json( - data({ - asset: { - ...asset, - qrScanned: qr.id, - }, - }) - ); - } catch (cause) { - const reason = makeShelfError(cause, { userId }); - return json(error(reason), { status: reason.status }); - } -} diff --git a/app/routes/api+/get-scanned-item.$qrId.ts b/app/routes/api+/get-scanned-item.$qrId.ts new file mode 100644 index 000000000..4ae2853f9 --- /dev/null +++ b/app/routes/api+/get-scanned-item.$qrId.ts @@ -0,0 +1,94 @@ +import { json } from "@remix-run/node"; +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { z } from "zod"; +import { getQr } from "~/modules/qr/service.server"; +import { makeShelfError, ShelfError } from "~/utils/error"; +import { data, error, getParams } from "~/utils/http.server"; +import { + PermissionAction, + PermissionEntity, +} from "~/utils/permissions/permission.data"; +import { requirePermission } from "~/utils/roles.server"; + +export async function loader({ request, params, context }: LoaderFunctionArgs) { + const authSession = context.getSession(); + const { userId } = authSession; + + try { + const { organizationId } = await requirePermission({ + userId, + request, + entity: PermissionEntity.qr, + action: PermissionAction.read, + }); + + const { qrId } = getParams(params, z.object({ qrId: z.string() }), { + additionalData: { + userId, + }, + }); + + const qr = await getQr({ + id: qrId, + include: { + asset: { + include: { + bookings: true, + }, + }, + kit: { + // Fits KitForBooking type + include: { + _count: { select: { assets: true } }, + assets: { + select: { + id: true, + status: true, + availableToBook: true, + custody: true, + bookings: { select: { id: true, status: true } }, + }, + }, + }, + }, + }, + }); + + if (qr.organizationId !== organizationId) { + throw new ShelfError({ + cause: null, + message: + "This QR code doesn't exist or it doesn't belong to your current organization.", + additionalData: { qrId, shouldSendNotification: false }, + label: "QR", + }); + } + + if (!qr.assetId && !qr.kitId) { + throw new ShelfError({ + cause: null, + message: "QR code is not linked to any asset or kit", + additionalData: { qrId, shouldSendNotification: false }, + label: "QR", + }); + } + + return json( + data({ + qr: { + ...qr, + type: qr.asset ? "asset" : qr.kit ? "kit" : undefined, + }, + }) + ); + } catch (cause) { + const reason = makeShelfError(cause, { userId }); + const sendNotification = reason.additionalData?.shouldSendNotification; + const shouldSendNotification = + typeof sendNotification === "boolean" && sendNotification; + + return json(error(reason, shouldSendNotification), { + status: reason.status, + }); + } +} diff --git a/app/routes/qr+/$qrId.tsx b/app/routes/qr+/$qrId.tsx index b2cef97be..848f8245d 100644 --- a/app/routes/qr+/$qrId.tsx +++ b/app/routes/qr+/$qrId.tsx @@ -28,7 +28,7 @@ export async function loader({ context, request, params }: LoaderFunctionArgs) { try { /* Find the QR in the database */ - const qr = await getQr(id); + const qr = await getQr({ id }); /** * If the QR doesn't exist, getQR will throw a 404 */ diff --git a/app/routes/qr+/$qrId_.link.asset.tsx b/app/routes/qr+/$qrId_.link.asset.tsx index 1c7e3c185..e921d806b 100644 --- a/app/routes/qr+/$qrId_.link.asset.tsx +++ b/app/routes/qr+/$qrId_.link.asset.tsx @@ -69,7 +69,7 @@ export const loader = async ({ const { qrId } = getParams(params, z.object({ qrId: z.string() })); try { - const qr = await getQr(qrId); + const qr = await getQr({ id: qrId }); if (qr?.assetId || qr?.kitId) { throw new ShelfError({ message: "This QR code is already linked to an asset or a kit.", diff --git a/app/routes/qr+/$qrId_.link.kit.tsx b/app/routes/qr+/$qrId_.link.kit.tsx index 9fa484d30..5f1ad5016 100644 --- a/app/routes/qr+/$qrId_.link.kit.tsx +++ b/app/routes/qr+/$qrId_.link.kit.tsx @@ -69,7 +69,7 @@ export const loader = async ({ const { qrId } = getParams(params, z.object({ qrId: z.string() })); try { - const qr = await getQr(qrId); + const qr = await getQr({ id: qrId }); if (qr?.assetId || qr?.kitId) { throw new ShelfError({ message: "This QR code is already linked to an asset or a kit.", diff --git a/app/utils/http.server.ts b/app/utils/http.server.ts index 1d0593878..2b89cf3c4 100644 --- a/app/utils/http.server.ts +++ b/app/utils/http.server.ts @@ -198,13 +198,13 @@ export type DataResponse = * @param cause - The error that has been catch * @returns The normalized error with `error` key set to the error */ -export function error(cause: ShelfError) { +export function error(cause: ShelfError, shouldSendNotification = true) { Logger.error(cause); - // TODO: @DonKoko maybe globally rethink this? if ( cause.additionalData?.userId && - typeof cause.additionalData?.userId === "string" + typeof cause.additionalData?.userId === "string" && + shouldSendNotification ) { sendNotification({ title: cause.title || "Oops! Something went wrong", From 66170d8c4dede646db79751e0c95ff359c3e94a2 Mon Sep 17 00:00:00 2001 From: Donkoko Date: Thu, 5 Sep 2024 21:19:17 +0300 Subject: [PATCH 41/53] handling unavailable kits --- .../layout/breadcrumbs/breadcrumb.tsx | 54 ++++++++------- app/components/scanner/drawer.tsx | 69 ++++++++++++++++--- .../bookings.$bookingId_.scan-assets.tsx | 2 +- 3 files changed, 90 insertions(+), 35 deletions(-) diff --git a/app/components/layout/breadcrumbs/breadcrumb.tsx b/app/components/layout/breadcrumbs/breadcrumb.tsx index 68ed05d4e..de6226de8 100644 --- a/app/components/layout/breadcrumbs/breadcrumb.tsx +++ b/app/components/layout/breadcrumbs/breadcrumb.tsx @@ -16,36 +16,44 @@ export function Breadcrumb({ if (typeof breadcrumb === "string") { if (breadcrumb === "single") { if (match?.data?.location) { - breadcrumb = - {match?.data?.location?.name} || - "Not found"; + breadcrumb = ( + + {match?.data?.location?.name || "Not found"} + + ); } else if (match?.data?.organization) { - breadcrumb = - ( - - {match?.data?.organization?.name} - - ) || "Not found"; + breadcrumb = ( + + {match?.data?.organization?.name || "Not found"} + + ); } else if (match?.data?.booking) { - breadcrumb = - {match?.data?.booking?.name} || - "Not found"; + breadcrumb = ( + + {match?.data?.booking?.name || "Not found"} + + ); } else if (match?.data?.kit) { - breadcrumb = - {match?.data?.kit?.name} || - "Not found"; + breadcrumb = ( + + {match?.data?.kit?.name || "Not found"} + + ); } else if (match?.data?.userName) { - breadcrumb = - {match?.data?.userName} || - "Not found"; + breadcrumb = ( + + {match?.data?.userName || "Not found"} + + ); } else { - breadcrumb = - {match?.data?.asset?.title} || - "Not found"; + breadcrumb = ( + + {match?.data?.asset?.title || "Not found"} + + ); } } else { - breadcrumb = - {breadcrumb} || "Not found"; + breadcrumb = {breadcrumb}; } } diff --git a/app/components/scanner/drawer.tsx b/app/components/scanner/drawer.tsx index 25dbbf8e0..eeb4ce88a 100644 --- a/app/components/scanner/drawer.tsx +++ b/app/components/scanner/drawer.tsx @@ -20,10 +20,7 @@ import type { AssetWithBooking } from "~/routes/_layout+/bookings.$bookingId.add import type { KitForBooking } from "~/routes/_layout+/bookings.$bookingId.add-kits"; import { type loader } from "~/routes/_layout+/bookings.$bookingId_.scan-assets"; import { tw } from "~/utils/tw"; -import { - AvailabilityBadge, - KitAvailabilityLabel, -} from "../booking/availability-label"; +import { AvailabilityBadge } from "../booking/availability-label"; import { AssetLabel } from "../icons/library"; import { ListHeader } from "../list/list-header"; import { Button } from "../shared/button"; @@ -116,6 +113,19 @@ export default function ScannedAssetsDrawer({ .map((a) => !!a && a.id); const hasUnavailableAssets = unavailableAssetsIds.length > 0; + /** + * 1. Get the count of kits kits that have assets inside them that are not avaiable to book + * 2. Get an array of the asset ids inside those kits that are not available to book + * */ + const countKitsWithUnavailableAssets = kits.filter((kit) => + kit.assets.some((a) => !a.availableToBook) + ).length; + const unavailableAssetsIdsInKits = kits + .filter((kit) => kit.assets.some((a) => !a.availableToBook)) + .flatMap((kit) => kit.assets.map((a) => a.id)); + + const hasUnavailableAssetsInKits = countKitsWithUnavailableAssets > 0; + /** QR codes that were scanned but are not valid to be added */ const hasErrors = errors.length > 0; @@ -129,6 +139,7 @@ export default function ScannedAssetsDrawer({ unavailableAssetsIds.length + assetsAlreadyAddedIds.length + assetsPartOfKitIds.length + + countKitsWithUnavailableAssets + errors.length; function resolveAllConflicts() { @@ -136,6 +147,7 @@ export default function ScannedAssetsDrawer({ ...assetsAlreadyAddedIds, ...assetsPartOfKitIds, ...unavailableAssetsIds, + ...unavailableAssetsIdsInKits, ]); removeItemsFromList(errors.map(([qrId]) => qrId)); } @@ -254,7 +266,7 @@ export default function ScannedAssetsDrawer({

- Unresolved blockers ({totalUnresolvedConflicts}) + ⚠️ Unresolved blockers ({totalUnresolvedConflicts})

Resolve the issues below to continue. They are @@ -275,6 +287,7 @@ export default function ScannedAssetsDrawer({


    + {/* Unavailable assets */}
  • @@ -295,9 +308,10 @@ export default function ScannedAssetsDrawer({ > Remove from list {" "} - to continue.
  • + + {/* Already added assets */}
  • @@ -316,9 +330,10 @@ export default function ScannedAssetsDrawer({ > Remove from list {" "} - to continue.
  • + + {/* Assets part of kit */}
  • {`${assetsPartOfKitIds.length} asset${ @@ -335,13 +350,37 @@ export default function ScannedAssetsDrawer({ > Remove from list {" "} - to continue.{" "}

    Note: Scan Kit QR to add the full kit

  • + +
  • + {`${countKitsWithUnavailableAssets} kit${ + countKitsWithUnavailableAssets > 1 + ? "s have" + : " has" + } `} + unavailable assets inside{" "} + {countKitsWithUnavailableAssets > 1 ? "them" : "it"} + .{" "} + {" "} +
  • +
    +
  • {`${errors.length} QR codes `} @@ -358,7 +397,6 @@ export default function ScannedAssetsDrawer({ > Remove from list {" "} - to continue.{" "}

    Note: Scan Kit QR to add the full kit

    @@ -425,7 +463,7 @@ function ItemRow({ qrId, item }: { qrId: string; item: ScanListItem }) { const request = await fetch(`/api/get-scanned-item/${qrId}`); const response = await request.json(); - /** */ + /** If the server returns an error, add it to the item and return */ if (response.error) { setItem({ qrId, @@ -566,6 +604,9 @@ function AssetRow({ asset }: { asset: AssetWithBooking }) { } function KitRow({ kit }: { kit: KitForBooking }) { + const someAssetMarkedUnavailable = kit.assets.some( + (asset) => !asset.availableToBook + ); return (

    @@ -585,7 +626,13 @@ function KitRow({ kit }: { kit: KitForBooking }) { > kit - + {someAssetMarkedUnavailable && ( + + )}

); diff --git a/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx b/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx index 53cd5be39..c91e03d53 100644 --- a/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx +++ b/app/routes/_layout+/bookings.$bookingId_.scan-assets.tsx @@ -140,7 +140,7 @@ export const meta: MetaFunction = ({ data }) => [ ]; export const handle = { - breadcrumb: () => "single", + breadcrumb: () => "Scan QR codes to add to booking", }; export default function ScanAssetsForBookings() { From d659b68eab74043e515897a8b8f13fea3bc820dd Mon Sep 17 00:00:00 2001 From: Donkoko Date: Fri, 6 Sep 2024 16:42:55 +0300 Subject: [PATCH 42/53] - removed scanner notifications - better handle kit blocking errors - add back button - fix how route is managed - added animations for adding/removing list items --- app/atoms/qr-scanner.ts | 51 --------- app/components/scanner/drawer.tsx | 104 ++++++++++-------- .../zxing-scanner/qr-scanner-notification.tsx | 37 ------- .../zxing-scanner/zxing-scanner.tsx | 43 ++++---- ...sx => bookings.$bookingId.scan-assets.tsx} | 2 + app/routes/_layout+/bookings.$bookingId.tsx | 14 ++- 6 files changed, 93 insertions(+), 158 deletions(-) delete mode 100644 app/components/zxing-scanner/qr-scanner-notification.tsx rename app/routes/_layout+/{bookings.$bookingId_.scan-assets.tsx => bookings.$bookingId.scan-assets.tsx} (98%) diff --git a/app/atoms/qr-scanner.ts b/app/atoms/qr-scanner.ts index 20c3cf45d..d6a4cf0f4 100644 --- a/app/atoms/qr-scanner.ts +++ b/app/atoms/qr-scanner.ts @@ -102,54 +102,3 @@ export const clearScannedItemsAtom = atom(null, (_get, set) => { }); /*******************************/ - -/**************************** - * QR Scanner Notification * - ****************************/ - -/** This atom is used to show the notification specifically for Qr Scanner */ -type QrScannerNotification = { message: string }; - -export const qrScannerNotificationAtom = atom< - QrScannerNotification | undefined ->(undefined); - -/** This atom is used to display a qr notification */ -export const displayQrScannerNotificationAtom = atom< - null, - QrScannerNotification[], - unknown ->(null, (_, set, update) => { - /** Only one notification is displayed at a time, so we are overriding the current message with older one */ - set(qrScannerNotificationAtom, update); - - /** Remove the notification after a certain time */ - setTimeout(() => { - set(qrScannerNotificationAtom, undefined); - }, 2000); -}); - -/** This atom is used to remove the notification immediately */ -export const removeQrScannerNotificationAtom = atom(null, (_, set) => { - set(qrScannerNotificationAtom, undefined); -}); - -/*************************** - * Error Shown for QR Ids * - ***************************/ - -/** This atom keeps track of the qrIds for which the error is shown */ -export const errorShownQrIdsAtom = atom([]); - -/** This atom adds a qrId in errorShownQrIdsAtom and automatically removes it after a certain interval. */ -export const addQrIdToErrorShownAtom = atom( - null, - (_, set, update) => { - set(errorShownQrIdsAtom, (prev) => [...prev, update]); - - /** Remove the qrId after 10 seconds */ - setTimeout(() => { - set(errorShownQrIdsAtom, (prev) => prev.filter((id) => id !== update)); - }, 10000); - } -); diff --git a/app/components/scanner/drawer.tsx b/app/components/scanner/drawer.tsx index eeb4ce88a..fe38ebd1e 100644 --- a/app/components/scanner/drawer.tsx +++ b/app/components/scanner/drawer.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { Asset, Kit, Prisma } from "@prisma/client"; import { Form, useLoaderData } from "@remix-run/react"; -import { motion } from "framer-motion"; +import { AnimatePresence, motion } from "framer-motion"; import { useAtomValue, useSetAtom } from "jotai"; import { createPortal } from "react-dom"; import { useZorm } from "react-zorm"; @@ -18,7 +18,7 @@ import { import { useViewportHeight } from "~/hooks/use-viewport-height"; import type { AssetWithBooking } from "~/routes/_layout+/bookings.$bookingId.add-assets"; import type { KitForBooking } from "~/routes/_layout+/bookings.$bookingId.add-kits"; -import { type loader } from "~/routes/_layout+/bookings.$bookingId_.scan-assets"; +import { type loader } from "~/routes/_layout+/bookings.$bookingId.scan-assets"; import { tw } from "~/utils/tw"; import { AvailabilityBadge } from "../booking/availability-label"; import { AssetLabel } from "../icons/library"; @@ -57,6 +57,7 @@ export default function ScannedAssetsDrawer({ // Get the scanned qrIds const items = useAtomValue(scannedItemsAtom); + console.log(items); const assets = Object.values(items) .filter((item) => !!item && item.data && item.type === "asset") .map((item) => item?.data as AssetWithBooking); @@ -113,16 +114,23 @@ export default function ScannedAssetsDrawer({ .map((a) => !!a && a.id); const hasUnavailableAssets = unavailableAssetsIds.length > 0; - /** - * 1. Get the count of kits kits that have assets inside them that are not avaiable to book - * 2. Get an array of the asset ids inside those kits that are not available to book - * */ - const countKitsWithUnavailableAssets = kits.filter((kit) => - kit.assets.some((a) => !a.availableToBook) - ).length; - const unavailableAssetsIdsInKits = kits + /** To get the QR ids of the kits that include unavailable assets, + * we first find the kits and + * then we we search in the items to kind the keys that hold those kits */ + const kitsWithUnavailableAssets = kits .filter((kit) => kit.assets.some((a) => !a.availableToBook)) - .flatMap((kit) => kit.assets.map((a) => a.id)); + .map((kit) => kit.id); + const countKitsWithUnavailableAssets = kitsWithUnavailableAssets.length; + + const qrIdsOfUnavailableKits = Object.entries(items) + .filter(([qrId, item]) => { + if (!item || item.type !== "kit") return false; + + if (kitsWithUnavailableAssets.includes((item?.data as Kit)?.id)) { + return qrId; + } + }) + .map(([qrId]) => qrId); const hasUnavailableAssetsInKits = countKitsWithUnavailableAssets > 0; @@ -133,7 +141,8 @@ export default function ScannedAssetsDrawer({ hasAssetsAlreadyAdded || hasAssetsPartOfKit || hasErrors || - hasUnavailableAssets; + hasUnavailableAssets || + hasUnavailableAssetsInKits; const totalUnresolvedConflicts = unavailableAssetsIds.length + @@ -147,9 +156,11 @@ export default function ScannedAssetsDrawer({ ...assetsAlreadyAddedIds, ...assetsPartOfKitIds, ...unavailableAssetsIds, - ...unavailableAssetsIdsInKits, ]); - removeItemsFromList(errors.map(([qrId]) => qrId)); + removeItemsFromList([ + ...errors.map(([qrId]) => qrId), + ...qrIdsOfUnavailableKits, + ]); } /** List of ids from: @@ -252,17 +263,26 @@ export default function ScannedAssetsDrawer({ - {Object.entries(items).map(([qrId, item]) => ( - - ))} + + {Object.entries(items).map(([qrId, item]) => ( + + ))} +
{/* Actions */}
+ {/* Blockers */} -
+

@@ -371,9 +391,7 @@ export default function ScannedAssetsDrawer({ type="button" className="text-gray inline text-[12px] font-normal underline" onClick={() => { - removeAssetsFromList( - unavailableAssetsIdsInKits - ); + removeItemsFromList(qrIdsOfUnavailableKits); }} > Remove from list @@ -403,7 +421,7 @@ export default function ScannedAssetsDrawer({ -

+ @@ -540,7 +558,11 @@ function ItemRow({ qrId, item }: { qrId: string; item: ScanListItem }) { function Tr({ children }: { children: React.ReactNode }) { return ( - {children} - + ); } @@ -558,6 +580,8 @@ function TextLoader({ text, className }: { text: string; className?: string }) { } function RowLoadingState({ qrId, error }: { qrId: string; error?: string }) { + // const items = useAtomValue(scannedItemsAtom); + // const item = items[qrId]; return (

@@ -583,7 +607,7 @@ function AssetRow({ asset }: { asset: AssetWithBooking }) { {asset.title}

-
+
{ - if (isMarkedAsUnavailable) { - return ( +}) => ( +
+ - ); - } - if (isAlreadyAdded) { - return ( + + + - ); - } + - /** - * Asset is part of a kit - */ - if (isPartOfKit) { - return ( + - ); - } - - return null; -}; + +
+); diff --git a/app/components/zxing-scanner/qr-scanner-notification.tsx b/app/components/zxing-scanner/qr-scanner-notification.tsx deleted file mode 100644 index 2d35ffd38..000000000 --- a/app/components/zxing-scanner/qr-scanner-notification.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { AnimatePresence, motion } from "framer-motion"; -import { useAtomValue, useSetAtom } from "jotai"; -import { - qrScannerNotificationAtom, - removeQrScannerNotificationAtom, -} from "~/atoms/qr-scanner"; -import { Button } from "../shared/button"; - -export default function QrScannerNotification() { - const qrScannerNotification = useAtomValue(qrScannerNotificationAtom); - const removeQrScannerNotification = useSetAtom( - removeQrScannerNotificationAtom - ); - - return ( - - {qrScannerNotification ? ( - -

{qrScannerNotification.message}

- +
+ +
+
    + {/* Unavailable assets */} + +
  • + + {`${unavailableAssetsIds.length} asset${ + unavailableAssetsIds.length > 1 ? "s are" : " is" + }`} + {" "} + marked as unavailable.{" "} + {" "} +
  • +
    + + {/* Already added assets */} + +
  • + + {`${assetsAlreadyAddedIds.length} asset${ + assetsAlreadyAddedIds.length > 1 ? "s" : "" + }`} + {" "} + already added to the booking.{" "} + {" "} +
  • +
    + + {/* Assets part of kit */} + +
  • + {`${assetsPartOfKitIds.length} asset${ + assetsPartOfKitIds.length > 1 ? "s" : "" + } `} + are part of a kit.{" "} + {" "} +

    + Note: Scan Kit QR to add the full kit +

    +
  • +
    + + +
  • + {`${countKitsWithUnavailableAssets} kit${ + countKitsWithUnavailableAssets > 1 ? "s have" : " has" + } `} + unavailable assets inside{" "} + {countKitsWithUnavailableAssets > 1 ? "them" : "it"}.{" "} + {" "} +
  • +
    + + +
  • + {`${errors.length} QR codes `} + are invalid.{" "} + {" "} +
  • +
    +
+ + + + ); + } + + return [hasConflictsToResolve, Blockers] as const; +} diff --git a/app/components/scanner/drawer.tsx b/app/components/scanner/drawer.tsx index fe38ebd1e..24649676d 100644 --- a/app/components/scanner/drawer.tsx +++ b/app/components/scanner/drawer.tsx @@ -9,9 +9,7 @@ import { z } from "zod"; import type { ScanListItem } from "~/atoms/qr-scanner"; import { clearScannedItemsAtom, - removeMultipleScannedItemsAtom, removeScannedItemAtom, - removeScannedItemsByAssetIdAtom, scannedItemsAtom, updateScannedItemAtom, } from "~/atoms/qr-scanner"; @@ -20,6 +18,7 @@ import type { AssetWithBooking } from "~/routes/_layout+/bookings.$bookingId.add import type { KitForBooking } from "~/routes/_layout+/bookings.$bookingId.add-kits"; import { type loader } from "~/routes/_layout+/bookings.$bookingId.scan-assets"; import { tw } from "~/utils/tw"; +import { useBlockers } from "./blockers"; import { AvailabilityBadge } from "../booking/availability-label"; import { AssetLabel } from "../icons/library"; import { ListHeader } from "../list/list-header"; @@ -49,7 +48,6 @@ export default function ScannedAssetsDrawer({ style, isLoading, }: ScannedAssetsDrawerProps) { - const { booking } = useLoaderData(); const zo = useZorm( "AddScannedAssetsToBooking", addScannedAssetsToBookingSchema @@ -57,7 +55,6 @@ export default function ScannedAssetsDrawer({ // Get the scanned qrIds const items = useAtomValue(scannedItemsAtom); - console.log(items); const assets = Object.values(items) .filter((item) => !!item && item.data && item.type === "asset") .map((item) => item?.data as AssetWithBooking); @@ -66,11 +63,7 @@ export default function ScannedAssetsDrawer({ .filter((item) => !!item && item.data && item.type === "kit") .map((item) => item?.data as KitForBooking); - const errors = Object.entries(items).filter(([, item]) => !!item?.error); - const clearList = useSetAtom(clearScannedItemsAtom); - const removeAssetsFromList = useSetAtom(removeScannedItemsByAssetIdAtom); - const removeItemsFromList = useSetAtom(removeMultipleScannedItemsAtom); const itemsLength = Object.keys(items).length; const hasItems = itemsLength > 0; @@ -92,82 +85,10 @@ export default function ScannedAssetsDrawer({ prevItemsLengthRef.current = itemsLength; }, [expanded, itemsLength]); - /** - * Check which of tha assets are already added in the booking.assets - * Returns an array of the assetIDs that are already added - */ - const assetsAlreadyAddedIds: string[] = assets - .filter((asset) => !!asset) - .filter((asset) => booking.assets.some((a) => a?.id === asset.id)) - .map((a) => !!a && a.id); - const hasAssetsAlreadyAdded = assetsAlreadyAddedIds.length > 0; - - /** Get list of ids of the assets that are part of kit */ - const assetsPartOfKitIds: string[] = assets - .filter((asset) => !!asset && asset.kitId && asset.id) - .map((asset) => asset.id); - const hasAssetsPartOfKit = assetsPartOfKitIds.length > 0; - - /** Get assets marked as unavailable to book */ - const unavailableAssetsIds = assets - .filter((asset) => !asset.availableToBook) - .map((a) => !!a && a.id); - const hasUnavailableAssets = unavailableAssetsIds.length > 0; - - /** To get the QR ids of the kits that include unavailable assets, - * we first find the kits and - * then we we search in the items to kind the keys that hold those kits */ - const kitsWithUnavailableAssets = kits - .filter((kit) => kit.assets.some((a) => !a.availableToBook)) - .map((kit) => kit.id); - const countKitsWithUnavailableAssets = kitsWithUnavailableAssets.length; - - const qrIdsOfUnavailableKits = Object.entries(items) - .filter(([qrId, item]) => { - if (!item || item.type !== "kit") return false; - - if (kitsWithUnavailableAssets.includes((item?.data as Kit)?.id)) { - return qrId; - } - }) - .map(([qrId]) => qrId); - - const hasUnavailableAssetsInKits = countKitsWithUnavailableAssets > 0; - - /** QR codes that were scanned but are not valid to be added */ - const hasErrors = errors.length > 0; - - const hasConflictsToResolve = - hasAssetsAlreadyAdded || - hasAssetsPartOfKit || - hasErrors || - hasUnavailableAssets || - hasUnavailableAssetsInKits; - - const totalUnresolvedConflicts = - unavailableAssetsIds.length + - assetsAlreadyAddedIds.length + - assetsPartOfKitIds.length + - countKitsWithUnavailableAssets + - errors.length; - - function resolveAllConflicts() { - removeAssetsFromList([ - ...assetsAlreadyAddedIds, - ...assetsPartOfKitIds, - ...unavailableAssetsIds, - ]); - removeItemsFromList([ - ...errors.map(([qrId]) => qrId), - ...qrIdsOfUnavailableKits, - ]); - } - /** List of ids from: * - all items with type asset * - all assets attached to items with type kit * */ - const assetIdsForBooking = Array.from( new Set([ ...assets.map((a) => a.id), @@ -175,6 +96,12 @@ export default function ScannedAssetsDrawer({ ]) ); + const [hasConflictsToResolve, Blockers] = useBlockers({ + assets, + items, + kits, + }); + return (
{/* Blockers */} - - -
-
-

- ⚠️ Unresolved blockers ({totalUnresolvedConflicts}) -

-

- Resolve the issues below to continue. They are - currently blocking you from being able to confirm. -

-
- - -
- -
-
    - {/* Unavailable assets */} - -
  • - - {`${unavailableAssetsIds.length} asset${ - unavailableAssetsIds.length > 1 - ? "s are" - : " is" - }`} - {" "} - marked as unavailable.{" "} - {" "} -
  • -
    - - {/* Already added assets */} - -
  • - - {`${assetsAlreadyAddedIds.length} asset${ - assetsAlreadyAddedIds.length > 1 ? "s" : "" - }`} - {" "} - already added to the booking.{" "} - {" "} -
  • -
    - - {/* Assets part of kit */} - -
  • - {`${assetsPartOfKitIds.length} asset${ - assetsPartOfKitIds.length > 1 ? "s" : "" - } `} - are part of a kit.{" "} - {" "} -

    - Note: Scan Kit QR to add the full kit -

    -
  • -
    - - -
  • - {`${countKitsWithUnavailableAssets} kit${ - countKitsWithUnavailableAssets > 1 - ? "s have" - : " has" - } `} - unavailable assets inside{" "} - {countKitsWithUnavailableAssets > 1 ? "them" : "it"} - .{" "} - {" "} -
  • -
    - - -
  • - {`${errors.length} QR codes `} - are invalid.{" "} - {" "} -

    - Note: Scan Kit QR to add the full kit -

    -
  • -
    -
-
-
+

@@ -456,7 +236,7 @@ export default function ScannedAssetsDrawer({ @@ -580,8 +360,6 @@ function TextLoader({ text, className }: { text: string; className?: string }) { } function RowLoadingState({ qrId, error }: { qrId: string; error?: string }) { - // const items = useAtomValue(scannedItemsAtom); - // const item = items[qrId]; return (

diff --git a/app/components/zxing-scanner/zxing-scanner.tsx b/app/components/zxing-scanner/zxing-scanner.tsx index d0ef75cba..f65547419 100644 --- a/app/components/zxing-scanner/zxing-scanner.tsx +++ b/app/components/zxing-scanner/zxing-scanner.tsx @@ -5,7 +5,6 @@ import { useLoaderData, useNavigation, } from "@remix-run/react"; -import { useAtomValue } from "jotai"; import { useZxing } from "react-zxing"; import type { loader } from "~/routes/_layout+/scanner"; @@ -23,19 +22,19 @@ import Icon from "../icons/icon"; import { Spinner } from "../shared/spinner"; type ZXingScannerProps = { - onQrDetectionSuccess?: (qrId: string) => void | Promise; + onQrDetectionSuccess?: (qrId: string, error?: string) => void | Promise; videoMediaDevices?: MediaDeviceInfo[]; isLoading?: boolean; - allowDuplicateScan?: boolean; backButtonText?: string; + allowNonShelfCodes?: boolean; }; export const ZXingScanner = ({ videoMediaDevices, onQrDetectionSuccess, isLoading: incomingIsLoading, - allowDuplicateScan = false, backButtonText = "Back", + allowNonShelfCodes = false, }: ZXingScannerProps) => { const { scannerCameraId } = useLoaderData(); @@ -65,10 +64,14 @@ export const ZXingScanner = ({ const qrId = match[2]; // Get the QR id from the URL if (!isQrId(qrId)) { - return; - } - - if (!allowDuplicateScan) { + /** If we allow nonShelf codes, we just run the callback with the result from the scanner and pass an optional error to the callback function */ + if (allowNonShelfCodes) { + onQrDetectionSuccess && + void onQrDetectionSuccess( + result, + "Scanned code is not a valid Shelf QR code." + ); + } return; } @@ -106,13 +109,56 @@ export const ZXingScanner = ({

) : ( <> - - {" "} - {backButtonText} - +
+
+ + {" "} + {backButtonText} + +
+
+ { + const form = e.currentTarget; + fetcher.submit(form); + }} + > + {videoMediaDevices && videoMediaDevices?.length > 0 ? ( + + ) : null} + +
+
+
diff --git a/app/components/shared/icons-map.tsx b/app/components/shared/icons-map.tsx index 032da3190..688256d0c 100644 --- a/app/components/shared/icons-map.tsx +++ b/app/components/shared/icons-map.tsx @@ -45,6 +45,8 @@ import { ScanIcon, MapIcon, ToolIcon, + AddTagsIcon, + RemoveTagsIcon, } from "../icons/library"; /** The possible options for icons to be rendered in the button */ @@ -68,6 +70,8 @@ export type IconType = | "question" | "write" | "tag" + | "tag-remove" + | "tag-add" | "category" | "location" | "gps" @@ -122,6 +126,8 @@ export const iconsMap: IconsMap = { question: , write: , tag: , + "tag-add": , + "tag-remove": , category: , location: , gps: , diff --git a/app/components/tag/tags-autocomplete.tsx b/app/components/tag/tags-autocomplete.tsx index 30878c8b5..257030de3 100644 --- a/app/components/tag/tags-autocomplete.tsx +++ b/app/components/tag/tags-autocomplete.tsx @@ -1,27 +1,23 @@ import React, { useCallback, useEffect, useState } from "react"; -import { useLoaderData } from "@remix-run/react"; import type { Tag } from "react-tag-autocomplete"; import { ReactTags } from "react-tag-autocomplete"; -import type { loader } from "~/routes/_layout+/assets.$assetId_.edit"; -export interface Suggestion { +export interface TagSuggestion { label: string; value: string; } -export const TagsAutocomplete = ({ existingTags }: { existingTags: Tag[] }) => { +export const TagsAutocomplete = ({ + existingTags, + suggestions, +}: { + existingTags: Tag[]; + suggestions: TagSuggestion[]; +}) => { /* This is a workaround for the SSR issue with react-tag-autocomplete */ if (typeof document === "undefined") { React.useLayoutEffect = React.useEffect; } - - /** Get the tags from the loader */ - - const suggestions = useLoaderData().tags.map((tag) => ({ - label: tag.name, - value: tag.id, - })); - const [selected, setSelected] = useState([]); useEffect(() => { diff --git a/app/modules/asset/service.server.ts b/app/modules/asset/service.server.ts index da7b22b0f..016fdccad 100644 --- a/app/modules/asset/service.server.ts +++ b/app/modules/asset/service.server.ts @@ -2651,3 +2651,54 @@ export async function bulkUpdateAssetCategory({ }); } } + +export async function bulkAssignAssetTags({ + userId, + assetIds, + organizationId, + tagsIds, + currentSearchParams, + remove, +}: { + userId: string; + assetIds: Asset["id"][]; + organizationId: Asset["organizationId"]; + tagsIds: string[]; + currentSearchParams?: string | null; + remove: boolean; +}) { + try { + const shouldUpdateAll = assetIds.includes(ALL_SELECTED_KEY); + let _assetIds = assetIds; + + if (shouldUpdateAll) { + const allOrgAssetIds = await db.asset.findMany({ + where: getAssetsWhereInput({ organizationId, currentSearchParams }), + select: { id: true }, + }); + _assetIds = allOrgAssetIds.map((a) => a.id); + } + + const updatePromises = _assetIds.map((id) => + db.asset.update({ + where: { id }, + data: { + tags: { + [remove ? "disconnect" : "connect"]: tagsIds.map((id) => ({ id })), // IDs of tags you want to connect + }, + }, + }) + ); + + await Promise.all(updatePromises); + + return true; + } catch (cause) { + throw new ShelfError({ + cause, + message: "Something went wrong while bulk updating category.", + additionalData: { userId, assetIds, organizationId, tagsIds }, + label, + }); + } +} diff --git a/app/routes/api+/assets.bulk-assign-tags.ts b/app/routes/api+/assets.bulk-assign-tags.ts new file mode 100644 index 000000000..1298cd1ac --- /dev/null +++ b/app/routes/api+/assets.bulk-assign-tags.ts @@ -0,0 +1,64 @@ +import { json, type ActionFunctionArgs } from "@remix-run/node"; +import { BulkAssignTagsSchema } from "~/components/assets/bulk-assign-tags-dialog"; +import { bulkAssignAssetTags } from "~/modules/asset/service.server"; +import { CurrentSearchParamsSchema } from "~/modules/asset/utils.server"; +import { sendNotification } from "~/utils/emitter/send-notification.server"; +import { makeShelfError } from "~/utils/error"; +import { + assertIsPost, + data, + error, + getCurrentSearchParams, + parseData, +} from "~/utils/http.server"; +import { + PermissionAction, + PermissionEntity, +} from "~/utils/permissions/permission.data"; +import { requirePermission } from "~/utils/roles.server"; + +export async function action({ context, request }: ActionFunctionArgs) { + const authSession = context.getSession(); + const userId = authSession.userId; + + try { + assertIsPost(request); + const searchParams = getCurrentSearchParams(request); + const remove = searchParams.get("remove") === "true"; + + const formData = await request.formData(); + + const { organizationId } = await requirePermission({ + userId, + request, + entity: PermissionEntity.asset, + action: PermissionAction.update, + }); + + const { assetIds, tags, currentSearchParams } = parseData( + formData, + BulkAssignTagsSchema.and(CurrentSearchParamsSchema) + ); + + await bulkAssignAssetTags({ + userId, + assetIds, + tagsIds: tags?.split(","), + organizationId, + currentSearchParams, + remove, + }); + + sendNotification({ + title: "Assets updated", + message: "Your asset's categories have been successfully updated", + icon: { name: "success", variant: "success" }, + senderId: authSession.userId, + }); + + return json(data({ success: true })); + } catch (cause) { + const reason = makeShelfError(cause, { userId }); + return json(error(reason), { status: reason.status }); + } +} diff --git a/app/styles/global.css b/app/styles/global.css index 9ac41d02d..2f88b69d7 100644 --- a/app/styles/global.css +++ b/app/styles/global.css @@ -316,3 +316,11 @@ dialog { clip-path: inset(0 -1ch 0 0); } } + +.bulk-tagging-dialog .react-tags__listbox { + z-index: 1000; +} + +.bulk-tagging-dialog .dialog-body { + @apply overflow-visible; +} From 5599ee3f7be686a45e1991d6df2e16b6e0531e35 Mon Sep 17 00:00:00 2001 From: Nikolay Bonev Date: Thu, 19 Sep 2024 11:55:05 +0300 Subject: [PATCH 53/53] fix: error with selecting all NRM --- app/components/nrm/bulk-actions-dropdown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/nrm/bulk-actions-dropdown.tsx b/app/components/nrm/bulk-actions-dropdown.tsx index c7f885568..03e00d457 100644 --- a/app/components/nrm/bulk-actions-dropdown.tsx +++ b/app/components/nrm/bulk-actions-dropdown.tsx @@ -40,7 +40,7 @@ function ConditionalDropdown() { const actionsButtonDisabled = selectedNRMs.length === 0; const someNRMHasCustody = selectedNRMs.some( - (nrm) => nrm._count.custodies > 0 + (nrm) => nrm?._count?.custodies > 0 ); const {