diff --git a/CHANGELOG.md b/CHANGELOG.md index ad84d6ce2..ff7c93127 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes to this project will be documented in this file. ## [0.4.2] - 2025-04-09 +### Added + +- Add vendor compliance reports UI + ### Changed - Simplified policy data model by removing version field and optimistic concurrency diff --git a/apps/console/src/pages/organizations/vendors/VendorView.tsx b/apps/console/src/pages/organizations/vendors/VendorView.tsx index 8cc33a5a2..f18784e0c 100644 --- a/apps/console/src/pages/organizations/vendors/VendorView.tsx +++ b/apps/console/src/pages/organizations/vendors/VendorView.tsx @@ -12,9 +12,12 @@ import { usePreloadedQuery, useQueryLoader, useMutation, + ConnectionHandler, } from "react-relay"; -import { Suspense, useEffect, useState, useCallback } from "react"; +import { Suspense, useEffect, useState, useCallback, useRef } from "react"; import type { VendorViewQuery as VendorViewQueryType } from "./__generated__/VendorViewQuery.graphql"; +import type { VendorViewDeleteComplianceReportMutation as DeleteComplianceReportMutationType } from "./__generated__/VendorViewDeleteComplianceReportMutation.graphql"; +import type { VendorViewUploadComplianceReportMutation as UploadComplianceReportMutationType } from "./__generated__/VendorViewUploadComplianceReportMutation.graphql"; import { useParams } from "react-router"; import { cn } from "@/lib/utils"; import { PageTemplate } from "@/components/PageTemplate"; @@ -36,6 +39,20 @@ const vendorViewQuery = graphql` privacyPolicyUrl createdAt updatedAt + complianceReports(first: 100) + @connection(key: "VendorView_complianceReports") { + edges { + node { + id + reportName + reportDate + validUntil + fileUrl + fileSize + createdAt + } + } + } } } } @@ -61,6 +78,38 @@ const updateVendorMutation = graphql` } `; +const deleteComplianceReportMutation = graphql` + mutation VendorViewDeleteComplianceReportMutation( + $input: DeleteVendorComplianceReportInput! + $connections: [ID!]! + ) { + deleteVendorComplianceReport(input: $input) { + deletedVendorComplianceReportId @deleteEdge(connections: $connections) + } + } +`; + +const uploadComplianceReportMutation = graphql` + mutation VendorViewUploadComplianceReportMutation( + $input: UploadVendorComplianceReportInput! + $connections: [ID!]! + ) { + uploadVendorComplianceReport(input: $input) { + vendorComplianceReportEdge @appendEdge(connections: $connections) { + node { + id + reportName + reportDate + validUntil + fileUrl + fileSize + createdAt + } + } + } + } +`; + function EditableField({ label, value, @@ -105,6 +154,129 @@ function formatDateForAPI(dateStr: string): string { return date.toISOString(); } +interface ComplianceReport { + id: string; + reportName: string; + reportDate: string; + validUntil: string | null | undefined; + fileUrl: string; + fileSize: number; + createdAt: string; +} + +function ComplianceReportsTable({ + reports, + onDelete, + onUpload, +}: { + reports: ComplianceReport[]; + onDelete: (id: string) => void; + onUpload: (event: React.ChangeEvent) => void; +}) { + const fileInputRef = useRef(null); + + // Truncate filename to a reasonable length if needed + const truncateFilename = (filename: string, maxLength = 40) => { + if (filename.length <= maxLength) return filename; + const extension = filename.split(".").pop(); + const name = filename.substring(0, maxLength - extension!.length - 3); + return `${name}...${extension}`; + }; + + return ( +
+
+

Compliance Reports

+

+ Upload and manage compliance reports for this vendor +

+
+
+
+ + + + + + + + + + + + {reports.map((report) => ( + + + + + + + + ))} + +
+ Report Name + + Report Date + + Valid Until + + File Size + + Actions +
+ +
+ {truncateFilename(report.reportName)} +
+
+
+ {new Date(report.reportDate).toLocaleDateString()} + + {report.validUntil + ? new Date(report.validUntil).toLocaleDateString() + : "N/A"} + + {(report.fileSize / 1024 / 1024).toFixed(2)} MB + + +
+
+
+
+ + +

+ Only PDF files up to 10MB are allowed +

+
+
+ ); +} + function VendorViewContent({ queryRef, }: { @@ -115,7 +287,6 @@ function VendorViewContent({ const [formData, setFormData] = useState({ name: data.node.name || "", description: data.node.description || "", - // Format dates properly for datetime-local input serviceStartAt: formatDateForInput(data.node.serviceStartAt), serviceTerminationAt: formatDateForInput(data.node.serviceTerminationAt), serviceCriticality: data.node.serviceCriticality, @@ -124,7 +295,15 @@ function VendorViewContent({ termsOfServiceUrl: data.node.termsOfServiceUrl || "", privacyPolicyUrl: data.node.privacyPolicyUrl || "", }); - const [commit] = useMutation(updateVendorMutation); + const [updateVendor] = useMutation(updateVendorMutation); + const [deleteVendorComplianceReport] = + useMutation( + deleteComplianceReportMutation + ); + const [uploadVendorComplianceReport] = + useMutation( + uploadComplianceReportMutation + ); const [, loadQuery] = useQueryLoader(vendorViewQuery); const { toast } = useToast(); @@ -139,7 +318,7 @@ function VendorViewContent({ : null, }; - commit({ + updateVendor({ variables: { input: { id: data.node.id, @@ -173,7 +352,7 @@ function VendorViewContent({ } }, }); - }, [commit, data.node.id, formData, loadQuery, toast]); + }, [updateVendor, data.node.id, formData, loadQuery, toast]); const handleFieldChange = (field: keyof typeof formData, value: unknown) => { setFormData((prev) => ({ @@ -199,6 +378,110 @@ function VendorViewContent({ setEditedFields(new Set()); }; + const handleDeleteReport = useCallback( + (reportId: string) => { + deleteVendorComplianceReport({ + variables: { + connections: [ + ConnectionHandler.getConnectionID( + data.node.id!, + "VendorView_complianceReports" + ), + ], + input: { + reportId, + }, + }, + onCompleted: () => { + toast({ + title: "Success", + description: "Compliance report deleted successfully", + variant: "default", + }); + loadQuery({ vendorId: data.node.id! }); + }, + onError: (error) => { + toast({ + title: "Error", + description: error.message || "Failed to delete compliance report", + variant: "destructive", + }); + }, + }); + }, + [deleteVendorComplianceReport, data.node.id, loadQuery, toast] + ); + + const handleUploadReport = useCallback( + (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + // Validate file type + if (file.type !== "application/pdf") { + toast({ + title: "Error", + description: "Only PDF files are allowed", + variant: "destructive", + }); + return; + } + + // Validate file size (max 10MB) + if (file.size > 10 * 1024 * 1024) { + toast({ + title: "Error", + description: "File size must be less than 10MB", + variant: "destructive", + }); + return; + } + + const reader = new FileReader(); + reader.onload = () => { + const reportDate = new Date().toISOString(); + + uploadVendorComplianceReport({ + variables: { + connections: [ + ConnectionHandler.getConnectionID( + data.node.id!, + "VendorView_complianceReports" + ), + ], + input: { + vendorId: data.node.id!, + reportDate, + reportName: file.name, + file: null, + }, + }, + uploadables: { + "input.file": file, + }, + onCompleted: () => { + toast({ + title: "Success", + description: "Compliance report uploaded successfully", + variant: "default", + }); + loadQuery({ vendorId: data.node.id! }); + }, + onError: (error) => { + toast({ + title: "Error", + description: + error.message || "Failed to upload compliance report", + variant: "destructive", + }); + }, + }); + }; + reader.readAsDataURL(file); + }, + [uploadVendorComplianceReport, data.node.id, loadQuery, toast] + ); + return (
@@ -370,6 +653,17 @@ function VendorViewContent({
+ + + edge.node) ?? [] + } + onDelete={handleDeleteReport} + onUpload={handleUploadReport} + /> + +