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/59] 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 75084480..a484b4cc 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 641b6f23..1db68da7 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/59] 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 a484b4cc..50387bbe 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/59] 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 11135f56..75ca846d 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 a0a57d81..756b510a 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 50073aba..b5189ac6 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 00000000..7acf0329 --- /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/59] 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 75ca846d..b1591a0d 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/59] 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 00000000..220d3c52 --- /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 5d90c9fb..23028d0f 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 2c646a37..903e48d0 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/59] 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 220d3c52..5a40d218 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 39edddda..82c5dbb6 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 00000000..9e64a7e0 --- /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/59] 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 5a40d218..f1c399e0 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 82c5dbb6..e593d04c 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 9e64a7e0..8a5228e7 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/59] 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 fc802680..cab972f6 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 46cd484b..2a15bf17 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 b2d5da03..1fe8517f 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 f1690dba..7c3f3cac 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 5f209c57..dc3e4246 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 0339acf9..5122dda7 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 6bd3ede6..a5778ed6 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 a8cfd188..f608cc40 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 e391318a..6d438668 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 c74d6854..b2d09775 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 1fe7a672..2dca2871 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 1680cb56..56e24708 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 c4712306..ab69974c 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 13ed9402..f09a12b1 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 e9d8dc41..f32d2843 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 a9e3c8d0..1f32f184 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 1afa10d2..7c4b552f 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 8bbf2050..6dde5c03 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 cd6afaf2..3b351693 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 bcf6eda4..c6e8a85e 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 6f589ea0..9210b5be 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 347e3e7d..bfe3aec0 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 6ac80081..4787f961 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 a5d40ca0..c2ff8bf2 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 18f36ac6..f253697a 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 5fb2dffa..92d69c0c 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 00000000..008afc96 --- /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 8b463b45..272a3f5d 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 f2367497..91704a7c 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 60ccb1da..47f071bc 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 d14a4a8b..4dcac54e 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 7e89db1c..7a81bdce 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 d8bd8e33..fae59ac8 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 00000000..595de21e --- /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 40cfe93b..6c1fc8a2 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/59] 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 fd70f012..1a571409 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 cfcb498c..35450413 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 e593d04c..2fb856d7 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 8a5228e7..7a5e3f9b 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/59] 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 00000000..f64d74f9 --- /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 00000000..e18f911b --- /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 35450413..f202827f 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 2fb856d7..824a8fca 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/59] 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 f64d74f9..6658cdee 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 e18f911b..16665888 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/59] 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 16665888..806c8a00 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 22815140..fbf51fad 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 824a8fca..ec725cee 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 7a5e3f9b..19de05ee 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/59] 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 77a481f2..41dbe555 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 f202827f..9b048e79 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/59] 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 806c8a00..e25ab241 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 9b048e79..0d9b3911 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 1dacfafa..6de81b05 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/59] 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 c08d0619..ac09f55d 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 e25ab241..dd22cf98 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 16449d3d..1795be4f 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 a2471738..e2e6c441 100644 --- a/app/components/zxing-scanner.tsx +++ b/app/components/zxing-scanner.tsx @@ -100,7 +100,7 @@ export const ZXingScanner = ({ { label: string; name: string; - disabled: boolean; + disabled?: boolean; placeholder: string; defaultValue: string; className?: string; - rest?: TextareaHTMLAttributes; } export const markdownAtom = atom(""); @@ -83,7 +82,6 @@ export const MarkdownEditor = forwardRef(function MarkdownEditor( {...rest} />
- {" "} This field supports{" "} markdown - {" "} +
diff --git a/app/modules/asset/service.server.ts b/app/modules/asset/service.server.ts index 5f09d948..168eb008 100644 --- a/app/modules/asset/service.server.ts +++ b/app/modules/asset/service.server.ts @@ -2303,7 +2303,7 @@ export async function bulkDeleteAssets({ }) ) ); - } catch(cause) { + } catch (cause) { throw new ShelfError({ cause, message: diff --git a/app/routes/_layout+/admin-dashboard+/announcements.new.tsx b/app/routes/_layout+/admin-dashboard+/announcements.new.tsx index bf5a4c12..f0cce4ed 100644 --- a/app/routes/_layout+/admin-dashboard+/announcements.new.tsx +++ b/app/routes/_layout+/admin-dashboard+/announcements.new.tsx @@ -73,10 +73,10 @@ export default function NewAnnouncement() { Announcement Content Date: Sat, 24 Aug 2024 07:22:52 +0530 Subject: [PATCH 28/59] fix(markdown-editor): add hidden input to persist markdown value --- app/components/markdown/markdown-editor.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/components/markdown/markdown-editor.tsx b/app/components/markdown/markdown-editor.tsx index 469e4584..32d059ca 100644 --- a/app/components/markdown/markdown-editor.tsx +++ b/app/components/markdown/markdown-editor.tsx @@ -67,11 +67,14 @@ export const MarkdownEditor = forwardRef(function MarkdownEditor( Edit Preview + + {/* Having this hidden input so that the value persists even if the tab changes */} + + Date: Sat, 24 Aug 2024 07:46:40 +0530 Subject: [PATCH 29/59] feat(multiline-cf): rendering multiline custom field value as markdown on asset overview page --- app/modules/asset/types.ts | 3 +- .../_layout+/assets.$assetId.overview.tsx | 64 +++++++++++++++---- 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/app/modules/asset/types.ts b/app/modules/asset/types.ts index e95296c5..c11e6eb9 100644 --- a/app/modules/asset/types.ts +++ b/app/modules/asset/types.ts @@ -1,3 +1,4 @@ +import type { RenderableTreeNode } from "@markdoc/markdoc"; import type { Asset, AssetCustomFieldValue, @@ -11,7 +12,7 @@ export interface ICustomFieldValueJson { valueBoolean?: boolean; valueDate?: string; valueOption?: string; - valueMultiLineText?: string; + valueMultiLineText?: RenderableTreeNode; } export type ShelfAssetCustomFieldValueType = Omit< diff --git a/app/routes/_layout+/assets.$assetId.overview.tsx b/app/routes/_layout+/assets.$assetId.overview.tsx index 835ed8ec..6cf23a3e 100644 --- a/app/routes/_layout+/assets.$assetId.overview.tsx +++ b/app/routes/_layout+/assets.$assetId.overview.tsx @@ -1,3 +1,5 @@ +import type { Prisma } from "@prisma/client"; +import { CustomFieldType } from "@prisma/client"; import type { MetaFunction, ActionFunctionArgs, @@ -14,6 +16,7 @@ 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 { MarkdownViewer } from "~/components/markdown/markdown-viewer"; import { QrPreview } from "~/components/qr/qr-preview"; import { Badge } from "~/components/shared/badge"; @@ -42,6 +45,7 @@ import { sendNotification } from "~/utils/emitter/send-notification.server"; import { makeShelfError } from "~/utils/error"; import { isFormProcessing } from "~/utils/form"; import { error, getParams, data, parseData } from "~/utils/http.server"; +import { parseMarkdownToReact } from "~/utils/md.server"; import { isLink } from "~/utils/misc"; import { PermissionAction, @@ -129,6 +133,33 @@ export async function loader({ context, request, params }: LoaderFunctionArgs) { asset.bookings = [currentBooking]; } + /** We only need customField with same category of asset or without any category */ + let customFields = asset.categoryId + ? asset.customFields.filter( + (cf) => + !cf.customField.categories.length || + cf.customField.categories + .map((c) => c.id) + .includes(asset.categoryId!) + ) + : asset.customFields; + + customFields = customFields.map((cf) => { + if (cf.customField.type !== CustomFieldType.MULTILINE_TEXT) { + return cf; + } + + const value = cf.value as { raw: string }; + + return { + ...cf, + value: { + ...value, + valueMultiLineText: parseMarkdownToReact(value.raw), + } as Prisma.JsonValue, + }; + }); + const header: HeaderData = { title: `${asset.title}'s overview`, }; @@ -142,16 +173,7 @@ export async function loader({ context, request, params }: LoaderFunctionArgs) { timeStyle: "short", }).format(asset.createdAt), custody, - /** We only need customField with same category of asset or without any category */ - customFields: asset.categoryId - ? asset.customFields.filter( - (cf) => - !cf.customField.categories.length || - cf.customField.categories - .map((c) => c.id) - .includes(asset.categoryId!) - ) - : asset.customFields, + customFields, }, lastScan, header, @@ -350,10 +372,14 @@ export default function AssetOverview() {
    {customFieldsValues.map((field, _index) => { + const fieldValue = + field.value as unknown as ShelfAssetCustomFieldValueType["value"]; + const customFieldDisplayValue = getCustomFieldDisplayValue( - field.value as unknown as ShelfAssetCustomFieldValueType["value"], + fieldValue, { locale, timeZone } ); + return (
  • {field.customField.name} -
    - {isLink(customFieldDisplayValue) ? ( +
    + {field.customField.type === + CustomFieldType.MULTILINE_TEXT && + fieldValue?.valueMultiLineText ? ( + + ) : isLink(customFieldDisplayValue) ? ( diff --git a/app/components/zxing-scanner/zxing-scanner.tsx b/app/components/zxing-scanner/zxing-scanner.tsx index f72811a2..bc0520f0 100644 --- a/app/components/zxing-scanner/zxing-scanner.tsx +++ b/app/components/zxing-scanner/zxing-scanner.tsx @@ -2,8 +2,10 @@ import { useFetcher, useLoaderData, useNavigation } from "@remix-run/react"; 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"; @@ -45,6 +47,9 @@ export const ZXingScanner = ({ const scannedQrIds = useAtomValue(scannedQrIdsAtom); const addScannedQrId = useSetAtom(addScannedQrIdAtom); + const errorShownQrIds = useAtomValue(errorShownQrIdsAtom); + const addQrIdToErrorShown = useSetAtom(addQrIdToErrorShownAtom); + const displayQrNotification = useSetAtom(displayQrScannerNotificationAtom); // Function to decode the QR code @@ -72,8 +77,13 @@ export const ZXingScanner = ({ return; } + if (!allowDuplicateScan && errorShownQrIds.includes(qrId)) { + return; + } + if (!allowDuplicateScan && scannedQrIds.includes(qrId)) { - displayQrNotification({ message: "Asset is already scanned." }); + displayQrNotification({ message: "QR is already scanned." }); + addQrIdToErrorShown(qrId); return; } From 39f011ba95697b7caee1b832616be17cb9bfe6c8 Mon Sep 17 00:00:00 2001 From: rockingrohit9639 Date: Sat, 24 Aug 2024 08:27:24 +0530 Subject: [PATCH 31/59] feat(scanner): add vibration if qr is detected successfully --- app/components/zxing-scanner/zxing-scanner.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/components/zxing-scanner/zxing-scanner.tsx b/app/components/zxing-scanner/zxing-scanner.tsx index bc0520f0..d37ad080 100644 --- a/app/components/zxing-scanner/zxing-scanner.tsx +++ b/app/components/zxing-scanner/zxing-scanner.tsx @@ -87,6 +87,11 @@ export const ZXingScanner = ({ return; } + /** At this point, a QR is successfully detected, so we can vibrate user's device for feedback */ + if (typeof navigator.vibrate === "function") { + navigator.vibrate(200); + } + void onQrDetectionSuccess(qrId); if (!allowDuplicateScan) { From 75d9189bec127c850db19f0d0da4441cc8284da2 Mon Sep 17 00:00:00 2001 From: rockingrohit9639 Date: Sat, 24 Aug 2024 09:25:27 +0530 Subject: [PATCH 32/59] feat(scan-assets): fix some minor style issues --- app/atoms/bookings.ts | 11 +++++++-- app/components/booking/availability-label.tsx | 23 ++++++++++--------- .../booking/scanned-assets-drawer.tsx | 6 ++++- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/app/atoms/bookings.ts b/app/atoms/bookings.ts index 30999115..9188bb99 100644 --- a/app/atoms/bookings.ts +++ b/app/atoms/bookings.ts @@ -1,6 +1,6 @@ import { atom } from "jotai"; -import invariant from "tiny-invariant"; 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. */ @@ -48,7 +48,14 @@ export const removeFetchedScannedAssetAtom = atom( (asset) => asset.id === update ); - invariant(removedAsset, "Asset not found"); // this should not happen in ideal case + /** 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) diff --git a/app/components/booking/availability-label.tsx b/app/components/booking/availability-label.tsx index 61c042f8..8d9a9e1b 100644 --- a/app/components/booking/availability-label.tsx +++ b/app/components/booking/availability-label.tsx @@ -36,6 +36,18 @@ export function AvailabilityLabel({ const isPartOfKit = !!asset.kitId; const { booking } = useLoaderData<{ booking: Booking }>(); + + /** User scanned the asset and it is already in booking */ + if (isAlreadyAdded) { + return ( + + ); + } + /** * Marked as not allowed for booking */ @@ -150,17 +162,6 @@ export function AvailabilityLabel({ ); } - /** User scanned the asset and it is already in booking */ - if (isAlreadyAdded) { - return ( - - ); - } - return null; } diff --git a/app/components/booking/scanned-assets-drawer.tsx b/app/components/booking/scanned-assets-drawer.tsx index 30d1c990..c0fd02f5 100644 --- a/app/components/booking/scanned-assets-drawer.tsx +++ b/app/components/booking/scanned-assets-drawer.tsx @@ -40,6 +40,9 @@ export const addScannedAssetsToBookingSchema = z.object({ assetIds: z.array(z.string()).min(1), }); +const DRAWER_OFFSET = 390; +const TOP_GAP = 80 + 53 + 8 + 16; + export default function ScannedAssetsDrawer({ className, style, @@ -59,7 +62,7 @@ export default function ScannedAssetsDrawer({ * At snap point 1 we need to show 1 item * At snap point 2 we need to show the full list */ - const SNAP_POINTS = [`${80 + 53 + 8 + 16}px`, `${vh - 153}px`]; + const SNAP_POINTS = [`${vh - (TOP_GAP + DRAWER_OFFSET)}px`, 1]; const [snap, setSnap] = useState(SNAP_POINTS[0]); @@ -204,6 +207,7 @@ export default function ScannedAssetsDrawer({ ) : ( - customFieldDisplayValue + (customFieldDisplayValue as string) )}
  • diff --git a/app/routes/_layout+/dashboard.tsx b/app/routes/_layout+/dashboard.tsx index 13623f06..3aebc73f 100644 --- a/app/routes/_layout+/dashboard.tsx +++ b/app/routes/_layout+/dashboard.tsx @@ -35,7 +35,7 @@ import { } from "~/utils/dashboard.server"; import { ShelfError, makeShelfError } from "~/utils/error"; import { data, error } from "~/utils/http.server"; -import { parseMarkdownToReact } from "~/utils/md.server"; +import { parseMarkdownToReact } from "~/utils/md"; import { PermissionAction, PermissionEntity, diff --git a/app/routes/api+/utils.parse-markdown.ts b/app/routes/api+/utils.parse-markdown.ts index 5a5474a8..6388602b 100644 --- a/app/routes/api+/utils.parse-markdown.ts +++ b/app/routes/api+/utils.parse-markdown.ts @@ -1,7 +1,7 @@ import { json, type ActionFunctionArgs } from "@remix-run/node"; import { makeShelfError } from "~/utils/error"; import { assertIsPost, data, error } from "~/utils/http.server"; -import { parseMarkdownToReact } from "~/utils/md.server"; +import { parseMarkdownToReact } from "~/utils/md"; export async function action({ context, request }: ActionFunctionArgs) { const authSession = context.getSession(); diff --git a/app/utils/custom-fields.ts b/app/utils/custom-fields.ts index 94f6dc6b..35310b88 100644 --- a/app/utils/custom-fields.ts +++ b/app/utils/custom-fields.ts @@ -1,3 +1,4 @@ +import type { RenderableTreeNode } from "@markdoc/markdoc"; import type { CustomField, CustomFieldType } from "@prisma/client"; import { format } from "date-fns"; import type { ZodRawShape } from "zod"; @@ -6,6 +7,7 @@ import type { ShelfAssetCustomFieldValueType } from "~/modules/asset/types"; import type { ClientHint } from "~/modules/booking/types"; import { formatDateBasedOnLocaleOnly } from "./client-hints"; import { ShelfError } from "./error"; +import { parseMarkdownToReact } from "./md"; /** Returns the schema depending on the field type. * Also handles the required field error message. @@ -181,7 +183,11 @@ export const buildCustomFieldValue = ( export const getCustomFieldDisplayValue = ( value: ShelfAssetCustomFieldValueType["value"], hints?: ClientHint -): string => { +): string | RenderableTreeNode => { + if (value.valueMultiLineText) { + return parseMarkdownToReact(value.raw as string); + } + if (value.valueDate && value.raw) { return hints ? formatDateBasedOnLocaleOnly(value.raw as string, hints.locale) diff --git a/app/utils/md.server.ts b/app/utils/md.ts similarity index 100% rename from app/utils/md.server.ts rename to app/utils/md.ts From 1215edce9a952f9e9b74ee89d7220a0cc603103b Mon Sep 17 00:00:00 2001 From: rockingrohit9639 Date: Sat, 31 Aug 2024 08:35:00 +0530 Subject: [PATCH 34/59] feat(multiline-cf): add maxLength prop in markdown-editor --- .../assets/custom-fields-inputs.tsx | 1 + app/components/markdown/markdown-editor.tsx | 29 ++++++++++++------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/app/components/assets/custom-fields-inputs.tsx b/app/components/assets/custom-fields-inputs.tsx index 07e46b8b..389b8077 100644 --- a/app/components/assets/custom-fields-inputs.tsx +++ b/app/components/assets/custom-fields-inputs.tsx @@ -186,6 +186,7 @@ export default function AssetCustomFields({ defaultValue={typeof value === "string" ? value : ""} placeholder={field.helpText ?? field.name} disabled={disabled} + maxLength={5000} /> {error ? (

    {error}

    diff --git a/app/components/markdown/markdown-editor.tsx b/app/components/markdown/markdown-editor.tsx index 32d059ca..d4b1fcc4 100644 --- a/app/components/markdown/markdown-editor.tsx +++ b/app/components/markdown/markdown-editor.tsx @@ -30,6 +30,7 @@ export const MarkdownEditor = forwardRef(function MarkdownEditor( placeholder, defaultValue, className, + maxLength, ...rest }: Props, ref @@ -82,18 +83,26 @@ export const MarkdownEditor = forwardRef(function MarkdownEditor( hideLabel inputClassName={tw("text-text-md", className)} ref={ref} + maxLength={maxLength} {...rest} /> -
    - This field supports{" "} - - markdown - +
    +

    + This field supports{" "} + + markdown + +

    + {maxLength ? ( +

    + {markdown.length}/{maxLength} +

    + ) : null}
    From 489f6ce5a1c7d2cc818330184b4b770fee51af4c Mon Sep 17 00:00:00 2001 From: Nikolay Bonev Date: Mon, 2 Sep 2024 16:48:04 +0300 Subject: [PATCH 35/59] updating react-router-hono-server --- package-lock.json | 167 ++++++++++++++++++++++++++-------------------- package.json | 2 +- 2 files changed, 96 insertions(+), 73 deletions(-) diff --git a/package-lock.json b/package-lock.json index c2e02877..9855545b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,7 +66,7 @@ "react-map-gl": "^7.1.7", "react-media-devices": "^1.1.5", "react-microsoft-clarity": "^1.2.0", - "react-router-hono-server": "https://pkg.pr.new/rphlmr/react-router-hono-server@6", + "react-router-hono-server": "^0.1.0", "react-tag-autocomplete": "^7.1.0", "react-to-print": "^2.15.1", "react-zorm": "^0.9.0", @@ -916,9 +916,9 @@ "dev": true }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20240806.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20240806.0.tgz", - "integrity": "sha512-FqcVBBCO//I39K5F+HqE/v+UkqY1UrRnS653Jv+XsNNH9TpX5fTs7VCKG4kDSnmxlAaKttyIN5sMEt7lpuNExQ==", + "version": "1.20240821.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20240821.1.tgz", + "integrity": "sha512-CDBpfZKrSy4YrIdqS84z67r3Tzal2pOhjCsIb63IuCnvVes59/ft1qhczBzk9EffeOE2iTCrA4YBT7Sbn7USew==", "cpu": [ "x64" ], @@ -932,9 +932,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20240806.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20240806.0.tgz", - "integrity": "sha512-8c3KvmzYp/wg+82KHSOzDetJK+pThH4MTrU1OsjmsR2cUfedm5dk5Lah9/0Ld68+6A0umFACi4W2xJHs/RoBpA==", + "version": "1.20240821.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20240821.1.tgz", + "integrity": "sha512-Q+9RedvNbPcEt/dKni1oN94OxbvuNAeJkgHmrLFTGF8zu21wzOhVkQeRNxcYxrMa9mfStc457NAg13OVCj2kHQ==", "cpu": [ "arm64" ], @@ -948,9 +948,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20240806.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20240806.0.tgz", - "integrity": "sha512-/149Bpxw4e2p5QqnBc06g0mx+4sZYh9j0doilnt0wk/uqYkLp0DdXGMQVRB74sBLg2UD3wW8amn1w3KyFhK2tQ==", + "version": "1.20240821.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20240821.1.tgz", + "integrity": "sha512-j6z3KsPtawrscoLuP985LbqFrmsJL6q1mvSXOXTqXGODAHIzGBipHARdOjms3UQqovzvqB2lQaQsZtLBwCZxtA==", "cpu": [ "x64" ], @@ -964,9 +964,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20240806.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20240806.0.tgz", - "integrity": "sha512-lacDWY3S1rKL/xT6iMtTQJEKmTTKrBavPczInEuBFXElmrS6IwVjZwv8hhVm32piyNt/AuFu9BYoJALi9D85/g==", + "version": "1.20240821.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20240821.1.tgz", + "integrity": "sha512-I9bHgZOxJQW0CV5gTdilyxzTG7ILzbTirehQWgfPx9X77E/7eIbR9sboOMgyeC69W4he0SKtpx0sYZuTJu4ERw==", "cpu": [ "arm64" ], @@ -980,9 +980,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20240806.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20240806.0.tgz", - "integrity": "sha512-hC6JEfTSQK6//Lg+D54TLVn1ceTPY+fv4MXqDZIYlPP53iN+dL8Xd0utn2SG57UYdlL5FRAhm/EWHcATZg1RgA==", + "version": "1.20240821.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20240821.1.tgz", + "integrity": "sha512-keC97QPArs6LWbPejQM7/Y8Jy8QqyaZow4/ZdsGo+QjlOLiZRDpAenfZx3CBUoWwEeFwQTl2FLO+8hV1SWFFYw==", "cpu": [ "x64" ], @@ -999,6 +999,7 @@ "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.9" }, @@ -1010,6 +1011,7 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" @@ -1534,6 +1536,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "license": "MIT", "engines": { "node": ">=14" } @@ -1674,6 +1677,35 @@ "tailwindcss": "^3.0" } }, + "node_modules/@hono/node-server": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.12.2.tgz", + "integrity": "sha512-xjzhqhSWUE/OhN0g3KCNVzNsQMlFUAL+/8GgPUr3TKcU7cvgZVBGswFofJ8WwGEHTqobzze1lDpGJl9ZNckDhA==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@hono/vite-dev-server": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@hono/vite-dev-server/-/vite-dev-server-0.13.1.tgz", + "integrity": "sha512-AK0yQkGwoQvcbf0MeBDDslVnVvzlKjAT00Z2A4Oqyh0RYhLQyCT1aSJzz3eZj7tYytG0B6EGt92DjrkB0tsy1A==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.12.0", + "miniflare": "^3.20240701.0", + "minimatch": "^9.0.3" + }, + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "*" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -8751,6 +8783,7 @@ "version": "1.0.55", "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==", + "license": "MIT", "dependencies": { "printable-characters": "^1.0.42" } @@ -9321,6 +9354,7 @@ "version": "0.7.0", "resolved": "https://registry.npmjs.org/capnp-ts/-/capnp-ts-0.7.0.tgz", "integrity": "sha512-XKxXAC3HVPv7r674zP0VC3RTXz+/JKhfyw94ljvF80yynK6VkTnqE3jMuN8b3dUVmmc43TjyxjW4KTsmB3c86g==", + "license": "MIT", "dependencies": { "debug": "^4.3.1", "tslib": "^2.2.0" @@ -12668,6 +12702,7 @@ "version": "2.0.12", "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", "integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==", + "license": "Unlicense", "dependencies": { "data-uri-to-buffer": "^2.0.0", "source-map": "^0.6.1" @@ -12676,12 +12711,14 @@ "node_modules/get-source/node_modules/data-uri-to-buffer": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", - "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==" + "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", + "license": "MIT" }, "node_modules/get-source/node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -12810,7 +12847,8 @@ "node_modules/glob-to-regexp": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==" + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "license": "BSD-2-Clause" }, "node_modules/global-prefix": { "version": "3.0.0", @@ -13087,9 +13125,9 @@ } }, "node_modules/hono": { - "version": "4.5.8", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.5.8.tgz", - "integrity": "sha512-pqpSlcdqGkpTTRpLYU1PnCz52gVr0zVR9H5GzMyJWuKQLLEBQxh96q45QizJ2PPX8NATtz2mu31/PKW/Jt+90Q==", + "version": "4.5.10", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.5.10.tgz", + "integrity": "sha512-az6tdU1U8o/l0v8O37FIQuc+ER/TeD9vHt/qs8JnBDgMxw6Zu5L2AixUyHeXZNcu87r7iYo8Ey85R7IogOINKA==", "license": "MIT", "engines": { "node": ">=16.0.0" @@ -15745,9 +15783,9 @@ } }, "node_modules/miniflare": { - "version": "3.20240806.1", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-3.20240806.1.tgz", - "integrity": "sha512-wJq3YQYx9k83L2CNYtxvwWvXSi+uHrC6aFoXYSbzhxIDlUWvMEqippj+3HeKLgsggC31nHJab3b1Pifg9IxIFQ==", + "version": "3.20240821.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-3.20240821.0.tgz", + "integrity": "sha512-4BhLGpssQxM/O6TZmJ10GkT3wBJK6emFkZ3V87/HyvQmVt8zMxEBvyw5uv6kdtp+7F54Nw6IKFJjPUL8rFVQrQ==", "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", @@ -15758,7 +15796,7 @@ "glob-to-regexp": "^0.4.1", "stoppable": "^1.1.0", "undici": "^5.28.4", - "workerd": "1.20240806.0", + "workerd": "1.20240821.1", "ws": "^8.17.1", "youch": "^3.2.2", "zod": "^3.22.3" @@ -15771,9 +15809,10 @@ } }, "node_modules/miniflare/node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "license": "MIT", "engines": { "node": ">=10.0.0" }, @@ -16118,6 +16157,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "license": "MIT", "bin": { "mustache": "bin/mustache" } @@ -18012,6 +18052,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/pretty-cache-header/-/pretty-cache-header-1.0.0.tgz", "integrity": "sha512-xtXazslu25CdnGnUkByU1RoOjK55TqwatJkjjJLg5ZAdz2Lngko/mmaUgeET36P2GMlNwh3fdM7FWBO717pNcw==", + "license": "MIT", "dependencies": { "timestring": "^6.0.0" }, @@ -18069,7 +18110,8 @@ "node_modules/printable-characters": { "version": "1.0.42", "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", - "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==" + "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==", + "license": "Unlicense" }, "node_modules/prisma": { "version": "5.15.1", @@ -18667,9 +18709,9 @@ } }, "node_modules/react-router-hono-server": { - "version": "0.0.5", - "resolved": "https://pkg.pr.new/rphlmr/react-router-hono-server@6", - "integrity": "sha512-bwiWhHe0Vbi4W3dXtd4eNJmOgzyvdoUvUibWeCECWovNfA//F3bs6YX83FAOkfoIhl3XhAVWgU1uwcC5VeS/rw==", + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/react-router-hono-server/-/react-router-hono-server-0.1.0.tgz", + "integrity": "sha512-kxEczgoER4ci1XxMtBhcKMGLbScdcH2TFur7kMcBixHrB9CfNX4GkwLFGvact6Z1GxhtHMvTM5af7xFUljOJ2g==", "license": "ISC", "workspaces": [ ".", @@ -18698,32 +18740,6 @@ "vite": "^5.0.0" } }, - "node_modules/react-router-hono-server/node_modules/@hono/node-server": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.12.1.tgz", - "integrity": "sha512-C9l+08O8xtXB7Ppmy8DjBFH1hYji7JKzsU32Yt1poIIbdPp6S7aOI8IldDHD9YFJ55lv2c21ovNrmxatlHfhAg==", - "license": "MIT", - "engines": { - "node": ">=18.14.1" - } - }, - "node_modules/react-router-hono-server/node_modules/@hono/vite-dev-server": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/@hono/vite-dev-server/-/vite-dev-server-0.13.1.tgz", - "integrity": "sha512-AK0yQkGwoQvcbf0MeBDDslVnVvzlKjAT00Z2A4Oqyh0RYhLQyCT1aSJzz3eZj7tYytG0B6EGt92DjrkB0tsy1A==", - "license": "MIT", - "dependencies": { - "@hono/node-server": "^1.12.0", - "miniflare": "^3.20240701.0", - "minimatch": "^9.0.3" - }, - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "*" - } - }, "node_modules/react-router-hono-server/node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.18.1.tgz", @@ -18738,9 +18754,9 @@ ] }, "node_modules/react-router-hono-server/node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.0.tgz", - "integrity": "sha512-e2hrvElFIh6kW/UNBQK/kzqMNY5mO+67YtEh9OA65RM5IJXYTWiXjX6fjIiPaqOkBthYF1EqgiZ6OXKcQsM0hg==", + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.2.tgz", + "integrity": "sha512-B90tYAUoLhU22olrafY3JQCFLnT3NglazdwkHyxNDYF/zAxJt5fJUB/yBoWFoIQ7SQj+KLe3iL4BhOMa9fzgpw==", "cpu": [ "x64" ], @@ -18751,9 +18767,9 @@ ] }, "node_modules/react-router-hono-server/node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.0.tgz", - "integrity": "sha512-1vvmgDdUSebVGXWX2lIcgRebqfQSff0hMEkLJyakQ9JQUbLDkEaMsPTLOmyccyC6IJ/l3FZuJbmrBw/u0A0uCQ==", + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.2.tgz", + "integrity": "sha512-7twFizNXudESmC9oneLGIUmoHiiLppz/Xs5uJQ4ShvE6234K0VB1/aJYU3f/4g7PhssLGKBVCC37uRkkOi8wjg==", "cpu": [ "x64" ], @@ -19240,6 +19256,7 @@ "url": "https://github.com/sponsors/sergiodxa" } ], + "license": "MIT", "dependencies": { "@remix-run/server-runtime": "^2.6.0", "hono": "^4.0.0", @@ -20203,6 +20220,7 @@ "version": "2.1.8", "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.1.8.tgz", "integrity": "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw==", + "license": "Unlicense", "dependencies": { "as-table": "^1.0.36", "get-source": "^2.0.12" @@ -20262,6 +20280,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", + "license": "MIT", "engines": { "node": ">=4", "npm": ">=6" @@ -21012,6 +21031,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/timestring/-/timestring-6.0.0.tgz", "integrity": "sha512-wMctrWD2HZZLuIlchlkE2dfXJh7J2KDI9Dwl+2abPYg0mswQHfOAyQW3jJg1pY5VfttSINZuKcXoB3FGypVklA==", + "license": "MIT", "engines": { "node": ">=8" } @@ -21824,6 +21844,7 @@ "version": "5.28.4", "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", "integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==", + "license": "MIT", "dependencies": { "@fastify/busboy": "^2.0.0" }, @@ -23130,9 +23151,9 @@ } }, "node_modules/workerd": { - "version": "1.20240806.0", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20240806.0.tgz", - "integrity": "sha512-yyNtyzTMgVY0sgYijHBONqZFVXsOFGj2jDjS8MF/RbO2ZdGROvs4Hkc/9QnmqFWahE0STxXeJ1yW1yVotdF0UQ==", + "version": "1.20240821.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20240821.1.tgz", + "integrity": "sha512-y4phjCnEG96u8ZkgkkHB+gSw0i6uMNo23rBmixylWpjxDklB+LWD8dztasvsu7xGaZbLoTxQESdEw956F7VJDA==", "hasInstallScript": true, "license": "Apache-2.0", "bin": { @@ -23142,11 +23163,11 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20240806.0", - "@cloudflare/workerd-darwin-arm64": "1.20240806.0", - "@cloudflare/workerd-linux-64": "1.20240806.0", - "@cloudflare/workerd-linux-arm64": "1.20240806.0", - "@cloudflare/workerd-windows-64": "1.20240806.0" + "@cloudflare/workerd-darwin-64": "1.20240821.1", + "@cloudflare/workerd-darwin-arm64": "1.20240821.1", + "@cloudflare/workerd-linux-64": "1.20240821.1", + "@cloudflare/workerd-linux-arm64": "1.20240821.1", + "@cloudflare/workerd-windows-64": "1.20240821.1" } }, "node_modules/wrap-ansi": { @@ -23363,6 +23384,7 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/youch/-/youch-3.3.3.tgz", "integrity": "sha512-qSFXUk3UZBLfggAW3dJKg0BMblG5biqSF8M34E06o5CSsZtH92u9Hqmj2RzGiHDi64fhe83+4tENFP2DB6t6ZA==", + "license": "MIT", "dependencies": { "cookie": "^0.5.0", "mustache": "^4.2.0", @@ -23373,6 +23395,7 @@ "version": "0.5.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "license": "MIT", "engines": { "node": ">= 0.6" } diff --git a/package.json b/package.json index 754a5e1d..9c311317 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "react-map-gl": "^7.1.7", "react-media-devices": "^1.1.5", "react-microsoft-clarity": "^1.2.0", - "react-router-hono-server": "https://pkg.pr.new/rphlmr/react-router-hono-server@6", + "react-router-hono-server": "^0.1.0", "react-tag-autocomplete": "^7.1.0", "react-to-print": "^2.15.1", "react-zorm": "^0.9.0", From b33d9b1a8ab5c173904c2f595f7782cb379012b6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Sep 2024 13:56:18 +0000 Subject: [PATCH 36/59] chore(deps): bump micromatch from 4.0.7 to 4.0.8 Bumps [micromatch](https://github.com/micromatch/micromatch) from 4.0.7 to 4.0.8. - [Release notes](https://github.com/micromatch/micromatch/releases) - [Changelog](https://github.com/micromatch/micromatch/blob/master/CHANGELOG.md) - [Commits](https://github.com/micromatch/micromatch/compare/4.0.7...4.0.8) --- updated-dependencies: - dependency-name: micromatch dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9855545b..d48e6e15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15714,9 +15714,9 @@ ] }, "node_modules/micromatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", - "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" From f1415514d69ed8b44193be9145714259ca2e57bb Mon Sep 17 00:00:00 2001 From: Nikolay Bonev Date: Mon, 2 Sep 2024 17:34:23 +0300 Subject: [PATCH 37/59] setting default max chars on textarea --- app/components/assets/notes/new.tsx | 1 - app/components/markdown/markdown-editor.tsx | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/components/assets/notes/new.tsx b/app/components/assets/notes/new.tsx index 68db8399..f6ce4d5d 100644 --- a/app/components/assets/notes/new.tsx +++ b/app/components/assets/notes/new.tsx @@ -107,7 +107,6 @@ export const NewNote = ({ className="rounded-b-none" onBlur={handelBlur} onKeyDown={handleKeyDown} - maxLength={100000} />
    ) : ( diff --git a/app/components/markdown/markdown-editor.tsx b/app/components/markdown/markdown-editor.tsx index d4b1fcc4..0bffa0af 100644 --- a/app/components/markdown/markdown-editor.tsx +++ b/app/components/markdown/markdown-editor.tsx @@ -30,7 +30,7 @@ export const MarkdownEditor = forwardRef(function MarkdownEditor( placeholder, defaultValue, className, - maxLength, + maxLength = 5000, ...rest }: Props, ref From db676e1ff6a868794dfe045c42250a92dc95aa37 Mon Sep 17 00:00:00 2001 From: Nikolay Bonev Date: Tue, 3 Sep 2024 10:49:16 +0300 Subject: [PATCH 38/59] feat: added a blocker for the app if cookies or js is disabled --- app/components/layout/maintenance-mode.tsx | 55 +++++++++++++--------- app/components/shared/icons-map.tsx | 5 +- app/root.tsx | 47 ++++++++++++++++-- 3 files changed, 82 insertions(+), 25 deletions(-) diff --git a/app/components/layout/maintenance-mode.tsx b/app/components/layout/maintenance-mode.tsx index ff413ac8..24870ae5 100644 --- a/app/components/layout/maintenance-mode.tsx +++ b/app/components/layout/maintenance-mode.tsx @@ -1,9 +1,25 @@ -import { ToolIcon } from "../icons/library"; import { Button } from "../shared/button"; +import type { IconType } from "../shared/icons-map"; +import iconsMap from "../shared/icons-map"; -export default function MaintenanceMode() { +interface Props { + title: string; + content: string; + cta?: { + to: string | undefined; + text: string | undefined; + }; + icon: IconType; +} + +export default function BlockInteractions({ + title, + content, + cta = undefined, + icon, +}: Props) { return ( -
    +
    background
    - + {iconsMap[icon]}
    -

    - Maintenance is being performed -

    -

    - Apologies, we’re down for scheduled maintenance. Please try again - later. -

    - +

    {title}

    +

    {content}

    + {cta?.to && cta?.text && ( + + )}
    diff --git a/app/components/shared/icons-map.tsx b/app/components/shared/icons-map.tsx index 4a129e77..21bf443f 100644 --- a/app/components/shared/icons-map.tsx +++ b/app/components/shared/icons-map.tsx @@ -43,6 +43,7 @@ import { LockIcon, ActiveSwitchIcon, MapIcon, + ToolIcon, } from "../icons/library"; /** The possible options for icons to be rendered in the button */ @@ -91,7 +92,8 @@ export type IconType = | "asset-label" | "lock" | "activate" - | "deactivate"; + | "deactivate" + | "tool"; type IconsMap = { [key in IconType]: JSX.Element; @@ -143,6 +145,7 @@ export const iconsMap: IconsMap = { lock: , activate: , deactivate: , + tool: , }; export default iconsMap; diff --git a/app/root.tsx b/app/root.tsx index 41616862..1d630ae3 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from "react"; import type { User } from "@prisma/client"; import type { LinksFunction, @@ -19,7 +20,7 @@ import { withSentry } from "@sentry/remix"; import nProgressStyles from "nprogress/nprogress.css?url"; import { ErrorContent } from "./components/errors"; import { HomeIcon } from "./components/icons/library"; -import MaintenanceMode from "./components/layout/maintenance-mode"; +import BlockInteractions from "./components/layout/maintenance-mode"; import { Clarity } from "./components/marketing/clarity"; import { config } from "./config/shelf.config"; import { useNprogress } from "./hooks/use-nprogress"; @@ -80,6 +81,11 @@ export const shouldRevalidate = () => false; export function Layout({ children }: { children: React.ReactNode }) { const data = useRouteLoaderData("root"); const nonce = useNonce(); + const [hasCookies, setHasCookies] = useState(true); + useEffect(() => { + setHasCookies(navigator.cookieEnabled); + }, []); + return ( @@ -92,7 +98,28 @@ export function Layout({ children }: { children: React.ReactNode }) { - {children} + + + {hasCookies ? ( + children + ) : ( + + )} +