Redesign vendor list page

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2025-04-22 08:30:57 -07:00
parent e2e8f38f97
commit 780693dd09
6 changed files with 304 additions and 106 deletions

View File

@@ -18,7 +18,8 @@ All notable changes to this project will be documented in this file.
### Changed
- Completely redesigned vendor detail page with a cleaner, more intuitive layout
- Completely redesigned vendor list page
- Completely redesigned vendor detail page
- Improved compliance reports table with better file size formatting and date display
- People may be linked to user

View File

@@ -8,9 +8,9 @@ import {
usePaginationFragment,
} from "react-relay";
import { useSearchParams, useParams } from "react-router";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Store, ChevronRight, Trash2 } from "lucide-react";
import { Store, Plus, MoreHorizontal } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Link } from "react-router";
@@ -83,6 +83,7 @@ const vendorListFragment = graphql`
id
name
description
websiteUrl
createdAt
updatedAt
}
@@ -108,6 +109,7 @@ const createVendorMutation = graphql`
id
name
description
websiteUrl
createdAt
updatedAt
}
@@ -141,7 +143,7 @@ function LoadAboveButton({
}
return (
<div className="flex justify-center">
<div className="flex justify-center mt-4">
<Button
variant="outline"
onClick={onLoadMore}
@@ -168,7 +170,7 @@ function LoadBelowButton({
}
return (
<div className="flex justify-center">
<div className="flex justify-center mt-4">
<Button
variant="outline"
onClick={onLoadMore}
@@ -194,6 +196,7 @@ function ListVendorContent({
const [, setSearchParams] = useSearchParams();
const [, startTransition] = useTransition();
const [searchTerm, setSearchTerm] = useState("");
const [showAddVendorDropdown, setShowAddVendorDropdown] = useState(false);
const [filteredVendors, setFilteredVendors] = useState<VendorData[]>([]);
const [vendorsData, setVendorsData] = useState<VendorData[]>([]);
const [isLoadingVendors, setIsLoadingVendors] = useState(false);
@@ -250,14 +253,77 @@ function ListVendorContent({
threshold: 0.3,
});
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setSearchTerm(value);
if (value.trim() === "") {
setFilteredVendors([]);
} else {
const results = fuse.search(value).map((result) => result.item);
setFilteredVendors(results);
}
};
// Helper to format date (similar to "Mon, 8 Mar. 2025" in the design)
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
weekday: 'short',
day: 'numeric',
month: 'short',
year: 'numeric'
});
};
// Get favicon URL from website URL
const getFaviconUrl = (websiteUrl: string | null | undefined) => {
if (!websiteUrl) return null;
try {
const url = new URL(websiteUrl);
return `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=64`;
} catch (e) {
return null;
}
};
// Function to determine risk badge style
const getRiskBadgeStyle = (riskLevel: string | undefined) => {
switch (riskLevel) {
case "CRITICAL":
return "bg-[#FFEFEF] text-[#CD2B31]";
case "SIGNIFICANT":
return "bg-[#FFF1E7] text-[#BD4B00]";
default:
return "bg-[#EEFADC] text-[#5D770D]";
}
};
// Mock risk status for demo (since the data doesn't have this field)
const getRiskStatus = (vendorId: string) => {
// Simplistic way to assign different risk levels for demo
const hash = vendorId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
if (hash % 3 === 0) return "CRITICAL";
if (hash % 3 === 1) return "SIGNIFICANT";
return "GENERAL";
};
return (
<PageTemplate
title="Vendors"
description="Vendors are third-party services that your company uses. Add them to
keep track of their risk and compliance status."
description="Vendors are third-party services that your company uses. Add them to keep track of their risk and compliance status."
actions={
<Button
className="flex items-center gap-2"
onClick={() => setShowAddVendorDropdown(!showAddVendorDropdown)}
>
<Plus className="h-4 w-4" />
Add vendor
</Button>
}
>
<div className="space-y-6">
<div className="rounded-xl border bg-level-1 p-4">
{showAddVendorDropdown && (
<div className="mb-6 p-4 border rounded-xl bg-white relative">
<div className="flex items-center gap-2 mb-4">
<Store className="h-5 w-5" />
<h3 className="font-medium">Add a vendor</h3>
@@ -268,18 +334,7 @@ function ListVendorContent({
placeholder="Type vendor's name"
value={searchTerm}
style={{ borderRadius: "0.3rem" }}
onChange={(e) => {
const value = e.target.value;
setSearchTerm(value);
if (value.trim() === "") {
setFilteredVendors([]);
} else {
const results = fuse
.search(value)
.map((result) => result.item);
setFilteredVendors(results);
}
}}
onChange={handleSearchChange}
disabled={isLoadingVendors}
/>
{isLoadingVendors && (
@@ -291,12 +346,12 @@ function ListVendorContent({
{searchTerm.trim() !== "" && (
<div
style={{ borderRadius: "0.3rem" }}
className="absolute top-full left-0 mt-1 w-[calc(100%-100px)] max-h-48 overflow-y-auto border bg-invert-bg shadow-md z-10"
className="absolute top-full left-0 mt-1 w-[calc(100%-100px)] max-h-48 overflow-y-auto border bg-white shadow-md z-10"
>
{filteredVendors.map((vendor) => (
<button
key={vendor.name}
className="w-full px-3 py-2 text-left bg-invert-bg hover:bg-h-subtle-bg"
className="w-full px-3 py-2 text-left hover:bg-gray-50"
onClick={() => {
createVendor({
variables: {
@@ -325,6 +380,7 @@ function ListVendorContent({
onCompleted() {
setSearchTerm("");
setFilteredVendors([]);
setShowAddVendorDropdown(false);
toast({
title: "Vendor added",
description:
@@ -334,12 +390,24 @@ function ListVendorContent({
});
}}
>
{vendor.name}
<div className="flex items-center gap-2">
{vendor.websiteUrl && (
<img
src={getFaviconUrl(vendor.websiteUrl) || ''}
alt=""
className="w-4 h-4"
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
)}
{vendor.name}
</div>
</button>
))}
<button
className="w-full px-3 py-2 text-left hover:bg-h-subtle-bg flex items-center gap-2 border-t"
onClick={() => {
className="w-full px-3 py-2 text-left hover:bg-gray-50 flex items-center gap-2 border-t"
onClick={(e) => {
createVendor({
variables: {
connections: [vendorsConnection.vendors.__id],
@@ -348,13 +416,12 @@ function ListVendorContent({
name: searchTerm.trim(),
description: "",
serviceStartAt: new Date().toISOString(),
serviceCriticality: "LOW",
riskTier: "GENERAL",
},
},
onCompleted() {
setSearchTerm("");
setFilteredVendors([]);
setShowAddVendorDropdown(false);
toast({
title: "Vendor created",
description:
@@ -364,80 +431,180 @@ function ListVendorContent({
});
}}
>
<span className="font-medium">Create new vendor:</span>{" "}
{searchTerm}
<div className="flex items-center gap-2">
<Plus className="h-4 w-4" />
<span className="font-medium">Create new vendor:</span> {searchTerm}
</div>
</button>
</div>
)}
</div>
</div>
)}
<div className="space-y-2">
{vendors.map((vendor) => (
<Link
key={vendor?.id}
to={`/organizations/${organizationId}/vendors/${vendor?.id}`}
className="block"
>
<div className="flex items-center justify-between p-4 rounded-xl border bg-level-1 hover:bg-accent-bg/5 transition-colors">
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback>{vendor?.name?.[0]}</AvatarFallback>
</Avatar>
<div className="flex items-center gap-2">
<p className="font-medium">{vendor?.name}</p>
</div>
</div>
<div className="rounded-xl border overflow-hidden">
<table className="w-full bg-white">
<thead className="bg-white border-b border-gray-100">
<tr>
<th className="py-3 px-4 text-left text-xs font-semibold text-gray-500">
<div className="flex items-center gap-2">
<Badge
variant="secondary"
className={
vendor.riskTier === "CRITICAL"
? "bg-danger-bg text-danger rounded-full px-3 py-0.5 text-xs font-medium"
: vendor?.riskTier === "SIGNIFICANT"
? "bg-warning-bg text-warning rounded-full px-3 py-0.5 text-xs font-medium"
: "bg-success-bg text-success rounded-full px-3 py-0.5 text-xs font-medium"
}
Vendor
<svg
width="8"
height="8"
viewBox="0 0 8 8"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="text-gray-400"
>
{vendor.riskTier}
</Badge>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-tertiary hover:bg-transparent hover:[&>svg]:text-danger"
onClick={(e) => {
e.preventDefault(); // Prevent navigation
if (
window.confirm(
"Are you sure you want to delete this vendor?"
)
) {
deleteVendor({
variables: {
connections: [vendorsConnection.vendors.__id],
input: {
vendorId: vendor.id,
},
},
onCompleted() {
toast({
title: "Vendor deleted",
description:
"The vendor has been deleted successfully",
});
},
});
}
}}
>
<Trash2 className="h-4 w-4 transition-colors" />
</Button>
<ChevronRight className="h-4 w-4 text-tertiary" />
<path d="M4 6L7 3H1L4 6Z" fill="currentColor" />
</svg>
</div>
</div>
</Link>
))}
</div>
</th>
<th className="py-3 px-4 text-left text-xs font-semibold text-gray-500">
<div className="flex items-center gap-2">
Last update
<svg
width="8"
height="8"
viewBox="0 0 8 8"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="text-gray-400"
>
<path d="M4 6L7 3H1L4 6Z" fill="currentColor" />
</svg>
</div>
</th>
<th className="py-3 px-4 text-left text-xs font-semibold text-gray-500">
<div className="flex items-center gap-2">
Risk
<svg
width="8"
height="8"
viewBox="0 0 8 8"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="text-gray-400"
>
<path d="M4 6L7 3H1L4 6Z" fill="currentColor" />
</svg>
</div>
</th>
<th className="py-3 px-4 text-left text-xs font-semibold text-gray-500">
<div className="flex items-center gap-2">
Compliance status
<svg
width="8"
height="8"
viewBox="0 0 8 8"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="text-gray-400"
>
<path d="M4 6L7 3H1L4 6Z" fill="currentColor" />
</svg>
</div>
</th>
<th className="py-3 px-4"></th>
</tr>
</thead>
<tbody>
{vendors.map((vendor) => {
const riskStatus = getRiskStatus(vendor.id);
return (
<tr
key={vendor.id}
className="border-b border-gray-100 hover:bg-gray-50 transition-colors bg-white"
>
<td className="py-4 px-4">
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8 bg-white">
{(() => {
const faviconUrl = vendor.websiteUrl ? getFaviconUrl(vendor.websiteUrl) : null;
return faviconUrl ? (
<AvatarImage src={faviconUrl} alt={vendor.name} />
) : (
<AvatarFallback>{vendor.name?.[0]}</AvatarFallback>
);
})()}
</Avatar>
<Link
to={`/organizations/${organizationId}/vendors/${vendor.id}`}
className="font-medium hover:underline"
>
{vendor.name}
</Link>
</div>
</td>
<td className="py-4 px-4 text-sm">
{formatDate(vendor.updatedAt)}
</td>
<td className="py-4 px-4">
<Badge className={`${getRiskBadgeStyle(riskStatus)} font-medium text-xs px-2 py-1 rounded-md`}>
{riskStatus === "CRITICAL"
? "Critical"
: riskStatus === "SIGNIFICANT"
? "Medium"
: "General"}
</Badge>
</td>
<td className="py-4 px-4">
<Badge
className={`${
riskStatus === "CRITICAL"
? "bg-[#FFEFEF] text-[#CD2B31]"
: riskStatus === "SIGNIFICANT"
? "bg-[#FFF1E7] text-[#BD4B00]"
: "bg-[#EEFADC] text-[#5D770D]"
} font-medium text-xs px-2 py-1 rounded-md`}
>
{riskStatus === "CRITICAL"
? "Late"
: riskStatus === "SIGNIFICANT"
? "In Progress"
: "Completed"}
</Badge>
</td>
<td className="py-4 px-4 text-right">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={(e) => {
e.preventDefault();
// Handle menu click - could be expanded into a dropdown menu
if (
window.confirm(
"Are you sure you want to delete this vendor?"
)
) {
deleteVendor({
variables: {
connections: [vendorsConnection.vendors.__id],
input: {
vendorId: vendor.id,
},
},
onCompleted() {
toast({
title: "Vendor deleted",
description:
"The vendor has been deleted successfully",
});
},
});
}
}}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<LoadAboveButton

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<ccdef4cce3d167b401f6d89dea1dde33>>
* @generated SignedSource<<55ac3df9e65daff882c0d44f7b4904c2>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -43,6 +43,7 @@ export type ListVendorViewCreateVendorMutation$data = {
readonly id: string;
readonly name: string;
readonly updatedAt: string;
readonly websiteUrl: string | null | undefined;
};
};
};
@@ -107,6 +108,13 @@ v3 = {
"name": "description",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "websiteUrl",
"storageKey": null
},
{
"alias": null,
"args": null,
@@ -193,16 +201,16 @@ return {
]
},
"params": {
"cacheID": "03c1d61a3a252ca74bd8da1821f1e0b9",
"cacheID": "4ff1631133af0ca4a0d3109dbd1bda4f",
"id": null,
"metadata": {},
"name": "ListVendorViewCreateVendorMutation",
"operationKind": "mutation",
"text": "mutation ListVendorViewCreateVendorMutation(\n $input: CreateVendorInput!\n) {\n createVendor(input: $input) {\n vendorEdge {\n node {\n id\n name\n description\n createdAt\n updatedAt\n }\n }\n }\n}\n"
"text": "mutation ListVendorViewCreateVendorMutation(\n $input: CreateVendorInput!\n) {\n createVendor(input: $input) {\n vendorEdge {\n node {\n id\n name\n description\n websiteUrl\n createdAt\n updatedAt\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "05ca888317537533f7345000f0948c0c";
(node as any).hash = "94be3494153c93d4bf4d1a6324b95f17";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<a59c7eee4b53c2b74df89a2fa693d9cd>>
* @generated SignedSource<<2d5b613cbbbd051c4c1f1561f55bf644>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -210,6 +210,13 @@ return {
"name": "description",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "websiteUrl",
"storageKey": null
},
{
"alias": null,
"args": null,
@@ -313,16 +320,16 @@ return {
]
},
"params": {
"cacheID": "b4165eeba6363c964b11f14f678d5338",
"cacheID": "b1055c67d0940ecd9189ec80410a8add",
"id": null,
"metadata": {},
"name": "ListVendorViewPaginationQuery",
"operationKind": "query",
"text": "query ListVendorViewPaginationQuery(\n $after: CursorKey\n $before: CursorKey\n $first: Int\n $last: Int\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...ListVendorView_vendors_pbnwq\n id\n }\n}\n\nfragment ListVendorView_vendors_pbnwq on Organization {\n vendors(first: $first, after: $after, last: $last, before: $before, orderBy: {direction: ASC, field: NAME}) {\n edges {\n node {\n id\n name\n description\n createdAt\n updatedAt\n __typename\n }\n cursor\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n id\n}\n"
"text": "query ListVendorViewPaginationQuery(\n $after: CursorKey\n $before: CursorKey\n $first: Int\n $last: Int\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...ListVendorView_vendors_pbnwq\n id\n }\n}\n\nfragment ListVendorView_vendors_pbnwq on Organization {\n vendors(first: $first, after: $after, last: $last, before: $before, orderBy: {direction: ASC, field: NAME}) {\n edges {\n node {\n id\n name\n description\n websiteUrl\n createdAt\n updatedAt\n __typename\n }\n cursor\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n id\n}\n"
}
};
})();
(node as any).hash = "477e430705f0f3cbadb8dfac89e24629";
(node as any).hash = "3908e5fd1af7e0cb367a480fc9fc437e";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<bdea7e121ea26a5af6985b4399be4bcc>>
* @generated SignedSource<<4d1328e7df631fabe3c8b7dccdf5ffb0>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -212,6 +212,13 @@ return {
"name": "description",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "websiteUrl",
"storageKey": null
},
{
"alias": null,
"args": null,
@@ -315,12 +322,12 @@ return {
]
},
"params": {
"cacheID": "d93a40f389769d5895b502cb8e7a2c16",
"cacheID": "f4269cea33bcb3665cfc49bc350cf426",
"id": null,
"metadata": {},
"name": "ListVendorViewQuery",
"operationKind": "query",
"text": "query ListVendorViewQuery(\n $organizationId: ID!\n $first: Int\n $after: CursorKey\n $last: Int\n $before: CursorKey\n) {\n organization: node(id: $organizationId) {\n __typename\n id\n ...ListVendorView_vendors_pbnwq\n }\n}\n\nfragment ListVendorView_vendors_pbnwq on Organization {\n vendors(first: $first, after: $after, last: $last, before: $before, orderBy: {direction: ASC, field: NAME}) {\n edges {\n node {\n id\n name\n description\n createdAt\n updatedAt\n __typename\n }\n cursor\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n id\n}\n"
"text": "query ListVendorViewQuery(\n $organizationId: ID!\n $first: Int\n $after: CursorKey\n $last: Int\n $before: CursorKey\n) {\n organization: node(id: $organizationId) {\n __typename\n id\n ...ListVendorView_vendors_pbnwq\n }\n}\n\nfragment ListVendorView_vendors_pbnwq on Organization {\n vendors(first: $first, after: $after, last: $last, before: $before, orderBy: {direction: ASC, field: NAME}) {\n edges {\n node {\n id\n name\n description\n websiteUrl\n createdAt\n updatedAt\n __typename\n }\n cursor\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n id\n}\n"
}
};
})();

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<1ea33fd638cd871d891e3cd41e1f3372>>
* @generated SignedSource<<e3d4dae05baff068e5f61916fb436e10>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -21,6 +21,7 @@ export type ListVendorView_vendors$data = {
readonly id: string;
readonly name: string;
readonly updatedAt: string;
readonly websiteUrl: string | null | undefined;
};
}>;
readonly pageInfo: {
@@ -153,6 +154,13 @@ return {
"name": "description",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "websiteUrl",
"storageKey": null
},
{
"alias": null,
"args": null,
@@ -248,6 +256,6 @@ return {
};
})();
(node as any).hash = "477e430705f0f3cbadb8dfac89e24629";
(node as any).hash = "3908e5fd1af7e0cb367a480fc9fc437e";
export default node;