diff --git a/apps/console/package.json b/apps/console/package.json index f26a5b1a2..9089ac9a4 100644 --- a/apps/console/package.json +++ b/apps/console/package.json @@ -29,6 +29,7 @@ "minisearch": "^7.1.2", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-dropzone": "^14.3.8", "react-error-boundary": "^6.0.0", "react-hook-form": "^7.56.4", "react-pdf": "^10.3.0", diff --git a/apps/console/src/pages/organizations/audits/AuditsPage.tsx b/apps/console/src/pages/organizations/audits/AuditsPage.tsx index 79f0cdb90..9ea11e25a 100644 --- a/apps/console/src/pages/organizations/audits/AuditsPage.tsx +++ b/apps/console/src/pages/organizations/audits/AuditsPage.tsx @@ -12,13 +12,17 @@ import { DropdownItem, IconPlusLarge, IconTrashCan, + IconUpload, PageHeader, Tbody, Td, Th, Thead, Tr, + useDialogRef, } from "@probo/ui"; +import { useCallback, useState } from "react"; +import { useDropzone } from "react-dropzone"; import { graphql, type PreloadedQuery, @@ -104,15 +108,50 @@ export default function AuditsPage(props: Props) { audit => audit.canDelete || audit.canUpdate, ); + const canCreateAudit = data.node.canCreateAudit; + const [droppedFile, setDroppedFile] = useState(null); + const dropDialogRef = useDialogRef(); + + const onDrop = useCallback( + (acceptedFiles: File[]) => { + if (!canCreateAudit || acceptedFiles.length === 0) return; + setDroppedFile(acceptedFiles[0]); + dropDialogRef.current?.open(); + }, + [canCreateAudit, dropDialogRef], + ); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + noClick: true, + noKeyboard: true, + accept: { "application/pdf": [".pdf"] }, + multiple: false, + onDrop, + disabled: !canCreateAudit, + }); + + const handleDropDialogClose = () => { + setDroppedFile(null); + }; + return ( -
+
+ + {isDragActive && canCreateAudit && ( +
+ +

+ {__("Drop a PDF to create an audit with a report")} +

+
+ )} - {data.node.canCreateAudit && ( + {canCreateAudit && ( + {canCreateAudit && ( + + )}
); } diff --git a/apps/console/src/pages/organizations/audits/dialogs/CreateAuditDialog.tsx b/apps/console/src/pages/organizations/audits/dialogs/CreateAuditDialog.tsx index 20cbcea77..f27211ba4 100644 --- a/apps/console/src/pages/organizations/audits/dialogs/CreateAuditDialog.tsx +++ b/apps/console/src/pages/organizations/audits/dialogs/CreateAuditDialog.tsx @@ -4,15 +4,18 @@ import { formatError, getAuditStateLabel, type GraphQLError, + promisifyMutation, } from "@probo/helpers"; import { useTranslate } from "@probo/i18n"; import { Breadcrumb, Button, + type DialogRef, Dialog, DialogContent, DialogFooter, Field, + IconUpload, Input, Option, Select, @@ -21,13 +24,16 @@ import { } from "@probo/ui"; import { Suspense } from "react"; import { type Control, Controller } from "react-hook-form"; -import { useLazyLoadQuery } from "react-relay"; +import { useLazyLoadQuery, useMutation } from "react-relay"; import { graphql } from "relay-runtime"; import { z } from "zod"; import type { CreateAuditDialogFrameworksQuery } from "#/__generated__/core/CreateAuditDialogFrameworksQuery.graphql"; import { ControlledField } from "#/components/form/ControlledField"; -import { useCreateAudit } from "#/hooks/graph/AuditGraph"; +import { + useCreateAudit, + uploadAuditReportMutation, +} from "#/hooks/graph/AuditGraph"; import { useFormWithSchema } from "#/hooks/useFormWithSchema"; const frameworksQuery = graphql` @@ -62,15 +68,21 @@ const schema = z.object({ }); type Props = { - children: React.ReactNode; + children?: React.ReactNode; connection: string; organizationId: string; + file?: File | null; + ref?: DialogRef; + onClose?: () => void; }; export function CreateAuditDialog({ children, connection, organizationId, + file, + ref: externalRef, + onClose, }: Props) { const { __ } = useTranslate(); const { toast } = useToast(); @@ -84,12 +96,15 @@ export function CreateAuditDialog({ state: "NOT_STARTED", }, }); - const ref = useDialogRef(); + const internalRef = useDialogRef(); + const ref = externalRef ?? internalRef; const createAudit = useCreateAudit(connection); + // eslint-disable-next-line relay/generated-typescript-types + const [uploadMutate] = useMutation(uploadAuditReportMutation); const onSubmit = async (data: z.infer) => { try { - await createAudit({ + const response = await createAudit({ organizationId, frameworkId: data.frameworkId, name: data.name || null, @@ -97,13 +112,46 @@ export function CreateAuditDialog({ validUntil: formatDatetime(data.validUntil), state: data.state, }); + + const auditId = (response as { createAudit: { auditEdge: { node: { id: string } } } }) + .createAudit.auditEdge.node.id; + + if (file && auditId) { + try { + await promisifyMutation(uploadMutate)({ + variables: { + input: { + auditId, + file: null, + }, + }, + uploadables: { + "input.file": file, + }, + }); + toast({ + title: __("Success"), + description: __("Audit created and report uploaded successfully"), + variant: "success", + }); + } catch { + toast({ + title: __("Warning"), + description: __("Audit created but report upload failed. You can upload the report from the audit detail page."), + variant: "warning", + }); + } + } else { + toast({ + title: __("Success"), + description: __("Audit created successfully"), + variant: "success", + }); + } + ref.current?.close(); reset(); - toast({ - title: __("Success"), - description: __("Audit created successfully"), - variant: "success", - }); + onClose?.(); } catch (error) { toast({ title: __("Error"), @@ -116,14 +164,33 @@ export function CreateAuditDialog({ } }; + const handleClose = () => { + onClose?.(); + }; + return ( } + onClose={handleClose} >
void handleSubmit(onSubmit)(e)} className="space-y-4"> + {file && ( +
+ +
+

+ {file.name} +

+

+ {(file.size / 1024 / 1024).toFixed(2)} MB +

+
+
+ )} + =11.0.0" }