Skip to content

Commit

Permalink
Merge pull request #1616 from rockingrohit9639/feature/bulk-download-qr
Browse files Browse the repository at this point in the history
Feature/bulk download qr
  • Loading branch information
DonKoko authored Feb 13, 2025
2 parents 79e5986 + f3348d7 commit afcd3d7
Show file tree
Hide file tree
Showing 12 changed files with 2,527 additions and 1,973 deletions.
3 changes: 2 additions & 1 deletion app/atoms/bulk-update-dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { BulkDialogType } from "~/components/bulk-update-dialog/bulk-update
* This atom is responsible for holding the open state for dialogs
* Open Dialog must be open at a time
*/
const DEFAULT_STATE = {
const DEFAULT_STATE: Record<BulkDialogType, boolean> = {
location: false,
category: false,
"assign-custody": false,
Expand All @@ -21,6 +21,7 @@ const DEFAULT_STATE = {
unavailable: false,
bookings: false,
"booking-exist": false,
"download-qr": false,
};

export const bulkDialogAtom =
Expand Down
29 changes: 29 additions & 0 deletions app/components/assets/bulk-actions-dropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useState } from "react";
import { useNavigation } from "@remix-run/react";
import { useAtomValue } from "jotai";
import { useHydrated } from "remix-utils/use-hydrated";
Expand Down Expand Up @@ -32,6 +33,8 @@ import {
} from "../shared/dropdown";
import When from "../when/when";
import BookSelectedAssetsDropdown from "./assets-index/book-selected-assets-dropdown";
import BulkDownloadQrDialog from "./bulk-download-qr-dialog";
import Icon from "../icons/icon";

export default function BulkActionsDropdown() {
const isHydrated = useHydrated();
Expand All @@ -57,6 +60,7 @@ export default function BulkActionsDropdown() {
function ConditionalDropdown() {
const navigation = useNavigation();
const isLoading = isFormProcessing(navigation.state);
const [isBulkDownloadQrOpen, setIsBulkDownloadQrOpen] = useState(false);

const {
ref: dropdownRef,
Expand Down Expand Up @@ -129,6 +133,13 @@ function ConditionalDropdown() {
<BulkMarkAvailabilityDialog type="unavailable" />
</When>

<BulkDownloadQrDialog
isDialogOpen={isBulkDownloadQrOpen}
onClose={() => {
setIsBulkDownloadQrOpen(false);
}}
/>

<When
truthy={userHasPermission({
roles,
Expand Down Expand Up @@ -204,6 +215,24 @@ function ConditionalDropdown() {
ref={dropdownRef}
>
<div className="order fixed bottom-0 left-0 w-screen rounded-b-none rounded-t-[4px] bg-white p-0 text-right md:static md:w-[180px] md:rounded-t-[4px]">
<DropdownMenuItem
onClick={() => {
closeMenu();
setIsBulkDownloadQrOpen(true);
}}
className="border-b py-1 lg:p-0"
>
<Button
variant="link"
className="w-full justify-start px-4 py-3 text-gray-700 hover:text-gray-700"
width="full"
>
<span className="flex items-center gap-2">
<Icon icon="download" /> Download QR Codes
</span>
</Button>
</DropdownMenuItem>

<When
truthy={userHasPermission({
roles,
Expand Down
206 changes: 206 additions & 0 deletions app/components/assets/bulk-download-qr-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { useState } from "react";
import { toBlob } from "html-to-image";
import { useAtomValue } from "jotai";
import JSZip from "jszip";
import { DownloadIcon } from "lucide-react";
import { selectedBulkItemsAtom } from "~/atoms/list";
import { useSearchParams } from "~/hooks/search-params";
import { generateHtmlFromComponent } from "~/utils/component-to-html";
import { isSelectingAllItems } from "~/utils/list";
import { sanitizeFilename } from "~/utils/misc";
import { Dialog, DialogPortal } from "../layout/dialog";
import type { QrDef } from "../qr/qr-preview";
import { QrLabel } from "../qr/qr-preview";
import { Button } from "../shared/button";
import { Spinner } from "../shared/spinner";
import When from "../when/when";

type BulkDownloadQrDialogProps = {
className?: string;
isDialogOpen: boolean;
onClose: () => void;
};

type DownloadState =
| { status: "idle" }
| { status: "loading" }
| { status: "success" }
| { status: "error"; error: string };

export default function BulkDownloadQrDialog({
className,
isDialogOpen,
onClose,
}: BulkDownloadQrDialogProps) {
const [downloadState, setDownloadState] = useState<DownloadState>({
status: "idle",
});
const [searchParams] = useSearchParams();

const selectedAssets = useAtomValue(selectedBulkItemsAtom);
const allAssetsSelected = isSelectingAllItems(selectedAssets);

const disabled =
selectedAssets.length === 0 || downloadState.status === "loading";

function handleClose() {
setDownloadState({ status: "idle" });
onClose();
}

async function handleBulkDownloadQr() {
const query = new URLSearchParams();

selectedAssets.forEach((asset) => {
query.append("assetIds", asset.id);
});

setDownloadState({ status: "loading" });

try {
/* Getting all validated assets with qr object */
const response = await fetch(
`/api/assets/get-assets-for-bulk-qr-download?${query}&${searchParams}`
).then((response) => response.json());

const assets = response.assets as Array<{
id: string;
title: string;
createdAt: string;
qr: QrDef;
}>;

const zip = new JSZip();
const qrFolder = zip.folder("qr-codes");

/* Converting our React component to html so that we can later convert it into an image */
const qrNodes = assets.map((asset) =>
generateHtmlFromComponent(
<QrLabel data={{ qr: asset.qr }} title={asset.title} />
)
);

const toBlobOptions = {
width: 300,
height: 300,
backgroundColor: "white",
style: {
display: "flex",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
},
};

/**
* We are converting first qr to image separately because toBlob will cache the font
* and will not make further network requests for other qr codes.
*/
const firstQrImage = await toBlob(qrNodes[0], toBlobOptions);

/* Converting all qr nodes into images */
const qrImages = await Promise.all(
qrNodes.slice(1).map((qrNode) => toBlob(qrNode, toBlobOptions))
);

/* Appending qr code image to zip file */
[firstQrImage, ...qrImages].forEach((qrImage, index) => {
const asset = assets[index];
const filename = `${sanitizeFilename(asset.title)}_${asset.qr.id}.jpg`;
if (!qrImage) {
return;
}

if (qrFolder) {
qrFolder.file(filename, qrImage);
} else {
zip.file(filename, qrImage);
}
});

const zipBlob = await zip.generateAsync({ type: "blob" });
const downloadLink = document.createElement("a");

downloadLink.href = URL.createObjectURL(zipBlob);
downloadLink.download = "qr-codes.zip";

downloadLink.click();

setTimeout(() => {
URL.revokeObjectURL(downloadLink.href);
}, 4e4);

setDownloadState({ status: "success" });
} catch (error) {
setDownloadState({
status: "error",
error: error instanceof Error ? error.message : "Something went wrong.",
});
}
}

return (
<DialogPortal>
<Dialog
open={isDialogOpen}
onClose={handleClose}
className={className}
title={
<div className="flex items-center justify-center rounded-full border-8 border-primary-50 bg-primary-100 p-2 text-primary-600">
<DownloadIcon />
</div>
}
>
<div className="px-6 py-4">
{downloadState.status === "loading" ? (
<div className="mb-6 flex flex-col items-center gap-4">
<Spinner />
<h3>Generating Zip file ...</h3>
</div>
) : (
<>
<h4 className="mb-1">
Download qr codes for{" "}
{allAssetsSelected ? "all" : selectedAssets.length} asset(s).
</h4>
<p className="mb-4">
{allAssetsSelected ? "All" : selectedAssets.length} qr code(s)
will be downloaded in a zip file.
</p>
<When truthy={downloadState.status === "success"}>
<p className="mb-4 text-success-500">
Successfully downloaded qr codes.
</p>
</When>

{downloadState.status === "error" ? (
<p className="mb-4 text-error-500">{downloadState.error}</p>
) : null}

<div className="flex w-full items-center justify-center gap-4">
<Button
className="flex-1"
variant="secondary"
onClick={handleClose}
disabled={disabled}
>
Close
</Button>

<When truthy={downloadState.status !== "success"}>
<Button
className="flex-1"
onClick={handleBulkDownloadQr}
disabled={disabled}
>
Download
</Button>
</When>
</div>
</>
)}
</div>
</Dialog>
</DialogPortal>
);
}
3 changes: 2 additions & 1 deletion app/components/bulk-update-dialog/bulk-update-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ type BulkDialogType =
| "available"
| "unavailable"
| "bookings"
| "booking-exist";
| "booking-exist"
| "download-qr";

type CommonBulkDialogProps = {
type: BulkDialogType;
Expand Down
2 changes: 1 addition & 1 deletion app/components/icons/library.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1273,7 +1273,7 @@ export const DownloadIcon = (props: SVGProps<SVGSVGElement>) => (
>
<path
d="M4 16.2422C2.79401 15.435 2 14.0602 2 12.5C2 10.1564 3.79151 8.23129 6.07974 8.01937C6.54781 5.17213 9.02024 3 12 3C14.9798 3 17.4522 5.17213 17.9203 8.01937C20.2085 8.23129 22 10.1564 22 12.5C22 14.0602 21.206 15.435 20 16.2422M8 17L12 21M12 21L16 17M12 21V12"
stroke="#667085"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
Expand Down
Loading

0 comments on commit afcd3d7

Please sign in to comment.