Add all missing page skeletons and use them as fallback

Signed-off-by: Émile Ré <nemile.re@gmail.com>
This commit is contained in:
Émile Ré
2025-03-21 15:13:55 +04:00
parent eca4f03ec1
commit f176bf13cd
20 changed files with 2540 additions and 2659 deletions

View File

@@ -13,6 +13,23 @@ import AuthLayout from "./layouts/AuthLayout";
import { RelayEnvironment } from "./RelayEnvironment";
import VisitorErrorBoundary from "./components/VisitorErrorBoundary";
import { FrameworkListPageSkeleton } from "./pages/FrameworkListPage";
import { ControlOverviewPageSkeleton } from "./pages/ControlOverviewPage";
import { CreateControlPageSkeleton } from "./pages/CreateControlPage";
import { UpdateFrameworkPageSkeleton } from "./pages/UpdateFrameworkPage";
import { UpdateControlPageSkeleton } from "./pages/UpdateControlPage";
import { CreateFrameworkPageSkeleton } from "./pages/CreateFrameworkPage";
import { CreateOrganizationPageSkeleton } from "./pages/CreateOrganizationPage";
import { CreatePeoplePageSkeleton } from "./pages/CreatePeoplePage";
import { CreatePolicyPageSkeleton } from "./pages/CreatePolicyPage";
import { FrameworkOverviewPageSkeleton } from "./pages/FrameworkOverviewPage";
import { PeopleListPageSkeleton } from "./pages/PeopleListPage";
import { PeopleOverviewPageSkeleton } from "./pages/PeopleOverviewPage";
import { PolicyListPageSkeleton } from "./pages/PolicyListPage";
import { PolicyOverviewPageSkeleton } from "./pages/PolicyOverviewPage";
import { UpdatePolicyPageSkeleton } from "./pages/UpdatePolicyPage";
import { VendorListPageSkeleton } from "./pages/VendorListPage";
import { VendorOverviewPageSkeleton } from "./pages/VendorOverviewPage";
import { SettingsPageSkeleton } from "./pages/SettingsPage";
posthog.init(process.env.POSTHOG_KEY!, {
api_host: process.env.POSTHOG_HOST,
@@ -85,7 +102,7 @@ function App() {
<Route
path="create"
element={
<Suspense>
<Suspense fallback={<CreateOrganizationPageSkeleton />}>
<ErrorBoundaryWithLocation>
<CreateOrganizationPage />
</ErrorBoundaryWithLocation>
@@ -106,7 +123,7 @@ function App() {
<Route
path="people"
element={
<Suspense>
<Suspense fallback={<PeopleListPageSkeleton />}>
<ErrorBoundaryWithLocation>
<PeopleListPage />
</ErrorBoundaryWithLocation>
@@ -116,7 +133,7 @@ function App() {
<Route
path="people/create"
element={
<Suspense>
<Suspense fallback={<CreatePeoplePageSkeleton />}>
<ErrorBoundaryWithLocation>
<CreatePeoplePage />
</ErrorBoundaryWithLocation>
@@ -126,7 +143,7 @@ function App() {
<Route
path="people/:peopleId"
element={
<Suspense>
<Suspense fallback={<PeopleOverviewPageSkeleton />}>
<ErrorBoundaryWithLocation>
<PeopleOverviewPage />
</ErrorBoundaryWithLocation>
@@ -136,7 +153,7 @@ function App() {
<Route
path="vendors"
element={
<Suspense>
<Suspense fallback={<VendorListPageSkeleton />}>
<ErrorBoundaryWithLocation>
<VendorListPage />
</ErrorBoundaryWithLocation>
@@ -156,7 +173,7 @@ function App() {
<Route
path="frameworks/create"
element={
<Suspense>
<Suspense fallback={<CreateFrameworkPageSkeleton />}>
<ErrorBoundaryWithLocation>
<CreateFrameworkPage />
</ErrorBoundaryWithLocation>
@@ -166,7 +183,9 @@ function App() {
<Route
path="frameworks/:frameworkId"
element={
<Suspense>
<Suspense
fallback={<FrameworkOverviewPageSkeleton />}
>
<ErrorBoundaryWithLocation>
<FrameworkOverviewPage />
</ErrorBoundaryWithLocation>
@@ -176,7 +195,7 @@ function App() {
<Route
path="frameworks/:frameworkId/update"
element={
<Suspense>
<Suspense fallback={<UpdateFrameworkPageSkeleton />}>
<ErrorBoundaryWithLocation>
<UpdateFrameworkPage />
</ErrorBoundaryWithLocation>
@@ -186,7 +205,7 @@ function App() {
<Route
path="frameworks/:frameworkId/controls/create"
element={
<Suspense>
<Suspense fallback={<CreateControlPageSkeleton />}>
<ErrorBoundaryWithLocation>
<CreateControlPage />
</ErrorBoundaryWithLocation>
@@ -196,7 +215,7 @@ function App() {
<Route
path="frameworks/:frameworkId/controls/:controlId"
element={
<Suspense>
<Suspense fallback={<ControlOverviewPageSkeleton />}>
<ErrorBoundaryWithLocation>
<ControlOverviewPage />
</ErrorBoundaryWithLocation>
@@ -206,7 +225,7 @@ function App() {
<Route
path="frameworks/:frameworkId/controls/:controlId/update"
element={
<Suspense>
<Suspense fallback={<UpdateControlPageSkeleton />}>
<ErrorBoundaryWithLocation>
<UpdateControlPage />
</ErrorBoundaryWithLocation>
@@ -216,7 +235,7 @@ function App() {
<Route
path="vendors/:vendorId"
element={
<Suspense>
<Suspense fallback={<VendorOverviewPageSkeleton />}>
<ErrorBoundaryWithLocation>
<VendorOverviewPage />
</ErrorBoundaryWithLocation>
@@ -227,7 +246,7 @@ function App() {
<Route
path="policies"
element={
<Suspense>
<Suspense fallback={<PolicyListPageSkeleton />}>
<ErrorBoundaryWithLocation>
<PolicyListPage />
</ErrorBoundaryWithLocation>
@@ -237,7 +256,7 @@ function App() {
<Route
path="policies/create"
element={
<Suspense>
<Suspense fallback={<CreatePolicyPageSkeleton />}>
<ErrorBoundaryWithLocation>
<CreatePolicyPage />
</ErrorBoundaryWithLocation>
@@ -247,7 +266,7 @@ function App() {
<Route
path="policies/:policyId"
element={
<Suspense>
<Suspense fallback={<PolicyOverviewPageSkeleton />}>
<ErrorBoundaryWithLocation>
<PolicyOverviewPage />
</ErrorBoundaryWithLocation>
@@ -257,7 +276,7 @@ function App() {
<Route
path="policies/:policyId/update"
element={
<Suspense>
<Suspense fallback={<UpdatePolicyPageSkeleton />}>
<ErrorBoundaryWithLocation>
<UpdatePolicyPage />
</ErrorBoundaryWithLocation>
@@ -267,7 +286,7 @@ function App() {
<Route
path="settings"
element={
<Suspense>
<Suspense fallback={<SettingsPageSkeleton />}>
<ErrorBoundaryWithLocation>
<SettingsPage />
</ErrorBoundaryWithLocation>

View File

@@ -56,8 +56,8 @@ export function PageHeading({
const PageHeadingSkeleton: FC = () => {
return (
<div className="py-[3px]">
<div className="bg-muted animate rounded-lg w-60 h-7.5" />
<div className="py-[3px] w-2/5">
<div className="bg-muted animate rounded-lg h-7.5" />
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
import { Suspense, useState } from "react";
import { useNavigate, useParams } from "react-router";
import { graphql, useMutation, ConnectionHandler } from "react-relay";
import { Helmet } from "react-helmet-async";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@@ -21,7 +20,7 @@ import {
CreateControlPageCreateControlMutation,
ControlImportance,
} from "./__generated__/CreateControlPageCreateControlMutation.graphql";
import { PageHeader } from "@/components/PageHeader";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
const createControlMutation = graphql`
mutation CreateControlPageCreateControlMutation(
@@ -182,90 +181,93 @@ function CreateControlPageContent() {
};
return (
<>
<Helmet>
<title>Create Control - Probo</title>
</Helmet>
<div className="container">
<PageHeader
className="mb-17"
title="Create Control"
description="Create a new control for your framework"
/>
<PageTemplate
title="Create Control"
description="Create a new control for your framework"
>
<Card className="max-w-2xl">
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<EditableField
label="Name"
value={formData.name}
onChange={(value) => handleFieldChange("name", value)}
required
/>
<Card className="max-w-2xl">
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<EditableField
label="Name"
value={formData.name}
onChange={(value) => handleFieldChange("name", value)}
required
/>
<EditableField
label="Category"
value={formData.category}
onChange={(value) => handleFieldChange("category", value)}
required
helpText="The category this control belongs to"
/>
<EditableField
label="Category"
value={formData.category}
onChange={(value) => handleFieldChange("category", value)}
required
helpText="The category this control belongs to"
/>
<EditableField
label="Description"
value={formData.description}
onChange={(value) => handleFieldChange("description", value)}
required
multiline
helpText="Provide a detailed description of the control"
/>
<EditableField
label="Description"
value={formData.description}
onChange={(value) => handleFieldChange("description", value)}
required
multiline
helpText="Provide a detailed description of the control"
/>
<div className="space-y-2">
<Label htmlFor="importance" className="text-sm font-medium">
Importance
</Label>
<Select
value={formData.importance}
onValueChange={(value) =>
handleFieldChange("importance", value as ControlImportance)
}
>
<SelectTrigger>
<SelectValue placeholder="Select importance" />
</SelectTrigger>
<SelectContent>
<SelectItem value="MANDATORY">Mandatory</SelectItem>
<SelectItem value="PREFERRED">Preferred</SelectItem>
<SelectItem value="ADVANCED">Advanced</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="importance" className="text-sm font-medium">
Importance
</Label>
<Select
value={formData.importance}
onValueChange={(value) =>
handleFieldChange("importance", value as ControlImportance)
}
>
<SelectTrigger>
<SelectValue placeholder="Select importance" />
</SelectTrigger>
<SelectContent>
<SelectItem value="MANDATORY">Mandatory</SelectItem>
<SelectItem value="PREFERRED">Preferred</SelectItem>
<SelectItem value="ADVANCED">Advanced</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() =>
navigate(
`/organizations/${organizationId}/frameworks/${frameworkId}`
)
}
>
Cancel
</Button>
<Button type="submit" disabled={isInFlight}>
{isInFlight ? "Creating..." : "Create Control"}
</Button>
</div>
</form>
</Card>
</PageTemplate>
);
}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() =>
navigate(
`/organizations/${organizationId}/frameworks/${frameworkId}`
)
}
>
Cancel
</Button>
<Button type="submit" disabled={isInFlight}>
{isInFlight ? "Creating..." : "Create Control"}
</Button>
</div>
</form>
</Card>
</div>
</>
export function CreateControlPageSkeleton() {
return (
<PageTemplateSkeleton
title="Create Control"
description="Create a new control for your framework"
>
<div className="max-w-2xl aspect-square bg-muted rounded-xl animate-pulse" />
</PageTemplateSkeleton>
);
}
export default function CreateControlPage() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Suspense fallback={<CreateControlPageSkeleton />}>
<CreateControlPageContent />
</Suspense>
);

View File

@@ -1,7 +1,6 @@
import { Suspense, useState } from "react";
import { useNavigate, useParams } from "react-router";
import { graphql, useMutation, ConnectionHandler } from "react-relay";
import { Helmet } from "react-helmet-async";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@@ -11,6 +10,7 @@ import { useToast } from "@/hooks/use-toast";
import { HelpCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { CreateFrameworkPageCreateFrameworkMutation } from "./__generated__/CreateFrameworkPageCreateFrameworkMutation.graphql";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
const createFrameworkMutation = graphql`
mutation CreateFrameworkPageCreateFrameworkMutation(
@@ -69,7 +69,7 @@ function EditableField({
}
className={cn(
"w-full resize-none",
required && !value && "border-red-500",
required && !value && "border-red-500"
)}
placeholder={`Enter ${label.toLowerCase()}`}
rows={4}
@@ -101,7 +101,7 @@ function CreateFrameworkPageContent() {
const [commit, isInFlight] =
useMutation<CreateFrameworkPageCreateFrameworkMutation>(
createFrameworkMutation,
createFrameworkMutation
);
const handleFieldChange = (field: keyof typeof formData, value: unknown) => {
@@ -125,7 +125,7 @@ function CreateFrameworkPageContent() {
const connectionId = ConnectionHandler.getConnectionID(
organizationId!,
"FrameworkListPage_frameworks",
"FrameworkListPage_frameworks"
);
commit({
@@ -153,7 +153,7 @@ function CreateFrameworkPageContent() {
});
navigate(
`/organizations/${organizationId}/frameworks/${data.createFramework.frameworkEdge.node.id}`,
`/organizations/${organizationId}/frameworks/${data.createFramework.frameworkEdge.node.id}`
);
},
onError(error) {
@@ -167,60 +167,62 @@ function CreateFrameworkPageContent() {
};
return (
<>
<Helmet>
<title>Create Framework - Probo</title>
</Helmet>
<div className="container mx-auto py-6">
<div className="mb-6">
<h1 className="text-2xl font-bold">Create Framework</h1>
<p className="text-muted-foreground">
Create a new framework to organize your controls
</p>
</div>
<PageTemplate
title="Create Framework"
description="Create a new framework to organize your controls"
>
<Card className="max-w-2xl">
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<EditableField
label="Name"
value={formData.name}
onChange={(value) => handleFieldChange("name", value)}
required
/>
<Card className="max-w-2xl">
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<EditableField
label="Name"
value={formData.name}
onChange={(value) => handleFieldChange("name", value)}
required
/>
<EditableField
label="Description"
value={formData.description}
onChange={(value) => handleFieldChange("description", value)}
required
multiline
helpText="Provide a detailed description of the framework"
/>
<EditableField
label="Description"
value={formData.description}
onChange={(value) => handleFieldChange("description", value)}
required
multiline
helpText="Provide a detailed description of the framework"
/>
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() =>
navigate(`/organizations/${organizationId}/frameworks`)
}
>
Cancel
</Button>
<Button type="submit" disabled={isInFlight}>
{isInFlight ? "Creating..." : "Create Framework"}
</Button>
</div>
</form>
</Card>
</PageTemplate>
);
}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() =>
navigate(`/organizations/${organizationId}/frameworks`)
}
>
Cancel
</Button>
<Button type="submit" disabled={isInFlight}>
{isInFlight ? "Creating..." : "Create Framework"}
</Button>
</div>
</form>
</Card>
</div>
</>
export function CreateFrameworkPageSkeleton() {
return (
<PageTemplateSkeleton
title="Create Framework"
description="Create a new framework to organize your controls"
>
<div className="max-w-2xl aspect-square bg-muted rounded-xl animate-pulse" />
</PageTemplateSkeleton>
);
}
export default function CreateFrameworkPage() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Suspense fallback={<CreateFrameworkPageSkeleton />}>
<CreateFrameworkPageContent />
</Suspense>
);

View File

@@ -6,7 +6,6 @@ import {
useMutation,
useLazyLoadQuery,
} from "react-relay";
import { Helmet } from "react-helmet-async";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -21,6 +20,7 @@ import { useToast } from "@/hooks/use-toast";
import { HelpCircle } from "lucide-react";
import { CreateOrganizationPageCreateOrganizationMutation } from "./__generated__/CreateOrganizationPageCreateOrganizationMutation.graphql";
import { CreateOrganizationPageViewerQuery } from "./__generated__/CreateOrganizationPageViewerQuery.graphql";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
const createOrganizationMutation = graphql`
mutation CreateOrganizationPageCreateOrganizationMutation(
@@ -81,16 +81,27 @@ function EditableField({
);
}
export function CreateOrganizationPageSkeleton() {
return (
<PageTemplateSkeleton
title="Create Organization"
description="Create a new organization to manage your compliance and security needs."
>
<div className="max-w-2xl aspect-square bg-muted rounded-xl animate-pulse" />
</PageTemplateSkeleton>
);
}
export default function CreateOrganizationPage() {
const navigate = useNavigate();
const { toast } = useToast();
const data = useLazyLoadQuery<CreateOrganizationPageViewerQuery>(
viewerQuery,
{},
{}
);
const [createOrganization] =
useMutation<CreateOrganizationPageCreateOrganizationMutation>(
createOrganizationMutation,
createOrganizationMutation
);
const [formData, setFormData] = useState({
name: "",
@@ -114,7 +125,7 @@ export default function CreateOrganizationPage() {
connections: [
ConnectionHandler.getConnectionID(
data.viewer.id,
"OrganizationSwitcher_organizations",
"OrganizationSwitcher_organizations"
),
],
},
@@ -138,46 +149,33 @@ export default function CreateOrganizationPage() {
};
return (
<>
<Helmet>
<title>Create Organization - Probo</title>
</Helmet>
<PageTemplate
title="Create Organization"
description="Create a new organization to manage your compliance and security needs."
>
<form onSubmit={handleSubmit}>
<Card className="max-w-2xl">
<CardHeader>
<CardTitle>Organization Details</CardTitle>
<CardDescription>
Enter the basic information about your organization.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<EditableField
label="Organization Name"
value={formData.name}
onChange={(value) => handleFieldChange("name", value)}
helpText="The name of your organization as it will appear throughout the platform."
required
/>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Create Organization
</h1>
<p className="text-muted-foreground">
Create a new organization to manage your compliance and security
needs.
</p>
</div>
<form onSubmit={handleSubmit}>
<Card>
<CardHeader>
<CardTitle>Organization Details</CardTitle>
<CardDescription>
Enter the basic information about your organization.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<EditableField
label="Organization Name"
value={formData.name}
onChange={(value) => handleFieldChange("name", value)}
helpText="The name of your organization as it will appear throughout the platform."
required
/>
<Button type="submit" className="w-full">
Create Organization
</Button>
</CardContent>
</Card>
</form>
</div>
</>
<Button type="submit" className="w-full">
Create Organization
</Button>
</CardContent>
</Card>
</form>
</PageTemplate>
);
}

View File

@@ -1,7 +1,6 @@
import { Suspense, useState } from "react";
import { useNavigate, useParams } from "react-router";
import { graphql, useMutation, ConnectionHandler } from "react-relay";
import { Helmet } from "react-helmet-async";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@@ -10,7 +9,7 @@ import { useToast } from "@/hooks/use-toast";
import { HelpCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { CreatePeoplePageCreatePeopleMutation } from "./__generated__/CreatePeoplePageCreatePeopleMutation.graphql";
import { PageHeader } from "@/components/PageHeader";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
const createPeopleMutation = graphql`
mutation CreatePeoplePageCreatePeopleMutation(
@@ -150,177 +149,166 @@ function CreatePeoplePageContent() {
};
return (
<>
<Helmet>
<title>Create Person - Probo Console</title>
</Helmet>
<div className="container">
<PageHeader
className="mb-17"
title="Add a Person"
description="Add a new person interacting with organization"
/>
<PageTemplate
title="Create Person"
description="Add a new person interacting with organization"
>
<form onSubmit={handleSubmit}>
<div className="max-w-2xl space-y-6">
<EditableField
label="Full Name"
value={formData.fullName}
onChange={(value) => handleFieldChange("fullName", value)}
required
/>
<form onSubmit={handleSubmit}>
<div className="max-w-4xl space-y-6">
<EditableField
label="Full Name"
value={formData.fullName}
onChange={(value) => handleFieldChange("fullName", value)}
required
/>
<EditableField
label="Primary Email"
value={formData.primaryEmailAddress}
type="email"
onChange={(value) =>
handleFieldChange("primaryEmailAddress", value)
}
required
/>
<EditableField
label="Primary Email"
value={formData.primaryEmailAddress}
type="email"
onChange={(value) =>
handleFieldChange("primaryEmailAddress", value)
}
required
/>
<div className="space-y-2">
<div className="flex items-center gap-2">
<HelpCircle className="h-4 w-4 text-gray-400" />
<Label className="text-sm">Additional Email Addresses</Label>
</div>
<div className="space-y-2">
{formData.additionalEmailAddresses.map((email, index) => (
<div key={index} className="flex gap-2">
<Input
type="email"
value={email}
onChange={(e) => {
const newEmails = [
...formData.additionalEmailAddresses,
];
newEmails[index] = e.target.value;
handleFieldChange(
"additionalEmailAddresses",
newEmails
);
}}
/>
<Button
type="button"
variant="outline"
onClick={() => {
const newEmails =
formData.additionalEmailAddresses.filter(
(_, i) => i !== index
);
handleFieldChange(
"additionalEmailAddresses",
newEmails
);
}}
>
Remove
</Button>
</div>
))}
<Button
type="button"
variant="outline"
onClick={() => {
handleFieldChange("additionalEmailAddresses", [
...formData.additionalEmailAddresses,
"",
]);
}}
>
Add Email
</Button>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<HelpCircle className="h-4 w-4 text-gray-400" />
<Label className="text-sm">Additional Email Addresses</Label>
</div>
<div className="space-y-2">
{formData.additionalEmailAddresses.map((email, index) => (
<div key={index} className="flex gap-2">
<Input
type="email"
value={email}
onChange={(e) => {
const newEmails = [...formData.additionalEmailAddresses];
newEmails[index] = e.target.value;
handleFieldChange("additionalEmailAddresses", newEmails);
}}
/>
<Button
type="button"
variant="outline"
onClick={() => {
const newEmails =
formData.additionalEmailAddresses.filter(
(_, i) => i !== index
);
handleFieldChange("additionalEmailAddresses", newEmails);
}}
>
Remove
</Button>
</div>
))}
<Button
type="button"
variant="outline"
onClick={() => {
handleFieldChange("additionalEmailAddresses", [
...formData.additionalEmailAddresses,
"",
]);
}}
>
Add Email
</Button>
</div>
</div>
<Card className="p-6">
<div className="space-y-4">
<div className="space-y-2">
<h2 className="text-lg font-medium">Additional Information</h2>
<p className="text-sm text-gray-500">
Additional details about the person
</p>
</div>
<Card className="p-6">
<div className="space-y-4">
<div className="space-y-2">
<h2 className="text-lg font-medium">
Additional Information
</h2>
<p className="text-sm text-gray-500">
Additional details about the person
</p>
</div>
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<HelpCircle className="h-4 w-4 text-gray-400" />
<Label className="text-sm">Kind</Label>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleFieldChange("kind", "EMPLOYEE")}
className={cn(
"rounded-full px-4 py-1 text-sm transition-colors",
formData.kind === "EMPLOYEE"
? "bg-blue-100 text-blue-900 ring-2 ring-blue-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
)}
>
Employee
</button>
<button
type="button"
onClick={() => handleFieldChange("kind", "CONTRACTOR")}
className={cn(
"rounded-full px-4 py-1 text-sm transition-colors",
formData.kind === "CONTRACTOR"
? "bg-purple-100 text-purple-900 ring-2 ring-purple-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
)}
>
Contractor
</button>
<button
type="button"
onClick={() =>
handleFieldChange("kind", "SERVICE_ACCOUNT")
}
className={cn(
"rounded-full px-4 py-1 text-sm transition-colors",
formData.kind === "SERVICE_ACCOUNT"
? "bg-green-100 text-green-900 ring-2 ring-green-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
)}
>
Service Account
</button>
</div>
<div className="flex items-center gap-2">
<HelpCircle className="h-4 w-4 text-gray-400" />
<Label className="text-sm">Kind</Label>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleFieldChange("kind", "EMPLOYEE")}
className={cn(
"rounded-full px-4 py-1 text-sm transition-colors",
formData.kind === "EMPLOYEE"
? "bg-blue-100 text-blue-900 ring-2 ring-blue-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
)}
>
Employee
</button>
<button
type="button"
onClick={() => handleFieldChange("kind", "CONTRACTOR")}
className={cn(
"rounded-full px-4 py-1 text-sm transition-colors",
formData.kind === "CONTRACTOR"
? "bg-purple-100 text-purple-900 ring-2 ring-purple-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
)}
>
Contractor
</button>
<button
type="button"
onClick={() =>
handleFieldChange("kind", "SERVICE_ACCOUNT")
}
className={cn(
"rounded-full px-4 py-1 text-sm transition-colors",
formData.kind === "SERVICE_ACCOUNT"
? "bg-green-100 text-green-900 ring-2 ring-green-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
)}
>
Service Account
</button>
</div>
</div>
</div>
</Card>
</div>
<div className="fixed bottom-6 right-6 flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => navigate(-1)}
>
Cancel
</Button>
<Button
type="submit"
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
Create Person
</Button>
</div>
</form>
</div>
</>
</div>
</Card>
</div>
<div className="fixed bottom-6 right-6 flex gap-2">
<Button type="button" variant="outline" onClick={() => navigate(-1)}>
Cancel
</Button>
<Button
type="submit"
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
Create Person
</Button>
</div>
</form>
</PageTemplate>
);
}
export function CreatePeoplePageSkeleton() {
return (
<PageTemplateSkeleton
title="Create Person"
description="Add a new person interacting with organization"
>
<div className="max-w-2xl aspect-square bg-muted rounded-xl animate-pulse" />
</PageTemplateSkeleton>
);
}
export default function CreatePeoplePage() {
return (
<Suspense fallback={null}>
<Suspense fallback={<CreatePeoplePageSkeleton />}>
<CreatePeoplePageContent />
</Suspense>
);

View File

@@ -8,7 +8,6 @@ import {
usePreloadedQuery,
PreloadedQuery,
} from "react-relay";
import { Helmet } from "react-helmet-async";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -21,7 +20,7 @@ import PeopleSelector from "@/components/PeopleSelector";
import { Suspense } from "react";
import type { CreatePolicyPageMutation } from "./__generated__/CreatePolicyPageMutation.graphql";
import type { CreatePolicyPageQuery as CreatePolicyPageQueryType } from "./__generated__/CreatePolicyPageQuery.graphql";
import { PageHeader } from "@/components/PageHeader";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
const CreatePolicyQuery = graphql`
query CreatePolicyPageQuery($organizationId: ID!) {
@@ -156,136 +155,119 @@ function CreatePolicyForm({
};
return (
<>
<Helmet>
<title>Create Policy - Probo Console</title>
</Helmet>
<div className="container">
<PageHeader
className="mb-17"
title="Create Policy"
description="Create a new policy for your organization"
/>
<PageTemplate
title="Create Policy"
description="Create a new policy for your organization"
>
<form onSubmit={handleSubmit}>
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Policy Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Policy Name</Label>
<Input
id="name"
placeholder="Enter policy name"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<form onSubmit={handleSubmit}>
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Policy Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Policy Name</Label>
<Input
id="name"
placeholder="Enter policy name"
value={name}
onChange={(e) => setName(e.target.value)}
required
<div className="space-y-2">
<Label htmlFor="content">Policy Content</Label>
<div className="min-h-[300px]">
<PolicyEditor
initialContent={content}
onChange={(html) => setContent(html)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="content">Policy Content</Label>
<div className="min-h-[300px]">
<PolicyEditor
initialContent={content}
onChange={(html) => setContent(html)}
/>
<div className="space-y-2">
<Label>Status</Label>
<RadioGroup
value={status}
onValueChange={(value: "DRAFT" | "ACTIVE") =>
setStatus(value)
}
className="flex space-x-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="DRAFT" id="draft" />
<Label htmlFor="draft" className="cursor-pointer">
Draft
</Label>
</div>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="ACTIVE" id="active" />
<Label htmlFor="active" className="cursor-pointer">
Active
</Label>
</div>
</RadioGroup>
</div>
<div className="space-y-2">
<Label>Status</Label>
<RadioGroup
value={status}
onValueChange={(value: "DRAFT" | "ACTIVE") =>
setStatus(value)
}
className="flex space-x-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="DRAFT" id="draft" />
<Label htmlFor="draft" className="cursor-pointer">
Draft
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="ACTIVE" id="active" />
<Label htmlFor="active" className="cursor-pointer">
Active
</Label>
</div>
</RadioGroup>
</div>
<div className="space-y-2">
<Label htmlFor="owner" className="flex items-center gap-2">
<User className="h-4 w-4" />
Policy Owner
</Label>
<PeopleSelector
organizationRef={data.organization}
selectedPersonId={ownerId}
onSelect={setOwnerId}
placeholder="Select policy owner"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="owner" className="flex items-center gap-2">
<User className="h-4 w-4" />
Policy Owner
</Label>
<PeopleSelector
organizationRef={data.organization}
selectedPersonId={ownerId}
onSelect={setOwnerId}
placeholder="Select policy owner"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="reviewDate" className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
Review Date
</Label>
<Input
id="reviewDate"
type="date"
value={reviewDate}
onChange={(e) => setReviewDate(e.target.value)}
/>
</div>
</CardContent>
</Card>
<div className="space-y-2">
<Label
htmlFor="reviewDate"
className="flex items-center gap-2"
>
<Calendar className="h-4 w-4" />
Review Date
</Label>
<Input
id="reviewDate"
type="date"
value={reviewDate}
onChange={(e) => setReviewDate(e.target.value)}
/>
</div>
</CardContent>
</Card>
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
onClick={() =>
navigate(`/organizations/${organizationId}/policies`)
}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Creating..." : "Create Policy"}
</Button>
</div>
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
onClick={() =>
navigate(`/organizations/${organizationId}/policies`)
}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Creating..." : "Create Policy"}
</Button>
</div>
</form>
</div>
</>
</div>
</form>
</PageTemplate>
);
}
function CreatePolicyPageFallback() {
export function CreatePolicyPageSkeleton() {
return (
<div className="container mx-auto py-6">
<div className="flex items-center mb-6">
<div className="mr-4">
<div className="h-12 w-12 bg-muted animate-pulse rounded-lg" />
</div>
<div>
<div className="h-8 w-48 bg-muted animate-pulse rounded mb-2" />
<div className="h-4 w-64 bg-muted animate-pulse rounded" />
</div>
</div>
<PageTemplateSkeleton
title="Create Policy"
description="Create a new policy for your organization"
>
<div className="bg-muted animate-pulse rounded-lg h-[600px]" />
</div>
</PageTemplateSkeleton>
);
}
@@ -300,9 +282,13 @@ export default function CreatePolicyPage() {
}
}, [organizationId, loadQuery]);
if (!queryRef) {
return <CreatePolicyPageSkeleton />;
}
return (
<Suspense fallback={<CreatePolicyPageFallback />}>
{queryRef && <CreatePolicyForm queryRef={queryRef} />}
<Suspense fallback={<CreatePolicyPageSkeleton />}>
{<CreatePolicyForm queryRef={queryRef} />}
</Suspense>
);
}

View File

@@ -320,6 +320,10 @@ export default function FrameworkListPage() {
loadQuery({ organizationId: organizationId! });
}, [loadQuery, organizationId]);
if (!queryRef) {
return <FrameworkListPageSkeleton />;
}
return (
<Suspense fallback={<FrameworkListPageSkeleton />}>
{queryRef && <FrameworkListPageContent queryRef={queryRef} />}

View File

@@ -20,8 +20,7 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import type { FrameworkOverviewPageQuery as FrameworkOverviewPageQueryType } from "./__generated__/FrameworkOverviewPageQuery.graphql";
import { Helmet } from "react-helmet-async";
import { PageHeader } from "@/components/PageHeader";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
const FrameworkOverviewPageQuery = graphql`
query FrameworkOverviewPageQuery($frameworkId: ID!) {
@@ -169,32 +168,29 @@ function FrameworkOverviewPageContent({
};
return (
<div className="container space-y-6">
<PageHeader
className="mb-10"
title={framework.name ?? ""}
description={framework.description ?? ""}
actions={
<div className="flex gap-4">
<Button variant="outline" asChild>
<Link
to={`/organizations/${organizationId}/frameworks/${framework.id}/update`}
>
Edit Framework
</Link>
</Button>
<Button asChild>
<Link
to={`/organizations/${organizationId}/frameworks/${framework.id}/controls/create`}
>
<Plus className="mr-2 h-4 w-4" />
Create Control
</Link>
</Button>
</div>
}
/>
<PageTemplate
title={framework.name ?? ""}
description={framework.description ?? ""}
actions={
<div className="flex gap-4">
<Button variant="outline" asChild>
<Link
to={`/organizations/${organizationId}/frameworks/${framework.id}/update`}
>
Edit Framework
</Link>
</Button>
<Button asChild>
<Link
to={`/organizations/${organizationId}/frameworks/${framework.id}/controls/create`}
>
<Plus className="mr-2 h-4 w-4" />
Create Control
</Link>
</Button>
</div>
}
>
<div className="grid gap-6">
{categories.map((category) => {
const isExpanded = expandedCategories.includes(category.id);
@@ -326,17 +322,13 @@ function FrameworkOverviewPageContent({
);
})}
</div>
</div>
</PageTemplate>
);
}
function FrameworkOverviewPageFallback() {
export function FrameworkOverviewPageSkeleton() {
return (
<div className="min-h-screen bg-background p-6">
<div className="mb-8">
<div className="h-8 w-48 bg-muted animate-pulse rounded" />
<div className="h-4 w-96 bg-muted animate-pulse rounded mt-2" />
</div>
<PageTemplateSkeleton>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i}>
@@ -351,7 +343,7 @@ function FrameworkOverviewPageFallback() {
</Card>
))}
</div>
</div>
</PageTemplateSkeleton>
);
}
@@ -365,14 +357,13 @@ export default function FrameworkOverviewPage() {
loadQuery({ frameworkId: frameworkId! });
}, [loadQuery, frameworkId]);
if (!queryRef) {
return <FrameworkOverviewPageSkeleton />;
}
return (
<>
<Helmet>
<title>Framework Overview - Probo Console</title>
</Helmet>
<Suspense fallback={<FrameworkOverviewPageFallback />}>
{queryRef && <FrameworkOverviewPageContent queryRef={queryRef} />}
</Suspense>
</>
<Suspense fallback={<FrameworkOverviewPageSkeleton />}>
{queryRef && <FrameworkOverviewPageContent queryRef={queryRef} />}
</Suspense>
);
}

View File

@@ -13,12 +13,11 @@ import { Badge } from "@/components/ui/badge";
import { UserPlus, Trash2, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Link } from "react-router";
import { Helmet } from "react-helmet-async";
import type { PeopleListPageQuery as PeopleListPageQueryType } from "./__generated__/PeopleListPageQuery.graphql";
import type { PeopleListPageDeletePeopleMutation } from "./__generated__/PeopleListPageDeletePeopleMutation.graphql";
import { PeopleListPagePaginationQuery } from "./__generated__/PeopleListPagePaginationQuery.graphql";
import { PeopleListPage_peoples$key } from "./__generated__/PeopleListPage_peoples.graphql";
import { PageHeader } from "@/components/PageHeader";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
const ITEMS_PER_PAGE = 25;
@@ -174,31 +173,25 @@ function PeopleListContent({
const pageInfo = peoplesConnection.peoples.pageInfo;
return (
<>
<Helmet>
<title>People - Probo</title>
</Helmet>
<div className="container space-y-6">
<PageHeader
className="mb-17"
title="Employees"
description="Keep track of your company's workforce and their progress
towards completing tasks assigned to them."
actions={
<Button
asChild
variant="outline"
style={{ borderRadius: "0.5rem" }}
className="gap-2"
>
<Link to={`/organizations/${organizationId}/people/create`}>
<UserPlus className="h-4 w-4" />
Add a person
</Link>
</Button>
}
/>
<PageTemplate
title="People"
description="Keep track of your company's workforce and their progress
towards completing tasks assigned to them."
actions={
<Button
asChild
variant="outline"
style={{ borderRadius: "0.5rem" }}
className="gap-2"
>
<Link to={`/organizations/${organizationId}/people/create`}>
<UserPlus className="h-4 w-4" />
Add a person
</Link>
</Button>
}
>
<div className="space-y-6">
<div className="space-y-2">
{peoples.map((person) => (
<Link
@@ -296,27 +289,32 @@ function PeopleListContent({
}}
/>
</div>
</>
</PageTemplate>
);
}
function PeopleListPageFallback() {
export function PeopleListPageSkeleton() {
return (
<div className="space-y-6">
<div>
<div className="h-8 w-48 bg-muted animate-pulse rounded" />
<div className="h-4 w-96 bg-muted animate-pulse rounded mt-1" />
<PageTemplateSkeleton
title="People"
description="Keep track of your company's workforce and their progress towards completing tasks assigned to them."
actions={<div className="bg-muted animate-pulse h-9 w-1/6 rounded-lg" />}
>
<div className="space-y-6">
<div className="rounded-xl border bg-card p-4 space-y-2">
<div className="h-5 w-32 bg-muted animate-pulse rounded" />
<div className="h-10 w-full bg-muted animate-pulse rounded" />
</div>
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div
key={i}
className="h-[72px] bg-muted animate-pulse rounded-xl"
/>
))}
</div>
</div>
<div className="rounded-xl border bg-card p-4 space-y-4">
<div className="h-5 w-32 bg-muted animate-pulse rounded" />
<div className="h-10 w-full bg-muted animate-pulse rounded" />
</div>
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-[72px] bg-muted animate-pulse rounded-xl" />
))}
</div>
</div>
</PageTemplateSkeleton>
);
}
@@ -341,17 +339,12 @@ export default function PeopleListPage() {
}, [loadQuery, organizationId, searchParams]);
if (!queryRef) {
return <PeopleListPageFallback />;
return <PeopleListPageSkeleton />;
}
return (
<>
<Helmet>
<title>People - Probo Console</title>
</Helmet>
<Suspense fallback={<PeopleListPageFallback />}>
<PeopleListContent queryRef={queryRef} />
</Suspense>
</>
<Suspense fallback={<PeopleListPageSkeleton />}>
<PeopleListContent queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -16,9 +16,8 @@ import {
import { Suspense, useEffect, useState, useCallback } from "react";
import type { PeopleOverviewPageQuery as PeopleOverviewPageQueryType } from "./__generated__/PeopleOverviewPageQuery.graphql";
import { useParams } from "react-router";
import { Helmet } from "react-helmet-async";
import { cn } from "@/lib/utils";
import { PageHeader } from "@/components/PageHeader";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
const peopleOverviewPageQuery = graphql`
query PeopleOverviewPageQuery($peopleId: ID!) {
@@ -161,174 +160,160 @@ function PeopleOverviewPageContent({
};
return (
<>
<Helmet>
<title>Person - Probo</title>
</Helmet>
<div className="container">
<PageHeader className="mb-17" title={formData.fullName} />
<div className="space-y-6">
<div className="mx-auto max-w-4xl space-y-6">
<EditableField
label="Full Name"
value={formData.fullName}
onChange={(value) => handleFieldChange("fullName", value)}
/>
<PageTemplate title={formData.fullName}>
<div className="space-y-6">
<div className="mx-auto max-w-4xl space-y-6">
<EditableField
label="Full Name"
value={formData.fullName}
onChange={(value) => handleFieldChange("fullName", value)}
/>
<EditableField
label="Primary Email"
value={formData.primaryEmailAddress}
type="email"
onChange={(value) =>
handleFieldChange("primaryEmailAddress", value)
}
/>
<EditableField
label="Primary Email"
value={formData.primaryEmailAddress}
type="email"
onChange={(value) =>
handleFieldChange("primaryEmailAddress", value)
}
/>
<div className="space-y-2">
<div className="flex items-center gap-2">
<HelpCircle className="h-4 w-4 text-gray-400" />
<Label className="text-sm">Additional Email Addresses</Label>
</div>
<div className="space-y-2">
{formData.additionalEmailAddresses.map((email, index) => (
<div key={index} className="flex gap-2">
<Input
type="email"
value={email}
onChange={(e) => {
const newEmails = [
...formData.additionalEmailAddresses,
];
newEmails[index] = e.target.value;
handleFieldChange(
"additionalEmailAddresses",
newEmails
);
}}
/>
<Button
variant="outline"
onClick={() => {
const newEmails =
formData.additionalEmailAddresses.filter(
(_, i) => i !== index
);
handleFieldChange(
"additionalEmailAddresses",
newEmails
);
}}
>
Remove
</Button>
</div>
))}
<Button
variant="outline"
onClick={() => {
handleFieldChange("additionalEmailAddresses", [
...formData.additionalEmailAddresses,
"",
]);
}}
>
Add Email
</Button>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<HelpCircle className="h-4 w-4 text-gray-400" />
<Label className="text-sm">Additional Email Addresses</Label>
</div>
<div className="space-y-2">
{formData.additionalEmailAddresses.map((email, index) => (
<div key={index} className="flex gap-2">
<Input
type="email"
value={email}
onChange={(e) => {
const newEmails = [...formData.additionalEmailAddresses];
newEmails[index] = e.target.value;
handleFieldChange("additionalEmailAddresses", newEmails);
}}
/>
<Button
variant="outline"
onClick={() => {
const newEmails =
formData.additionalEmailAddresses.filter(
(_, i) => i !== index
);
handleFieldChange("additionalEmailAddresses", newEmails);
}}
>
Remove
</Button>
</div>
))}
<Button
variant="outline"
onClick={() => {
handleFieldChange("additionalEmailAddresses", [
...formData.additionalEmailAddresses,
"",
]);
}}
>
Add Email
</Button>
</div>
</div>
<Card className="p-6">
<div className="space-y-4">
<div className="space-y-2">
<h2 className="text-lg font-medium">Additional Information</h2>
<p className="text-sm text-gray-500">
Additional details about the person
</p>
</div>
<Card className="p-6">
<div className="space-y-4">
<div className="space-y-2">
<h2 className="text-lg font-medium">
Additional Information
</h2>
<p className="text-sm text-gray-500">
Additional details about the person
</p>
</div>
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<HelpCircle className="h-4 w-4 text-gray-400" />
<Label className="text-sm">Kind</Label>
</div>
<div className="flex gap-2">
<button
onClick={() => handleFieldChange("kind", "EMPLOYEE")}
className={cn(
"rounded-full px-4 py-1 text-sm transition-colors",
formData.kind === "EMPLOYEE"
? "bg-blue-100 text-blue-900 ring-2 ring-blue-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
)}
>
Employee
</button>
<button
onClick={() => handleFieldChange("kind", "CONTRACTOR")}
className={cn(
"rounded-full px-4 py-1 text-sm transition-colors",
formData.kind === "CONTRACTOR"
? "bg-purple-100 text-purple-900 ring-2 ring-purple-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
)}
>
Contractor
</button>
<button
onClick={() =>
handleFieldChange("kind", "SERVICE_ACCOUNT")
}
className={cn(
"rounded-full px-4 py-1 text-sm transition-colors",
formData.kind === "SERVICE_ACCOUNT"
? "bg-green-100 text-green-900 ring-2 ring-green-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
)}
>
Service Account
</button>
</div>
<div className="flex items-center gap-2">
<HelpCircle className="h-4 w-4 text-gray-400" />
<Label className="text-sm">Kind</Label>
</div>
<div className="flex gap-2">
<button
onClick={() => handleFieldChange("kind", "EMPLOYEE")}
className={cn(
"rounded-full px-4 py-1 text-sm transition-colors",
formData.kind === "EMPLOYEE"
? "bg-blue-100 text-blue-900 ring-2 ring-blue-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
)}
>
Employee
</button>
<button
onClick={() => handleFieldChange("kind", "CONTRACTOR")}
className={cn(
"rounded-full px-4 py-1 text-sm transition-colors",
formData.kind === "CONTRACTOR"
? "bg-purple-100 text-purple-900 ring-2 ring-purple-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
)}
>
Contractor
</button>
<button
onClick={() =>
handleFieldChange("kind", "SERVICE_ACCOUNT")
}
className={cn(
"rounded-full px-4 py-1 text-sm transition-colors",
formData.kind === "SERVICE_ACCOUNT"
? "bg-green-100 text-green-900 ring-2 ring-green-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
)}
>
Service Account
</button>
</div>
</div>
</div>
</Card>
</div>
</div>
</Card>
</div>
{hasChanges && (
<div className="fixed bottom-6 right-6 flex gap-2">
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button
onClick={handleSave}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
Save Changes
</Button>
</div>
)}
</div>
</>
{hasChanges && (
<div className="fixed bottom-6 right-6 flex gap-2">
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button
onClick={handleSave}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
Save Changes
</Button>
</div>
)}
</PageTemplate>
);
}
function PeopleOverviewPageFallback() {
export function PeopleOverviewPageSkeleton() {
return (
<div className="p-6 space-y-6">
<div className="space-y-1">
<div className="h-8 w-48 bg-muted animate-pulse rounded" />
<div className="h-4 w-96 bg-muted animate-pulse rounded" />
<PageTemplateSkeleton>
<div className="space-y-6">
<div className="space-y-1">
<div className="h-8 w-48 bg-muted animate-pulse rounded" />
<div className="h-4 w-96 bg-muted animate-pulse rounded" />
</div>
<div className="space-y-2">
{[1, 2].map((i) => (
<div key={i} className="h-20 bg-muted animate-pulse rounded-lg" />
))}
</div>
</div>
<div className="space-y-2">
{[1, 2].map((i) => (
<div key={i} className="h-20 bg-muted animate-pulse rounded-lg" />
))}
</div>
</div>
</PageTemplateSkeleton>
);
}
@@ -343,17 +328,12 @@ export default function PeopleOverviewPage() {
}, [loadQuery, peopleId]);
if (!queryRef) {
return <PeopleOverviewPageFallback />;
return <PeopleOverviewPageSkeleton />;
}
return (
<>
<Helmet>
<title>People Overview - Probo Console</title>
</Helmet>
<Suspense fallback={<PeopleOverviewPageFallback />}>
<PeopleOverviewPageContent queryRef={queryRef} />
</Suspense>
</>
<Suspense fallback={<PeopleOverviewPageSkeleton />}>
<PeopleOverviewPageContent queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -7,7 +7,6 @@ import {
} from "react-relay";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { Link, useParams } from "react-router";
import { Helmet } from "react-helmet-async";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@@ -34,7 +33,7 @@ import {
import { Badge } from "@/components/ui/badge";
import { format } from "date-fns";
import type { PolicyListPageQuery as PolicyListPageQueryType } from "./__generated__/PolicyListPageQuery.graphql";
import { PageHeader } from "@/components/PageHeader";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
const PolicyListPageQuery = graphql`
query PolicyListPageQuery($organizationId: ID!) {
@@ -220,153 +219,141 @@ function PolicyListPageContent({
});
return (
<>
<Helmet>
<title>Policies - Probo</title>
</Helmet>
<div className="container space-y-6">
<PageHeader
className="mb-17"
title="Policies"
description="Manage your organization's policies"
actions={
<Button asChild>
<Link to={`/organizations/${organizationId}/policies/create`}>
<Plus className="mr-2 h-4 w-4" />
Create Policy
</Link>
</Button>
}
/>
{/* Search and filter controls */}
<div className="flex flex-col md:flex-row gap-4 mb-6">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search policies..."
className="pl-10"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="flex gap-2">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[180px]">
<div className="flex items-center gap-2">
<Filter className="h-4 w-4" />
<SelectValue placeholder="Filter by status" />
</div>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All Statuses</SelectItem>
<SelectItem value="ACTIVE">Active</SelectItem>
<SelectItem value="DRAFT">Draft</SelectItem>
</SelectContent>
</Select>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="flex items-center gap-2">
<ArrowUpDown className="h-4 w-4" />
Sort
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setSortBy("name-asc")}>
Name (A-Z)
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy("name-desc")}>
Name (Z-A)
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy("updated-desc")}>
Recently Updated
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy("updated-asc")}>
Oldest Updated
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy("created-desc")}>
Recently Created
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy("created-asc")}>
Oldest Created
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<PageTemplate
title="Policies"
description="Manage your organization's policies"
actions={
<Button asChild>
<Link to={`/organizations/${organizationId}/policies/create`}>
<Plus className="mr-2 h-4 w-4" />
Create Policy
</Link>
</Button>
}
>
{/* Search and filter controls */}
<div className="flex flex-col md:flex-row gap-4 mb-6">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search policies..."
className="pl-10"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{/* Results summary */}
<div className="mb-4 text-sm text-muted-foreground">
Showing {filteredPolicies.length} of {policies.length} policies
</div>
<div className="flex gap-2">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[180px]">
<div className="flex items-center gap-2">
<Filter className="h-4 w-4" />
<SelectValue placeholder="Filter by status" />
</div>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All Statuses</SelectItem>
<SelectItem value="ACTIVE">Active</SelectItem>
<SelectItem value="DRAFT">Draft</SelectItem>
</SelectContent>
</Select>
{/* Policy grid */}
<div className="space-y-6">
{filteredPolicies.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredPolicies.map((policy) => (
<Link
key={policy.id}
to={`/organizations/${organizationId}/policies/${policy.id}`}
className="group"
>
<PolicyCard
title={policy.name}
content={policy.content}
status={policy.status}
updatedAt={policy.updatedAt}
/>
</Link>
))}
</div>
) : (
<div className="text-center py-12 border rounded-lg bg-muted/20">
<FileText className="mx-auto h-8 w-8 text-muted-foreground mb-3" />
<h3 className="text-lg font-medium">No policies found</h3>
<p className="text-muted-foreground mb-4">
{searchQuery || statusFilter !== "ALL"
? "Try adjusting your search or filters"
: "Create your first policy to get started"}
</p>
{searchQuery || statusFilter !== "ALL" ? (
<Button
variant="outline"
onClick={() => {
setSearchQuery("");
setStatusFilter("ALL");
}}
>
Clear filters
</Button>
) : (
<Button asChild>
<Link to={`/organizations/${organizationId}/policies/create`}>
<Plus className="mr-2 h-4 w-4" />
Create Policy
</Link>
</Button>
)}
</div>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="flex items-center gap-2">
<ArrowUpDown className="h-4 w-4" />
Sort
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setSortBy("name-asc")}>
Name (A-Z)
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy("name-desc")}>
Name (Z-A)
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy("updated-desc")}>
Recently Updated
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy("updated-asc")}>
Oldest Updated
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy("created-desc")}>
Recently Created
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy("created-asc")}>
Oldest Created
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</>
{/* Results summary */}
<div className="mb-4 text-sm text-muted-foreground">
Showing {filteredPolicies.length} of {policies.length} policies
</div>
{/* Policy grid */}
<div className="space-y-6">
{filteredPolicies.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredPolicies.map((policy) => (
<Link
key={policy.id}
to={`/organizations/${organizationId}/policies/${policy.id}`}
className="group"
>
<PolicyCard
title={policy.name}
content={policy.content}
status={policy.status}
updatedAt={policy.updatedAt}
/>
</Link>
))}
</div>
) : (
<div className="text-center py-12 border rounded-lg bg-muted/20">
<FileText className="mx-auto h-8 w-8 text-muted-foreground mb-3" />
<h3 className="text-lg font-medium">No policies found</h3>
<p className="text-muted-foreground mb-4">
{searchQuery || statusFilter !== "ALL"
? "Try adjusting your search or filters"
: "Create your first policy to get started"}
</p>
{searchQuery || statusFilter !== "ALL" ? (
<Button
variant="outline"
onClick={() => {
setSearchQuery("");
setStatusFilter("ALL");
}}
>
Clear filters
</Button>
) : (
<Button asChild>
<Link to={`/organizations/${organizationId}/policies/create`}>
<Plus className="mr-2 h-4 w-4" />
Create Policy
</Link>
</Button>
)}
</div>
)}
</div>
</PageTemplate>
);
}
function PolicyListPageFallback() {
export function PolicyListPageSkeleton() {
return (
<div className="container mx-auto py-6">
<div className="flex justify-between items-center mb-6">
<div>
<div className="h-8 w-48 bg-muted animate-pulse rounded" />
<div className="h-4 w-96 bg-muted animate-pulse rounded mt-1" />
</div>
<div className="h-10 w-36 bg-muted animate-pulse rounded" />
</div>
<PageTemplateSkeleton
title="Policies"
description="Manage your organization's policies"
actions={<div className="bg-muted animate-pulse h-9 w-1/6 rounded-lg" />}
>
{/* Search and filter controls skeleton */}
<div className="flex flex-col md:flex-row gap-4 mb-6">
<div className="flex-1 h-10 bg-muted animate-pulse rounded" />
@@ -407,7 +394,7 @@ function PolicyListPageFallback() {
</Card>
))}
</div>
</div>
</PageTemplateSkeleton>
);
}
@@ -421,14 +408,13 @@ export default function PolicyListPage() {
loadQuery({ organizationId: organizationId! });
}, [loadQuery, organizationId]);
if (!queryRef) {
return <PolicyListPageSkeleton />;
}
return (
<>
<Helmet>
<title>Policies - Probo Console</title>
</Helmet>
<Suspense fallback={<PolicyListPageFallback />}>
{queryRef && <PolicyListPageContent queryRef={queryRef} />}
</Suspense>
</>
<Suspense fallback={<PolicyListPageSkeleton />}>
{<PolicyListPageContent queryRef={queryRef} />}
</Suspense>
);
}

View File

@@ -15,10 +15,9 @@ import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { PolicyOverviewPageQuery as PolicyOverviewPageQueryType } from "./__generated__/PolicyOverviewPageQuery.graphql";
import type { PolicyOverviewPageDeleteMutation } from "./__generated__/PolicyOverviewPageDeleteMutation.graphql";
import { Helmet } from "react-helmet-async";
import "../styles/policy-content.css";
import { useToast } from "@/hooks/use-toast";
import { PageHeader } from "@/components/PageHeader";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
const PolicyOverviewPageQuery = graphql`
query PolicyOverviewPageQuery($policyId: ID!) {
@@ -149,276 +148,272 @@ function PolicyOverviewPageContent({
};
return (
<>
<Helmet>
<title>{policy.name} - Probo Console</title>
</Helmet>
<div className="container">
<PageHeader
className="mb-17"
title={policy.name ?? ""}
description={
<div className="flex items-center gap-2 text-muted-foreground">
<Badge
variant={policy.status === "ACTIVE" ? "default" : "outline"}
<PageTemplate
title={policy.name ?? ""}
description={
<div className="flex items-center gap-2 text-muted-foreground">
<Badge variant={policy.status === "ACTIVE" ? "default" : "outline"}>
{policy.status === "ACTIVE" ? "Active" : "Draft"}
</Badge>
{policy.owner && (
<div className="flex items-center gap-1">
<User className="h-3 w-3" />
<Link
to={`/organizations/${organizationId}/people/${policy.owner.id}`}
className="hover:underline"
>
{policy.status === "ACTIVE" ? "Active" : "Draft"}
</Badge>
{policy.owner && (
<div className="flex items-center gap-1">
<User className="h-3 w-3" />
{policy.owner.fullName}
</Link>
</div>
)}
{policy.reviewDate && (
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
<span>Review: {formatDate(policy.reviewDate)}</span>
</div>
)}
</div>
}
actions={
<div className="flex gap-2">
<Button variant="outline" size="sm" asChild>
<Link
to={`/organizations/${organizationId}/policies/${policy.id}/update`}
>
<Edit className="h-4 w-4 mr-2" />
Edit
</Link>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
// Logic to download policy content as PDF or text
const element = document.createElement("a");
const file = new Blob([policy.content || ""], {
type: "text/plain",
});
element.href = URL.createObjectURL(file);
element.download = `${policy.name}.txt`;
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}}
>
<Download className="h-4 w-4 mr-2" />
Download
</Button>
</div>
}
>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<Card className="border shadow-sm">
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-md bg-slate-100">
<Shield className="h-6 w-6" />
</div>
<div className="flex gap-2">
{policy.status && (
<Badge
className={`px-3 py-1 rounded-md font-medium ${
policy.status === "ACTIVE"
? "bg-green-100 text-green-700 hover:bg-green-200"
: policy.status === "DRAFT"
? "bg-yellow-100 text-yellow-700 hover:bg-yellow-200"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
{policy.status === "ACTIVE"
? "Active"
: policy.status === "DRAFT"
? "Draft"
: policy.status}
</Badge>
)}
</div>
</div>
<h1 className="text-3xl font-bold mb-3">{policy.name}</h1>
<p className="text-muted-foreground mb-6">
{getDescription(policy.content)}
</p>
<Tabs
defaultValue="content"
value={activeTab}
onValueChange={setActiveTab}
className="mb-6"
>
<TabsList className="border-b w-full rounded-none bg-transparent p-0 h-auto">
<TabsTrigger
value="content"
className={`rounded-none border-b-2 border-transparent px-4 py-2 font-medium ${
activeTab === "content"
? "border-primary text-primary"
: "text-muted-foreground"
}`}
>
Policy Content
</TabsTrigger>
<TabsTrigger
value="history"
className={`rounded-none border-b-2 border-transparent px-4 py-2 font-medium ${
activeTab === "history"
? "border-primary text-primary"
: "text-muted-foreground"
}`}
>
Version History
</TabsTrigger>
<TabsTrigger
value="approvals"
className={`rounded-none border-b-2 border-transparent px-4 py-2 font-medium ${
activeTab === "approvals"
? "border-primary text-primary"
: "text-muted-foreground"
}`}
>
Approvals
</TabsTrigger>
</TabsList>
<TabsContent value="content" className="pt-6">
<div className="prose prose-sm md:prose-base lg:prose-lg max-w-none">
<div
className="policy-content"
dangerouslySetInnerHTML={{
__html: policy.content || "",
}}
/>
</div>
</TabsContent>
<TabsContent value="history" className="pt-6">
<div className="text-center py-12">
<p className="text-muted-foreground">
Version history will be available soon.
</p>
</div>
</TabsContent>
<TabsContent value="approvals" className="pt-6">
<div className="text-center py-12">
<p className="text-muted-foreground">
Approval workflow will be available soon.
</p>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
<div className="lg:col-span-1">
<Card className="border shadow-sm mb-6">
<CardContent className="p-6">
<h2 className="text-xl font-semibold mb-6">Policy Details</h2>
<div className="space-y-6">
<div>
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<FileText className="h-4 w-4" />
<span className="text-sm">Last Updated</span>
</div>
<p className="font-medium">{formatDate(policy.updatedAt)}</p>
</div>
<div>
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<FileText className="h-4 w-4" />
<span className="text-sm">Review Due</span>
</div>
<p className="font-medium">
{policy.reviewDate
? formatDate(policy.reviewDate)
: "Not set"}
</p>
</div>
<div>
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<User className="h-4 w-4" />
<span className="text-sm">Owner</span>
</div>
<Link
to={`/organizations/${organizationId}/people/${policy.owner.id}`}
to={`/organizations/${organizationId}/people/${policy.owner?.id}`}
className="hover:underline"
>
{policy.owner.fullName}
<p className="font-medium">
{policy.owner ? policy.owner.fullName : "Not assigned"}
</p>
</Link>
</div>
)}
{policy.reviewDate && (
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
<span>Review: {formatDate(policy.reviewDate)}</span>
</div>
)}
</div>
}
actions={
<div className="flex gap-2">
<Button variant="outline" size="sm" asChild>
<Link
to={`/organizations/${organizationId}/policies/${policy.id}/update`}
>
<Edit className="h-4 w-4 mr-2" />
Edit
</Link>
</Button>
</div>
</CardContent>
</Card>
<Button asChild className="w-full">
<Link
to={`/organizations/${organizationId}/policies/${policy.id}/update`}
>
<Edit className="mr-2 h-4 w-4" />
Edit Policy
</Link>
</Button>
<Card className="border shadow-sm mt-6 bg-red-50">
<CardContent className="p-6">
<h3 className="text-red-500 font-semibold mb-3">Danger Zone</h3>
<p className="text-sm text-muted-foreground mb-4">
Permanently delete this policy and all of its data. This action
cannot be undone.
</p>
<Button
variant="outline"
size="sm"
onClick={() => {
// Logic to download policy content as PDF or text
const element = document.createElement("a");
const file = new Blob([policy.content || ""], {
type: "text/plain",
});
element.href = URL.createObjectURL(file);
element.download = `${policy.name}.txt`;
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}}
variant="destructive"
className="w-full"
onClick={handleDeletePolicy}
disabled={isDeleting}
>
<Download className="h-4 w-4 mr-2" />
Download
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mr-2"
>
<path d="M3 6h18"></path>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
</svg>
{isDeleting ? "Deleting..." : "Delete Policy"}
</Button>
</div>
}
/>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<Card className="border shadow-sm">
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-md bg-slate-100">
<Shield className="h-6 w-6" />
</div>
<div className="flex gap-2">
{policy.status && (
<Badge
className={`px-3 py-1 rounded-md font-medium ${
policy.status === "ACTIVE"
? "bg-green-100 text-green-700 hover:bg-green-200"
: policy.status === "DRAFT"
? "bg-yellow-100 text-yellow-700 hover:bg-yellow-200"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
{policy.status === "ACTIVE"
? "Active"
: policy.status === "DRAFT"
? "Draft"
: policy.status}
</Badge>
)}
</div>
</div>
<h1 className="text-3xl font-bold mb-3">{policy.name}</h1>
<p className="text-muted-foreground mb-6">
{getDescription(policy.content)}
</p>
<Tabs
defaultValue="content"
value={activeTab}
onValueChange={setActiveTab}
className="mb-6"
>
<TabsList className="border-b w-full rounded-none bg-transparent p-0 h-auto">
<TabsTrigger
value="content"
className={`rounded-none border-b-2 border-transparent px-4 py-2 font-medium ${
activeTab === "content"
? "border-primary text-primary"
: "text-muted-foreground"
}`}
>
Policy Content
</TabsTrigger>
<TabsTrigger
value="history"
className={`rounded-none border-b-2 border-transparent px-4 py-2 font-medium ${
activeTab === "history"
? "border-primary text-primary"
: "text-muted-foreground"
}`}
>
Version History
</TabsTrigger>
<TabsTrigger
value="approvals"
className={`rounded-none border-b-2 border-transparent px-4 py-2 font-medium ${
activeTab === "approvals"
? "border-primary text-primary"
: "text-muted-foreground"
}`}
>
Approvals
</TabsTrigger>
</TabsList>
<TabsContent value="content" className="pt-6">
<div className="prose prose-sm md:prose-base lg:prose-lg max-w-none">
<div
className="policy-content"
dangerouslySetInnerHTML={{
__html: policy.content || "",
}}
/>
</div>
</TabsContent>
<TabsContent value="history" className="pt-6">
<div className="text-center py-12">
<p className="text-muted-foreground">
Version history will be available soon.
</p>
</div>
</TabsContent>
<TabsContent value="approvals" className="pt-6">
<div className="text-center py-12">
<p className="text-muted-foreground">
Approval workflow will be available soon.
</p>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
<div className="lg:col-span-1">
<Card className="border shadow-sm mb-6">
<CardContent className="p-6">
<h2 className="text-xl font-semibold mb-6">Policy Details</h2>
<div className="space-y-6">
<div>
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<FileText className="h-4 w-4" />
<span className="text-sm">Last Updated</span>
</div>
<p className="font-medium">
{formatDate(policy.updatedAt)}
</p>
</div>
<div>
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<FileText className="h-4 w-4" />
<span className="text-sm">Review Due</span>
</div>
<p className="font-medium">
{policy.reviewDate
? formatDate(policy.reviewDate)
: "Not set"}
</p>
</div>
<div>
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<User className="h-4 w-4" />
<span className="text-sm">Owner</span>
</div>
<Link
to={`/organizations/${organizationId}/people/${policy.owner?.id}`}
className="hover:underline"
>
<p className="font-medium">
{policy.owner ? policy.owner.fullName : "Not assigned"}
</p>
</Link>
</div>
</div>
</CardContent>
</Card>
<Button asChild className="w-full">
<Link
to={`/organizations/${organizationId}/policies/${policy.id}/update`}
>
<Edit className="mr-2 h-4 w-4" />
Edit Policy
</Link>
</Button>
<Card className="border shadow-sm mt-6 bg-red-50">
<CardContent className="p-6">
<h3 className="text-red-500 font-semibold mb-3">Danger Zone</h3>
<p className="text-sm text-muted-foreground mb-4">
Permanently delete this policy and all of its data. This
action cannot be undone.
</p>
<Button
variant="destructive"
className="w-full"
onClick={handleDeletePolicy}
disabled={isDeleting}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mr-2"
>
<path d="M3 6h18"></path>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
</svg>
{isDeleting ? "Deleting..." : "Delete Policy"}
</Button>
</CardContent>
</Card>
</div>
</CardContent>
</Card>
</div>
</div>
</>
</PageTemplate>
);
}
function PolicyOverviewPageFallback() {
export function PolicyOverviewPageSkeleton() {
return (
<div className="container mx-auto py-6">
<PageTemplateSkeleton
withDescription
actions={
<div className="flex gap-2 w-1/3">
<div className="bg-muted animate-pulse h-8 w-1/2 rounded-lg" />
<div className="bg-muted animate-pulse h-8 w-1/2 rounded-lg" />
</div>
}
>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<Card className="border shadow-sm">
@@ -505,7 +500,7 @@ function PolicyOverviewPageFallback() {
</Card>
</div>
</div>
</div>
</PageTemplateSkeleton>
);
}
@@ -520,8 +515,12 @@ export default function PolicyOverviewPage() {
loadQuery({ policyId: policyId! });
}, [loadQuery, policyId]);
if (!queryRef) {
return <PolicyOverviewPageSkeleton />;
}
return (
<Suspense fallback={<PolicyOverviewPageFallback />}>
<Suspense fallback={<PolicyOverviewPageSkeleton />}>
{queryRef && <PolicyOverviewPageContent queryRef={queryRef} />}
</Suspense>
);

View File

@@ -14,7 +14,6 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Helmet } from "react-helmet-async";
import { Suspense, useEffect, useState, useRef } from "react";
import {
graphql,
@@ -39,7 +38,7 @@ import type { SettingsPageQuery as SettingsPageQueryType } from "./__generated__
import type { SettingsPageUpdateOrganizationMutation as SettingsPageUpdateOrganizationMutationType } from "./__generated__/SettingsPageUpdateOrganizationMutation.graphql";
import type { SettingsPageInviteUserMutation as SettingsPageInviteUserMutationType } from "./__generated__/SettingsPageInviteUserMutation.graphql";
import type { SettingsPageRemoveUserMutation as SettingsPageRemoveUserMutationType } from "./__generated__/SettingsPageRemoveUserMutation.graphql";
import { PageHeader } from "@/components/PageHeader";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
const settingsPageQuery = graphql`
query SettingsPageQuery($organizationID: ID!) {
@@ -290,14 +289,11 @@ function SettingsPageContent({
};
return (
<>
<div className="container space-y-6">
<PageHeader
className="mb-17"
title="Settings"
description="Manage your details and personal preferences here"
/>
<PageTemplate
title="Settings"
description="Manage your details and personal preferences here"
>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Organization information</CardTitle>
@@ -519,23 +515,28 @@ function SettingsPageContent({
</DialogFooter>
</DialogContent>
</Dialog>
</>
</PageTemplate>
);
}
function SettingsPageFallback() {
export function SettingsPageSkeleton() {
return (
<div className="p-6 space-y-6">
<div className="space-y-1">
<div className="h-8 w-48 bg-muted animate-pulse rounded" />
<div className="h-4 w-96 bg-muted animate-pulse rounded" />
<PageTemplateSkeleton
title="Settings"
description="Manage your details and personal preferences here"
>
<div className="space-y-6">
<div className="space-y-1">
<div className="h-8 w-48 bg-muted animate-pulse rounded" />
<div className="h-4 w-96 bg-muted animate-pulse rounded" />
</div>
<div className="space-y-2">
{[1, 2].map((i) => (
<div key={i} className="h-64 bg-muted animate-pulse rounded-lg" />
))}
</div>
</div>
<div className="space-y-2">
{[1, 2].map((i) => (
<div key={i} className="h-64 bg-muted animate-pulse rounded-lg" />
))}
</div>
</div>
</PageTemplateSkeleton>
);
}
@@ -550,17 +551,12 @@ export default function SettingsPage() {
}, [loadQuery, organizationId]);
if (!queryRef) {
return <SettingsPageFallback />;
return <SettingsPageSkeleton />;
}
return (
<>
<Helmet>
<title>Settings - Probo Console</title>
</Helmet>
<Suspense fallback={<SettingsPageFallback />}>
<SettingsPageContent queryRef={queryRef} />
</Suspense>
</>
<Suspense fallback={<SettingsPageSkeleton />}>
<SettingsPageContent queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -7,7 +7,6 @@ import {
PreloadedQuery,
useQueryLoader,
} from "react-relay";
import { Helmet } from "react-helmet-async";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@@ -28,7 +27,7 @@ import type {
ControlState,
ControlImportance,
} from "./__generated__/UpdateControlPageUpdateControlMutation.graphql";
import { PageHeader } from "@/components/PageHeader";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
const updateControlMutation = graphql`
mutation UpdateControlPageUpdateControlMutation($input: UpdateControlInput!) {
@@ -250,100 +249,97 @@ function UpdateControlPageContent({
};
return (
<>
<Helmet>
<title>Update Control - Probo</title>
</Helmet>
<div className="container">
<PageHeader
className="mb-17"
title="Update Control"
description="Update the control details"
/>
<PageTemplate
title="Update Control"
description="Update the control details"
>
<Card className="max-w-2xl">
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<EditableField
label="Name"
value={formData.name}
onChange={(value) => handleFieldChange("name", value)}
required
/>
<Card className="max-w-2xl">
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<EditableField
label="Name"
value={formData.name}
onChange={(value) => handleFieldChange("name", value)}
required
/>
<EditableField
label="Description"
value={formData.description}
onChange={(value) => handleFieldChange("description", value)}
required
multiline
helpText="Provide a detailed description of the control"
/>
<EditableField
label="Description"
value={formData.description}
onChange={(value) => handleFieldChange("description", value)}
required
multiline
helpText="Provide a detailed description of the control"
/>
<EditableField
label="Category"
value={formData.category}
onChange={(value) => handleFieldChange("category", value)}
required
/>
<EditableField
label="Category"
value={formData.category}
onChange={(value) => handleFieldChange("category", value)}
required
/>
<div className="space-y-2">
<Label htmlFor="importance" className="text-sm font-medium">
Importance
</Label>
<Select
value={formData.importance}
onValueChange={(value) => handleFieldChange("importance", value)}
>
<SelectTrigger>
<SelectValue placeholder="Select importance" />
</SelectTrigger>
<SelectContent>
<SelectItem value="MANDATORY">Mandatory</SelectItem>
<SelectItem value="PREFERRED">Preferred</SelectItem>
<SelectItem value="ADVANCED">Advanced</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="importance" className="text-sm font-medium">
Importance
</Label>
<Select
value={formData.importance}
onValueChange={(value) =>
handleFieldChange("importance", value)
}
>
<SelectTrigger>
<SelectValue placeholder="Select importance" />
</SelectTrigger>
<SelectContent>
<SelectItem value="MANDATORY">Mandatory</SelectItem>
<SelectItem value="PREFERRED">Preferred</SelectItem>
<SelectItem value="ADVANCED">Advanced</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="state" className="text-sm font-medium">
State
</Label>
<Select
value={formData.state}
onValueChange={(value) => handleFieldChange("state", value)}
>
<SelectTrigger>
<SelectValue placeholder="Select state" />
</SelectTrigger>
<SelectContent>
<SelectItem value="NOT_STARTED">Not Started</SelectItem>
<SelectItem value="IN_PROGRESS">In Progress</SelectItem>
<SelectItem value="NOT_APPLICABLE">Not Applicable</SelectItem>
<SelectItem value="IMPLEMENTED">Implemented</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="state" className="text-sm font-medium">
State
</Label>
<Select
value={formData.state}
onValueChange={(value) => handleFieldChange("state", value)}
>
<SelectTrigger>
<SelectValue placeholder="Select state" />
</SelectTrigger>
<SelectContent>
<SelectItem value="NOT_STARTED">Not Started</SelectItem>
<SelectItem value="IN_PROGRESS">In Progress</SelectItem>
<SelectItem value="NOT_APPLICABLE">Not Applicable</SelectItem>
<SelectItem value="IMPLEMENTED">Implemented</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button type="submit" disabled={isInFlight || !hasChanges}>
{isInFlight ? "Updating..." : "Update Control"}
</Button>
</div>
</form>
</Card>
</div>
</>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button type="submit" disabled={isInFlight || !hasChanges}>
{isInFlight ? "Updating..." : "Update Control"}
</Button>
</div>
</form>
</Card>
</PageTemplate>
);
}
function UpdateControlPageFallback() {
return <div>Loading...</div>;
export function UpdateControlPageSkeleton() {
return (
<PageTemplateSkeleton
title="Update Control"
description="Update the control details"
>
<div className="max-w-2xl aspect-square bg-muted rounded-xl animate-pulse" />
</PageTemplateSkeleton>
);
}
export default function UpdateControlPage() {
@@ -358,11 +354,11 @@ export default function UpdateControlPage() {
}, [controlId, loadQuery]);
if (!queryRef) {
return <UpdateControlPageFallback />;
return <UpdateControlPageSkeleton />;
}
return (
<Suspense fallback={<UpdateControlPageFallback />}>
<Suspense fallback={<UpdateControlPageSkeleton />}>
<UpdateControlPageContent queryRef={queryRef} />
</Suspense>
);

View File

@@ -7,7 +7,6 @@ import {
PreloadedQuery,
useQueryLoader,
} from "react-relay";
import { Helmet } from "react-helmet-async";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@@ -18,7 +17,7 @@ import { HelpCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { UpdateFrameworkPageUpdateFrameworkMutation } from "./__generated__/UpdateFrameworkPageUpdateFrameworkMutation.graphql";
import { UpdateFrameworkPageQuery as UpdateFrameworkPageQueryType } from "./__generated__/UpdateFrameworkPageQuery.graphql";
import { PageHeader } from "@/components/PageHeader";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
const updateFrameworkMutation = graphql`
mutation UpdateFrameworkPageUpdateFrameworkMutation(
@@ -199,52 +198,51 @@ function UpdateFrameworkPageContent({
};
return (
<>
<Helmet>
<title>Update Framework - Probo</title>
</Helmet>
<div className="container">
<PageHeader
className="mb-17"
title="Update Framework"
description="Update the framework details"
/>
<PageTemplate
title="Update Framework"
description="Update the framework details"
>
<Card className="max-w-2xl">
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<EditableField
label="Name"
value={formData.name}
onChange={(value) => handleFieldChange("name", value)}
required
/>
<Card className="max-w-2xl">
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<EditableField
label="Name"
value={formData.name}
onChange={(value) => handleFieldChange("name", value)}
required
/>
<EditableField
label="Description"
value={formData.description}
onChange={(value) => handleFieldChange("description", value)}
required
multiline
helpText="Provide a detailed description of the framework"
/>
<EditableField
label="Description"
value={formData.description}
onChange={(value) => handleFieldChange("description", value)}
required
multiline
helpText="Provide a detailed description of the framework"
/>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button type="submit" disabled={isInFlight}>
{isInFlight ? "Updating..." : "Update Framework"}
</Button>
</div>
</form>
</Card>
</div>
</>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button type="submit" disabled={isInFlight}>
{isInFlight ? "Updating..." : "Update Framework"}
</Button>
</div>
</form>
</Card>
</PageTemplate>
);
}
function UpdateFrameworkPageFallback() {
return <div>Loading...</div>;
export function UpdateFrameworkPageSkeleton() {
return (
<PageTemplateSkeleton
title="Update Framework"
description="Update the framework details"
>
<div className="max-w-2xl aspect-square bg-muted rounded-xl animate-pulse" />
</PageTemplateSkeleton>
);
}
export default function UpdateFrameworkPage() {
@@ -259,11 +257,11 @@ export default function UpdateFrameworkPage() {
}, [frameworkId, loadQuery]);
if (!queryRef) {
return <UpdateFrameworkPageFallback />;
return <UpdateFrameworkPageSkeleton />;
}
return (
<Suspense fallback={<UpdateFrameworkPageFallback />}>
<Suspense fallback={<UpdateFrameworkPageSkeleton />}>
<UpdateFrameworkPageContent queryRef={queryRef} />
</Suspense>
);

View File

@@ -7,7 +7,6 @@ import {
useQueryLoader,
PreloadedQuery,
} from "react-relay";
import { Helmet } from "react-helmet-async";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -20,7 +19,7 @@ import PolicyEditor from "@/components/PolicyEditor";
import PeopleSelector from "@/components/PeopleSelector";
import type { UpdatePolicyPageQuery as UpdatePolicyPageQueryType } from "./__generated__/UpdatePolicyPageQuery.graphql";
import type { UpdatePolicyPageMutation as UpdatePolicyPageMutationType } from "./__generated__/UpdatePolicyPageMutation.graphql";
import { PageHeader } from "@/components/PageHeader";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
const UpdatePolicyPageQuery = graphql`
query UpdatePolicyPageQuery($policyId: ID!, $organizationId: ID!) {
@@ -148,127 +147,116 @@ function UpdatePolicyPageContent({
};
return (
<>
<Helmet>
<title>Update Policy - Probo Console</title>
</Helmet>
<div className="container">
<PageHeader
className="mb-17"
title="Update Policy"
description="Update an existing policy"
/>
<PageTemplate title="Update Policy" description="Update an existing policy">
<form onSubmit={handleSubmit}>
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Policy Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Policy Name</Label>
<Input
id="name"
placeholder="Enter policy name"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<form onSubmit={handleSubmit}>
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Policy Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Policy Name</Label>
<Input
id="name"
placeholder="Enter policy name"
value={name}
onChange={(e) => setName(e.target.value)}
required
<div className="space-y-2">
<Label htmlFor="content">Policy Content</Label>
<div className="min-h-[300px]">
<PolicyEditor
initialContent={content}
onChange={(html) => setContent(html)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="content">Policy Content</Label>
<div className="min-h-[300px]">
<PolicyEditor
initialContent={content}
onChange={(html) => setContent(html)}
/>
<div className="space-y-2">
<Label>Status</Label>
<RadioGroup
value={status}
onValueChange={(value) =>
setStatus(value as "DRAFT" | "ACTIVE")
}
className="flex space-x-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="DRAFT" id="draft" />
<Label htmlFor="draft" className="cursor-pointer">
Draft
</Label>
</div>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="ACTIVE" id="active" />
<Label htmlFor="active" className="cursor-pointer">
Active
</Label>
</div>
</RadioGroup>
</div>
<div className="space-y-2">
<Label>Status</Label>
<RadioGroup
value={status}
onValueChange={(value) =>
setStatus(value as "DRAFT" | "ACTIVE")
}
className="flex space-x-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="DRAFT" id="draft" />
<Label htmlFor="draft" className="cursor-pointer">
Draft
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="ACTIVE" id="active" />
<Label htmlFor="active" className="cursor-pointer">
Active
</Label>
</div>
</RadioGroup>
</div>
<div className="space-y-2">
<Label htmlFor="owner" className="flex items-center gap-2">
<User className="h-4 w-4" />
Policy Owner
</Label>
<PeopleSelector
organizationRef={data.organization}
selectedPersonId={ownerId}
onSelect={setOwnerId}
placeholder="Select policy owner"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="owner" className="flex items-center gap-2">
<User className="h-4 w-4" />
Policy Owner
</Label>
<PeopleSelector
organizationRef={data.organization}
selectedPersonId={ownerId}
onSelect={setOwnerId}
placeholder="Select policy owner"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="reviewDate" className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
Review Date
</Label>
<Input
id="reviewDate"
type="date"
value={reviewDate}
onChange={(e) => setReviewDate(e.target.value)}
/>
</div>
</CardContent>
</Card>
<div className="space-y-2">
<Label
htmlFor="reviewDate"
className="flex items-center gap-2"
>
<Calendar className="h-4 w-4" />
Review Date
</Label>
<Input
id="reviewDate"
type="date"
value={reviewDate}
onChange={(e) => setReviewDate(e.target.value)}
/>
</div>
</CardContent>
</Card>
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
onClick={() =>
navigate(
`/organizations/${organizationId}/policies/${policyId}`
)
}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Updating..." : "Update Policy"}
</Button>
</div>
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
onClick={() =>
navigate(
`/organizations/${organizationId}/policies/${policyId}`
)
}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Updating..." : "Update Policy"}
</Button>
</div>
</form>
</div>
</>
</div>
</form>
</PageTemplate>
);
}
function UpdatePolicyPageFallback() {
export function UpdatePolicyPageSkeleton() {
return (
<div className="container mx-auto py-6">
<PageTemplateSkeleton
title="Update Policy"
description="Update an existing policy"
>
<div className="flex items-center mb-6">
<div className="mr-4">
<div className="h-12 w-12 bg-muted animate-pulse rounded-lg" />
@@ -279,7 +267,7 @@ function UpdatePolicyPageFallback() {
</div>
</div>
<div className="bg-muted animate-pulse rounded-lg h-[600px]" />
</div>
</PageTemplateSkeleton>
);
}
@@ -295,8 +283,12 @@ export default function UpdatePolicyPage() {
}
}, [organizationId, policyId, loadQuery]);
if (!queryRef) {
return <UpdatePolicyPageSkeleton />;
}
return (
<Suspense fallback={<UpdatePolicyPageFallback />}>
<Suspense fallback={<UpdatePolicyPageSkeleton />}>
{queryRef && <UpdatePolicyPageContent queryRef={queryRef} />}
</Suspense>
);

View File

@@ -16,13 +16,12 @@ import { Button } from "@/components/ui/button";
import { Link } from "react-router";
import Fuse from "fuse.js";
import type { VendorListPageQuery as VendorListPageQueryType } from "./__generated__/VendorListPageQuery.graphql";
import { Helmet } from "react-helmet-async";
import { VendorListPageCreateVendorMutation } from "./__generated__/VendorListPageCreateVendorMutation.graphql";
import { VendorListPageDeleteVendorMutation } from "./__generated__/VendorListPageDeleteVendorMutation.graphql";
import { VendorListPagePaginationQuery } from "./__generated__/VendorListPagePaginationQuery.graphql";
import { VendorListPage_vendors$key } from "./__generated__/VendorListPage_vendors.graphql";
import { useToast } from "@/hooks/use-toast";
import { PageHeader } from "@/components/PageHeader";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
const ITEMS_PER_PAGE = 25;
@@ -240,97 +239,52 @@ function VendorListContent({
});
return (
<>
<Helmet>
<title>Vendors - Probo</title>
</Helmet>
<div className="space-y-6">
<PageHeader
className="mb-17"
title="Vendors"
description="Vendors are third-party services that your company uses. Add them to
keep track of their risk and compliance status."
/>
<PageTemplate
title="Vendors"
description="Vendors are third-party services that your company uses. Add them to
keep track of their risk and compliance status."
>
<div className="rounded-xl border bg-card p-4">
<div className="flex items-center gap-2 mb-4">
<Store className="h-5 w-5" />
<h3 className="font-medium">Add a vendor</h3>
</div>
<div className="flex gap-2 relative">
<Input
type="text"
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);
}
}}
/>
<div className="rounded-xl border bg-card p-4">
<div className="flex items-center gap-2 mb-4">
<Store className="h-5 w-5" />
<h3 className="font-medium">Add a vendor</h3>
</div>
<div className="flex gap-2 relative">
<Input
type="text"
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);
}
}}
/>
{searchTerm.trim() !== "" && (
<>
{filteredVendors.length > 0 ? (
<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-popover shadow-md z-10"
>
{filteredVendors.map((vendor: VendorItem) => (
<button
key={vendor.id}
className="w-full px-3 py-2 text-left hover:bg-accent"
onClick={() => {
createVendor({
variables: {
connections: [vendorsConnection.vendors.__id],
input: {
organizationId: data.organization.id,
name: vendor.name,
description: "",
serviceStartAt: new Date().toISOString(),
serviceCriticality: "LOW",
riskTier: "GENERAL",
},
},
onCompleted() {
setSearchTerm("");
setFilteredVendors([]);
toast({
title: "Vendor added",
description:
"The vendor has been added successfully",
});
},
});
}}
>
{vendor.name}
</button>
))}
</div>
) : (
<div
style={{ borderRadius: "0.3rem" }}
className="absolute top-full left-0 mt-1 w-[calc(100%-100px)] border bg-popover shadow-md z-10"
>
{searchTerm.trim() !== "" && (
<>
{filteredVendors.length > 0 ? (
<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-popover shadow-md z-10"
>
{filteredVendors.map((vendor: VendorItem) => (
<button
className="w-full px-3 py-2 text-left hover:bg-accent flex items-center gap-2"
key={vendor.id}
className="w-full px-3 py-2 text-left hover:bg-accent"
onClick={() => {
createVendor({
variables: {
connections: [vendorsConnection.vendors.__id],
input: {
organizationId: data.organization.id,
name: searchTerm.trim(),
name: vendor.name,
description: "",
serviceStartAt: new Date().toISOString(),
serviceCriticality: "LOW",
@@ -341,135 +295,174 @@ function VendorListContent({
setSearchTerm("");
setFilteredVendors([]);
toast({
title: "Vendor created",
title: "Vendor added",
description:
"The new vendor has been created successfully",
"The vendor has been added successfully",
});
},
});
}}
>
<span className="font-medium">Create new vendor:</span>{" "}
{searchTerm}
{vendor.name}
</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-card hover:bg-accent/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>
{vendor?.description && (
<>
<span className="text-muted-foreground">•</span>
<p className="text-sm text-muted-foreground">
{vendor.description}
</p>
</>
)}
</div>
))}
</div>
<div className="flex items-center gap-2">
<Badge
variant="secondary"
className={
vendor.riskTier === "CRITICAL"
? "bg-red-100 text-red-900 rounded-full px-3 py-0.5 text-xs font-medium"
: vendor?.riskTier === "SIGNIFICANT"
? "bg-yellow-100 text-yellow-900 rounded-full px-3 py-0.5 text-xs font-medium"
: "bg-green-100 text-green-900 rounded-full px-3 py-0.5 text-xs font-medium"
}
>
{vendor.riskTier}
</Badge>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:bg-transparent hover:[&>svg]:text-destructive"
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,
},
) : (
<div
style={{ borderRadius: "0.3rem" }}
className="absolute top-full left-0 mt-1 w-[calc(100%-100px)] border bg-popover shadow-md z-10"
>
<button
className="w-full px-3 py-2 text-left hover:bg-accent flex items-center gap-2"
onClick={() => {
createVendor({
variables: {
connections: [vendorsConnection.vendors.__id],
input: {
organizationId: data.organization.id,
name: searchTerm.trim(),
description: "",
serviceStartAt: new Date().toISOString(),
serviceCriticality: "LOW",
riskTier: "GENERAL",
},
onCompleted() {
toast({
title: "Vendor deleted",
description:
"The vendor has been deleted successfully",
});
},
});
}
},
onCompleted() {
setSearchTerm("");
setFilteredVendors([]);
toast({
title: "Vendor created",
description:
"The new vendor has been created successfully",
});
},
});
}}
>
<Trash2 className="h-4 w-4 transition-colors" />
</Button>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Create new vendor:</span>{" "}
{searchTerm}
</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-card hover:bg-accent/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>
{vendor?.description && (
<>
<span className="text-muted-foreground">•</span>
<p className="text-sm text-muted-foreground">
{vendor.description}
</p>
</>
)}
</div>
</div>
</Link>
))}
</div>
<LoadAboveButton
isLoading={isLoadingPrevious}
hasMore={hasPrevious}
onLoadMore={() => {
startTransition(() => {
setSearchParams((prev) => {
prev.set("before", pageInfo?.startCursor || "");
prev.delete("after");
return prev;
});
loadPrevious(ITEMS_PER_PAGE);
});
}}
/>
<LoadBelowButton
isLoading={isLoadingNext}
hasMore={hasNext}
onLoadMore={() => {
startTransition(() => {
setSearchParams((prev) => {
prev.set("after", pageInfo?.endCursor || "");
prev.delete("before");
return prev;
});
loadNext(ITEMS_PER_PAGE);
});
}}
/>
<div className="flex items-center gap-2">
<Badge
variant="secondary"
className={
vendor.riskTier === "CRITICAL"
? "bg-red-100 text-red-900 rounded-full px-3 py-0.5 text-xs font-medium"
: vendor?.riskTier === "SIGNIFICANT"
? "bg-yellow-100 text-yellow-900 rounded-full px-3 py-0.5 text-xs font-medium"
: "bg-green-100 text-green-900 rounded-full px-3 py-0.5 text-xs font-medium"
}
>
{vendor.riskTier}
</Badge>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:bg-transparent hover:[&>svg]:text-destructive"
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-muted-foreground" />
</div>
</div>
</Link>
))}
</div>
</>
<LoadAboveButton
isLoading={isLoadingPrevious}
hasMore={hasPrevious}
onLoadMore={() => {
startTransition(() => {
setSearchParams((prev) => {
prev.set("before", pageInfo?.startCursor || "");
prev.delete("after");
return prev;
});
loadPrevious(ITEMS_PER_PAGE);
});
}}
/>
<LoadBelowButton
isLoading={isLoadingNext}
hasMore={hasNext}
onLoadMore={() => {
startTransition(() => {
setSearchParams((prev) => {
prev.set("after", pageInfo?.endCursor || "");
prev.delete("before");
return prev;
});
loadNext(ITEMS_PER_PAGE);
});
}}
/>
</PageTemplate>
);
}
function VendorListFallback() {
export function VendorListPageSkeleton() {
return (
<div className="space-y-6">
<PageTemplateSkeleton
title="Vendors"
description="Vendors are third-party services that your company uses. Add them to
keep track of their risk and compliance status."
>
<div>
<div className="h-8 w-48 bg-muted animate-pulse rounded" />
<div className="h-4 w-96 bg-muted animate-pulse rounded mt-1" />
@@ -486,7 +479,7 @@ function VendorListFallback() {
<div key={i} className="h-[72px] bg-muted animate-pulse rounded-xl" />
))}
</div>
</div>
</PageTemplateSkeleton>
);
}
@@ -511,17 +504,12 @@ export default function VendorListPage() {
}, [loadQuery, organizationId]);
if (!queryRef) {
return <VendorListFallback />;
return <VendorListPageSkeleton />;
}
return (
<>
<Helmet>
<title>Vendors - Probo Console</title>
</Helmet>
<Suspense fallback={<VendorListFallback />}>
<VendorListContent queryRef={queryRef} />
</Suspense>
</>
<Suspense fallback={<VendorListPageSkeleton />}>
<VendorListContent queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -16,9 +16,8 @@ import {
import { Suspense, useEffect, useState, useCallback } from "react";
import type { VendorOverviewPageQuery as VendorOverviewPageQueryType } from "./__generated__/VendorOverviewPageQuery.graphql";
import { useParams } from "react-router";
import { Helmet } from "react-helmet-async";
import { cn } from "@/lib/utils";
import { PageHeader } from "@/components/PageHeader";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
const vendorOverviewPageQuery = graphql`
query VendorOverviewPageQuery($vendorId: ID!) {
@@ -205,188 +204,176 @@ function VendorOverviewPageContent({
};
return (
<>
<Helmet>
<title>Vendor - Probo</title>
</Helmet>
<div className="container">
<PageHeader className="mb-17" title={formData.name} />
<div className="max-w-4xl space-y-6">
<EditableField
label="Name"
value={formData.name}
onChange={(value) => handleFieldChange("name", value)}
/>
<PageTemplate title={formData.name}>
<div className="max-w-2xl space-y-6">
<EditableField
label="Name"
value={formData.name}
onChange={(value) => handleFieldChange("name", value)}
/>
<EditableField
label="Description"
value={formData.description}
onChange={(value) => handleFieldChange("description", value)}
/>
<EditableField
label="Description"
value={formData.description}
onChange={(value) => handleFieldChange("description", value)}
/>
<Card className="p-6">
<div className="space-y-4">
<div className="space-y-2">
<h2 className="text-lg font-medium">Service Information</h2>
<p className="text-sm text-gray-500">
Basic information about the vendor service
</p>
</div>
<Card className="p-6">
<div className="space-y-4">
<EditableField
label="Service Start At"
value={formData.serviceStartAt}
type="datetime-local"
onChange={(value) => handleFieldChange("serviceStartAt", value)}
/>
<EditableField
label="Service Termination At"
value={formData.serviceTerminationAt}
type="datetime-local"
onChange={(value) =>
handleFieldChange("serviceTerminationAt", value)
}
/>
<div className="space-y-2">
<h2 className="text-lg font-medium">Service Information</h2>
<div className="flex items-center gap-2">
<HelpCircle className="h-4 w-4 text-gray-400" />
<Label className="text-sm">Service Criticality</Label>
</div>
<div className="flex gap-2">
<button
onClick={() =>
handleFieldChange("serviceCriticality", "LOW")
}
className={cn(
"rounded-full px-4 py-1 text-sm transition-colors",
formData.serviceCriticality === "LOW"
? "bg-green-100 text-green-900 ring-2 ring-green-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
)}
>
Low
</button>
<button
onClick={() =>
handleFieldChange("serviceCriticality", "MEDIUM")
}
className={cn(
"rounded-full px-4 py-1 text-sm transition-colors",
formData.serviceCriticality === "MEDIUM"
? "bg-yellow-100 text-yellow-900 ring-2 ring-yellow-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
)}
>
Medium
</button>
<button
onClick={() =>
handleFieldChange("serviceCriticality", "HIGH")
}
className={cn(
"rounded-full px-4 py-1 text-sm transition-colors",
formData.serviceCriticality === "HIGH"
? "bg-red-100 text-red-900 ring-2 ring-red-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
)}
>
High
</button>
</div>
<p className="text-sm text-gray-500">
Basic information about the vendor service
{formData.serviceCriticality === "HIGH" &&
"Critical service - downtime severely impacts end-users"}
{formData.serviceCriticality === "MEDIUM" &&
"Important service - downtime moderately affects end-users"}
{formData.serviceCriticality === "LOW" &&
"Non-critical service - minimal end-user impact if down"}
</p>
</div>
<div className="space-y-4">
<EditableField
label="Service Start At"
value={formData.serviceStartAt}
type="datetime-local"
onChange={(value) =>
handleFieldChange("serviceStartAt", value)
}
/>
<EditableField
label="Service Termination At"
value={formData.serviceTerminationAt}
type="datetime-local"
onChange={(value) =>
handleFieldChange("serviceTerminationAt", value)
}
/>
<div className="space-y-2">
<div className="flex items-center gap-2">
<HelpCircle className="h-4 w-4 text-gray-400" />
<Label className="text-sm">Service Criticality</Label>
</div>
<div className="flex gap-2">
<button
onClick={() =>
handleFieldChange("serviceCriticality", "LOW")
}
className={cn(
"rounded-full px-4 py-1 text-sm transition-colors",
formData.serviceCriticality === "LOW"
? "bg-green-100 text-green-900 ring-2 ring-green-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
)}
>
Low
</button>
<button
onClick={() =>
handleFieldChange("serviceCriticality", "MEDIUM")
}
className={cn(
"rounded-full px-4 py-1 text-sm transition-colors",
formData.serviceCriticality === "MEDIUM"
? "bg-yellow-100 text-yellow-900 ring-2 ring-yellow-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
)}
>
Medium
</button>
<button
onClick={() =>
handleFieldChange("serviceCriticality", "HIGH")
}
className={cn(
"rounded-full px-4 py-1 text-sm transition-colors",
formData.serviceCriticality === "HIGH"
? "bg-red-100 text-red-900 ring-2 ring-red-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
)}
>
High
</button>
</div>
<p className="text-sm text-gray-500">
{formData.serviceCriticality === "HIGH" &&
"Critical service - downtime severely impacts end-users"}
{formData.serviceCriticality === "MEDIUM" &&
"Important service - downtime moderately affects end-users"}
{formData.serviceCriticality === "LOW" &&
"Non-critical service - minimal end-user impact if down"}
</p>
<div className="space-y-2">
<div className="flex items-center gap-2">
<HelpCircle className="h-4 w-4 text-gray-400" />
<Label className="text-sm">Risk Tier</Label>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<HelpCircle className="h-4 w-4 text-gray-400" />
<Label className="text-sm">Risk Tier</Label>
</div>
<div className="flex gap-2">
<button
onClick={() => handleFieldChange("riskTier", "CRITICAL")}
className={cn(
"rounded-full px-4 py-1 text-sm transition-colors",
formData.riskTier === "CRITICAL"
? "bg-red-100 text-red-900 ring-2 ring-red-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
)}
>
Critical
</button>
<button
onClick={() =>
handleFieldChange("riskTier", "SIGNIFICANT")
}
className={cn(
"rounded-full px-4 py-1 text-sm transition-colors",
formData.riskTier === "SIGNIFICANT"
? "bg-yellow-100 text-yellow-900 ring-2 ring-yellow-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
)}
>
Significant
</button>
<button
onClick={() => handleFieldChange("riskTier", "GENERAL")}
className={cn(
"rounded-full px-4 py-1 text-sm transition-colors",
formData.riskTier === "GENERAL"
? "bg-green-100 text-green-900 ring-2 ring-green-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
)}
>
General
</button>
</div>
<p className="text-sm text-gray-500">
{formData.riskTier === "CRITICAL" &&
"Handles sensitive data, critical for platform operation"}
{formData.riskTier === "SIGNIFICANT" &&
"No user data access, but important for platform management"}
{formData.riskTier === "GENERAL" &&
"General vendor with minimal risk"}
</p>
<div className="flex gap-2">
<button
onClick={() => handleFieldChange("riskTier", "CRITICAL")}
className={cn(
"rounded-full px-4 py-1 text-sm transition-colors",
formData.riskTier === "CRITICAL"
? "bg-red-100 text-red-900 ring-2 ring-red-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
)}
>
Critical
</button>
<button
onClick={() => handleFieldChange("riskTier", "SIGNIFICANT")}
className={cn(
"rounded-full px-4 py-1 text-sm transition-colors",
formData.riskTier === "SIGNIFICANT"
? "bg-yellow-100 text-yellow-900 ring-2 ring-yellow-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
)}
>
Significant
</button>
<button
onClick={() => handleFieldChange("riskTier", "GENERAL")}
className={cn(
"rounded-full px-4 py-1 text-sm transition-colors",
formData.riskTier === "GENERAL"
? "bg-green-100 text-green-900 ring-2 ring-green-600 ring-offset-2"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
)}
>
General
</button>
</div>
<EditableField
label="Status Page URL"
value={formData.statusPageUrl || ""}
onChange={(value) =>
handleFieldChange("statusPageUrl", value)
}
/>
<EditableField
label="Terms of Service URL"
value={formData.termsOfServiceUrl || ""}
onChange={(value) =>
handleFieldChange("termsOfServiceUrl", value)
}
/>
<EditableField
label="Privacy Policy URL"
value={formData.privacyPolicyUrl || ""}
onChange={(value) =>
handleFieldChange("privacyPolicyUrl", value)
}
/>
<p className="text-sm text-gray-500">
{formData.riskTier === "CRITICAL" &&
"Handles sensitive data, critical for platform operation"}
{formData.riskTier === "SIGNIFICANT" &&
"No user data access, but important for platform management"}
{formData.riskTier === "GENERAL" &&
"General vendor with minimal risk"}
</p>
</div>
<EditableField
label="Status Page URL"
value={formData.statusPageUrl || ""}
onChange={(value) => handleFieldChange("statusPageUrl", value)}
/>
<EditableField
label="Terms of Service URL"
value={formData.termsOfServiceUrl || ""}
onChange={(value) =>
handleFieldChange("termsOfServiceUrl", value)
}
/>
<EditableField
label="Privacy Policy URL"
value={formData.privacyPolicyUrl || ""}
onChange={(value) =>
handleFieldChange("privacyPolicyUrl", value)
}
/>
</div>
</Card>
</div>
</div>
</Card>
</div>
{hasChanges && (
@@ -402,13 +389,13 @@ function VendorOverviewPageContent({
</Button>
</div>
)}
</>
</PageTemplate>
);
}
function VendorOverviewPageFallback() {
export function VendorOverviewPageSkeleton() {
return (
<div className="p-6 space-y-6">
<PageTemplateSkeleton>
<div className="space-y-1">
<div className="h-8 w-48 bg-muted animate-pulse rounded" />
<div className="h-4 w-96 bg-muted animate-pulse rounded" />
@@ -418,7 +405,7 @@ function VendorOverviewPageFallback() {
<div key={i} className="h-20 bg-muted animate-pulse rounded-lg" />
))}
</div>
</div>
</PageTemplateSkeleton>
);
}
@@ -433,17 +420,12 @@ export default function VendorOverviewPage() {
}, [loadQuery, vendorId]);
if (!queryRef) {
return <VendorOverviewPageFallback />;
return <VendorOverviewPageSkeleton />;
}
return (
<>
<Helmet>
<title>Vendor Overview - Probo Console</title>
</Helmet>
<Suspense fallback={<VendorOverviewPageFallback />}>
<VendorOverviewPageContent queryRef={queryRef} />
</Suspense>
</>
<Suspense fallback={<VendorOverviewPageSkeleton />}>
<VendorOverviewPageContent queryRef={queryRef} />
</Suspense>
);
}