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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ import {
useMutation, useMutation,
useLazyLoadQuery, useLazyLoadQuery,
} from "react-relay"; } from "react-relay";
import { Helmet } from "react-helmet-async";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@@ -21,6 +20,7 @@ import { useToast } from "@/hooks/use-toast";
import { HelpCircle } from "lucide-react"; import { HelpCircle } from "lucide-react";
import { CreateOrganizationPageCreateOrganizationMutation } from "./__generated__/CreateOrganizationPageCreateOrganizationMutation.graphql"; import { CreateOrganizationPageCreateOrganizationMutation } from "./__generated__/CreateOrganizationPageCreateOrganizationMutation.graphql";
import { CreateOrganizationPageViewerQuery } from "./__generated__/CreateOrganizationPageViewerQuery.graphql"; import { CreateOrganizationPageViewerQuery } from "./__generated__/CreateOrganizationPageViewerQuery.graphql";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
const createOrganizationMutation = graphql` const createOrganizationMutation = graphql`
mutation CreateOrganizationPageCreateOrganizationMutation( 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() { export default function CreateOrganizationPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { toast } = useToast(); const { toast } = useToast();
const data = useLazyLoadQuery<CreateOrganizationPageViewerQuery>( const data = useLazyLoadQuery<CreateOrganizationPageViewerQuery>(
viewerQuery, viewerQuery,
{}, {}
); );
const [createOrganization] = const [createOrganization] =
useMutation<CreateOrganizationPageCreateOrganizationMutation>( useMutation<CreateOrganizationPageCreateOrganizationMutation>(
createOrganizationMutation, createOrganizationMutation
); );
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: "", name: "",
@@ -114,7 +125,7 @@ export default function CreateOrganizationPage() {
connections: [ connections: [
ConnectionHandler.getConnectionID( ConnectionHandler.getConnectionID(
data.viewer.id, data.viewer.id,
"OrganizationSwitcher_organizations", "OrganizationSwitcher_organizations"
), ),
], ],
}, },
@@ -138,46 +149,33 @@ export default function CreateOrganizationPage() {
}; };
return ( return (
<> <PageTemplate
<Helmet> title="Create Organization"
<title>Create Organization - Probo</title> description="Create a new organization to manage your compliance and security needs."
</Helmet> >
<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"> <Button type="submit" className="w-full">
<div> Create Organization
<h1 className="text-2xl font-semibold tracking-tight"> </Button>
Create Organization </CardContent>
</h1> </Card>
<p className="text-muted-foreground"> </form>
Create a new organization to manage your compliance and security </PageTemplate>
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>
</>
); );
} }

View File

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

View File

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

View File

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

View File

@@ -20,8 +20,7 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import type { FrameworkOverviewPageQuery as FrameworkOverviewPageQueryType } from "./__generated__/FrameworkOverviewPageQuery.graphql"; import type { FrameworkOverviewPageQuery as FrameworkOverviewPageQueryType } from "./__generated__/FrameworkOverviewPageQuery.graphql";
import { Helmet } from "react-helmet-async"; import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
import { PageHeader } from "@/components/PageHeader";
const FrameworkOverviewPageQuery = graphql` const FrameworkOverviewPageQuery = graphql`
query FrameworkOverviewPageQuery($frameworkId: ID!) { query FrameworkOverviewPageQuery($frameworkId: ID!) {
@@ -169,32 +168,29 @@ function FrameworkOverviewPageContent({
}; };
return ( return (
<div className="container space-y-6"> <PageTemplate
<PageHeader title={framework.name ?? ""}
className="mb-10" description={framework.description ?? ""}
title={framework.name ?? ""} actions={
description={framework.description ?? ""} <div className="flex gap-4">
actions={ <Button variant="outline" asChild>
<div className="flex gap-4"> <Link
<Button variant="outline" asChild> to={`/organizations/${organizationId}/frameworks/${framework.id}/update`}
<Link >
to={`/organizations/${organizationId}/frameworks/${framework.id}/update`} Edit Framework
> </Link>
Edit Framework </Button>
</Link> <Button asChild>
</Button> <Link
<Button asChild> to={`/organizations/${organizationId}/frameworks/${framework.id}/controls/create`}
<Link >
to={`/organizations/${organizationId}/frameworks/${framework.id}/controls/create`} <Plus className="mr-2 h-4 w-4" />
> Create Control
<Plus className="mr-2 h-4 w-4" /> </Link>
Create Control </Button>
</Link> </div>
</Button> }
</div> >
}
/>
<div className="grid gap-6"> <div className="grid gap-6">
{categories.map((category) => { {categories.map((category) => {
const isExpanded = expandedCategories.includes(category.id); const isExpanded = expandedCategories.includes(category.id);
@@ -326,17 +322,13 @@ function FrameworkOverviewPageContent({
); );
})} })}
</div> </div>
</div> </PageTemplate>
); );
} }
function FrameworkOverviewPageFallback() { export function FrameworkOverviewPageSkeleton() {
return ( return (
<div className="min-h-screen bg-background p-6"> <PageTemplateSkeleton>
<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>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => ( {[1, 2, 3].map((i) => (
<Card key={i}> <Card key={i}>
@@ -351,7 +343,7 @@ function FrameworkOverviewPageFallback() {
</Card> </Card>
))} ))}
</div> </div>
</div> </PageTemplateSkeleton>
); );
} }
@@ -365,14 +357,13 @@ export default function FrameworkOverviewPage() {
loadQuery({ frameworkId: frameworkId! }); loadQuery({ frameworkId: frameworkId! });
}, [loadQuery, frameworkId]); }, [loadQuery, frameworkId]);
if (!queryRef) {
return <FrameworkOverviewPageSkeleton />;
}
return ( return (
<> <Suspense fallback={<FrameworkOverviewPageSkeleton />}>
<Helmet> {queryRef && <FrameworkOverviewPageContent queryRef={queryRef} />}
<title>Framework Overview - Probo Console</title> </Suspense>
</Helmet>
<Suspense fallback={<FrameworkOverviewPageFallback />}>
{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 { UserPlus, Trash2, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Link } from "react-router"; import { Link } from "react-router";
import { Helmet } from "react-helmet-async";
import type { PeopleListPageQuery as PeopleListPageQueryType } from "./__generated__/PeopleListPageQuery.graphql"; import type { PeopleListPageQuery as PeopleListPageQueryType } from "./__generated__/PeopleListPageQuery.graphql";
import type { PeopleListPageDeletePeopleMutation } from "./__generated__/PeopleListPageDeletePeopleMutation.graphql"; import type { PeopleListPageDeletePeopleMutation } from "./__generated__/PeopleListPageDeletePeopleMutation.graphql";
import { PeopleListPagePaginationQuery } from "./__generated__/PeopleListPagePaginationQuery.graphql"; import { PeopleListPagePaginationQuery } from "./__generated__/PeopleListPagePaginationQuery.graphql";
import { PeopleListPage_peoples$key } from "./__generated__/PeopleListPage_peoples.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; const ITEMS_PER_PAGE = 25;
@@ -174,31 +173,25 @@ function PeopleListContent({
const pageInfo = peoplesConnection.peoples.pageInfo; const pageInfo = peoplesConnection.peoples.pageInfo;
return ( return (
<> <PageTemplate
<Helmet> title="People"
<title>People - Probo</title> description="Keep track of your company's workforce and their progress
</Helmet> towards completing tasks assigned to them."
<div className="container space-y-6"> actions={
<PageHeader <Button
className="mb-17" asChild
title="Employees" variant="outline"
description="Keep track of your company's workforce and their progress style={{ borderRadius: "0.5rem" }}
towards completing tasks assigned to them." className="gap-2"
actions={ >
<Button <Link to={`/organizations/${organizationId}/people/create`}>
asChild <UserPlus className="h-4 w-4" />
variant="outline" Add a person
style={{ borderRadius: "0.5rem" }} </Link>
className="gap-2" </Button>
> }
<Link to={`/organizations/${organizationId}/people/create`}> >
<UserPlus className="h-4 w-4" /> <div className="space-y-6">
Add a person
</Link>
</Button>
}
/>
<div className="space-y-2"> <div className="space-y-2">
{peoples.map((person) => ( {peoples.map((person) => (
<Link <Link
@@ -296,27 +289,32 @@ function PeopleListContent({
}} }}
/> />
</div> </div>
</> </PageTemplate>
); );
} }
function PeopleListPageFallback() { export function PeopleListPageSkeleton() {
return ( return (
<div className="space-y-6"> <PageTemplateSkeleton
<div> title="People"
<div className="h-8 w-48 bg-muted animate-pulse rounded" /> description="Keep track of your company's workforce and their progress towards completing tasks assigned to them."
<div className="h-4 w-96 bg-muted animate-pulse rounded mt-1" /> 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>
<div className="rounded-xl border bg-card p-4 space-y-4"> </PageTemplateSkeleton>
<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>
); );
} }
@@ -341,17 +339,12 @@ export default function PeopleListPage() {
}, [loadQuery, organizationId, searchParams]); }, [loadQuery, organizationId, searchParams]);
if (!queryRef) { if (!queryRef) {
return <PeopleListPageFallback />; return <PeopleListPageSkeleton />;
} }
return ( return (
<> <Suspense fallback={<PeopleListPageSkeleton />}>
<Helmet> <PeopleListContent queryRef={queryRef} />
<title>People - Probo Console</title> </Suspense>
</Helmet>
<Suspense fallback={<PeopleListPageFallback />}>
<PeopleListContent queryRef={queryRef} />
</Suspense>
</>
); );
} }

View File

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

View File

@@ -7,7 +7,6 @@ import {
} from "react-relay"; } from "react-relay";
import { Card, CardContent, CardFooter } from "@/components/ui/card"; import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { Link, useParams } from "react-router"; import { Link, useParams } from "react-router";
import { Helmet } from "react-helmet-async";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
@@ -34,7 +33,7 @@ import {
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { format } from "date-fns"; import { format } from "date-fns";
import type { PolicyListPageQuery as PolicyListPageQueryType } from "./__generated__/PolicyListPageQuery.graphql"; import type { PolicyListPageQuery as PolicyListPageQueryType } from "./__generated__/PolicyListPageQuery.graphql";
import { PageHeader } from "@/components/PageHeader"; import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
const PolicyListPageQuery = graphql` const PolicyListPageQuery = graphql`
query PolicyListPageQuery($organizationId: ID!) { query PolicyListPageQuery($organizationId: ID!) {
@@ -220,153 +219,141 @@ function PolicyListPageContent({
}); });
return ( return (
<> <PageTemplate
<Helmet> title="Policies"
<title>Policies - Probo</title> description="Manage your organization's policies"
</Helmet> actions={
<div className="container space-y-6"> <Button asChild>
<PageHeader <Link to={`/organizations/${organizationId}/policies/create`}>
className="mb-17" <Plus className="mr-2 h-4 w-4" />
title="Policies" Create Policy
description="Manage your organization's policies" </Link>
actions={ </Button>
<Button asChild> }
<Link to={`/organizations/${organizationId}/policies/create`}> >
<Plus className="mr-2 h-4 w-4" /> {/* Search and filter controls */}
Create Policy <div className="flex flex-col md:flex-row gap-4 mb-6">
</Link> <div className="relative flex-1">
</Button> <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"
{/* Search and filter controls */} value={searchQuery}
<div className="flex flex-col md:flex-row gap-4 mb-6"> onChange={(e) => setSearchQuery(e.target.value)}
<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>
</div> </div>
{/* Results summary */} <div className="flex gap-2">
<div className="mb-4 text-sm text-muted-foreground"> <Select value={statusFilter} onValueChange={setStatusFilter}>
Showing {filteredPolicies.length} of {policies.length} policies <SelectTrigger className="w-[180px]">
</div> <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 */} <DropdownMenu>
<div className="space-y-6"> <DropdownMenuTrigger asChild>
{filteredPolicies.length > 0 ? ( <Button variant="outline" className="flex items-center gap-2">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <ArrowUpDown className="h-4 w-4" />
{filteredPolicies.map((policy) => ( Sort
<Link </Button>
key={policy.id} </DropdownMenuTrigger>
to={`/organizations/${organizationId}/policies/${policy.id}`} <DropdownMenuContent align="end">
className="group" <DropdownMenuItem onClick={() => setSortBy("name-asc")}>
> Name (A-Z)
<PolicyCard </DropdownMenuItem>
title={policy.name} <DropdownMenuItem onClick={() => setSortBy("name-desc")}>
content={policy.content} Name (Z-A)
status={policy.status} </DropdownMenuItem>
updatedAt={policy.updatedAt} <DropdownMenuItem onClick={() => setSortBy("updated-desc")}>
/> Recently Updated
</Link> </DropdownMenuItem>
))} <DropdownMenuItem onClick={() => setSortBy("updated-asc")}>
</div> Oldest Updated
) : ( </DropdownMenuItem>
<div className="text-center py-12 border rounded-lg bg-muted/20"> <DropdownMenuItem onClick={() => setSortBy("created-desc")}>
<FileText className="mx-auto h-8 w-8 text-muted-foreground mb-3" /> Recently Created
<h3 className="text-lg font-medium">No policies found</h3> </DropdownMenuItem>
<p className="text-muted-foreground mb-4"> <DropdownMenuItem onClick={() => setSortBy("created-asc")}>
{searchQuery || statusFilter !== "ALL" Oldest Created
? "Try adjusting your search or filters" </DropdownMenuItem>
: "Create your first policy to get started"} </DropdownMenuContent>
</p> </DropdownMenu>
{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> </div>
</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 ( return (
<div className="container mx-auto py-6"> <PageTemplateSkeleton
<div className="flex justify-between items-center mb-6"> title="Policies"
<div> description="Manage your organization's policies"
<div className="h-8 w-48 bg-muted animate-pulse rounded" /> actions={<div className="bg-muted animate-pulse h-9 w-1/6 rounded-lg" />}
<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>
{/* Search and filter controls skeleton */} {/* Search and filter controls skeleton */}
<div className="flex flex-col md:flex-row gap-4 mb-6"> <div className="flex flex-col md:flex-row gap-4 mb-6">
<div className="flex-1 h-10 bg-muted animate-pulse rounded" /> <div className="flex-1 h-10 bg-muted animate-pulse rounded" />
@@ -407,7 +394,7 @@ function PolicyListPageFallback() {
</Card> </Card>
))} ))}
</div> </div>
</div> </PageTemplateSkeleton>
); );
} }
@@ -421,14 +408,13 @@ export default function PolicyListPage() {
loadQuery({ organizationId: organizationId! }); loadQuery({ organizationId: organizationId! });
}, [loadQuery, organizationId]); }, [loadQuery, organizationId]);
if (!queryRef) {
return <PolicyListPageSkeleton />;
}
return ( return (
<> <Suspense fallback={<PolicyListPageSkeleton />}>
<Helmet> {<PolicyListPageContent queryRef={queryRef} />}
<title>Policies - Probo Console</title> </Suspense>
</Helmet>
<Suspense fallback={<PolicyListPageFallback />}>
{queryRef && <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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { PolicyOverviewPageQuery as PolicyOverviewPageQueryType } from "./__generated__/PolicyOverviewPageQuery.graphql"; import type { PolicyOverviewPageQuery as PolicyOverviewPageQueryType } from "./__generated__/PolicyOverviewPageQuery.graphql";
import type { PolicyOverviewPageDeleteMutation } from "./__generated__/PolicyOverviewPageDeleteMutation.graphql"; import type { PolicyOverviewPageDeleteMutation } from "./__generated__/PolicyOverviewPageDeleteMutation.graphql";
import { Helmet } from "react-helmet-async";
import "../styles/policy-content.css"; import "../styles/policy-content.css";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { PageHeader } from "@/components/PageHeader"; import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
const PolicyOverviewPageQuery = graphql` const PolicyOverviewPageQuery = graphql`
query PolicyOverviewPageQuery($policyId: ID!) { query PolicyOverviewPageQuery($policyId: ID!) {
@@ -149,276 +148,272 @@ function PolicyOverviewPageContent({
}; };
return ( return (
<> <PageTemplate
<Helmet> title={policy.name ?? ""}
<title>{policy.name} - Probo Console</title> description={
</Helmet> <div className="flex items-center gap-2 text-muted-foreground">
<div className="container"> <Badge variant={policy.status === "ACTIVE" ? "default" : "outline"}>
<PageHeader {policy.status === "ACTIVE" ? "Active" : "Draft"}
className="mb-17" </Badge>
title={policy.name ?? ""} {policy.owner && (
description={ <div className="flex items-center gap-1">
<div className="flex items-center gap-2 text-muted-foreground"> <User className="h-3 w-3" />
<Badge <Link
variant={policy.status === "ACTIVE" ? "default" : "outline"} to={`/organizations/${organizationId}/people/${policy.owner.id}`}
className="hover:underline"
> >
{policy.status === "ACTIVE" ? "Active" : "Draft"} {policy.owner.fullName}
</Badge> </Link>
{policy.owner && ( </div>
<div className="flex items-center gap-1"> )}
<User className="h-3 w-3" /> {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 <Link
to={`/organizations/${organizationId}/people/${policy.owner.id}`} to={`/organizations/${organizationId}/people/${policy.owner?.id}`}
className="hover:underline" className="hover:underline"
> >
{policy.owner.fullName} <p className="font-medium">
{policy.owner ? policy.owner.fullName : "Not assigned"}
</p>
</Link> </Link>
</div> </div>
)} </div>
{policy.reviewDate && ( </CardContent>
<div className="flex items-center gap-1"> </Card>
<Calendar className="h-3 w-3" />
<span>Review: {formatDate(policy.reviewDate)}</span> <Button asChild className="w-full">
</div> <Link
)} to={`/organizations/${organizationId}/policies/${policy.id}/update`}
</div> >
} <Edit className="mr-2 h-4 w-4" />
actions={ Edit Policy
<div className="flex gap-2"> </Link>
<Button variant="outline" size="sm" asChild> </Button>
<Link
to={`/organizations/${organizationId}/policies/${policy.id}/update`} <Card className="border shadow-sm mt-6 bg-red-50">
> <CardContent className="p-6">
<Edit className="h-4 w-4 mr-2" /> <h3 className="text-red-500 font-semibold mb-3">Danger Zone</h3>
Edit <p className="text-sm text-muted-foreground mb-4">
</Link> Permanently delete this policy and all of its data. This action
</Button> cannot be undone.
</p>
<Button <Button
variant="outline" variant="destructive"
size="sm" className="w-full"
onClick={() => { onClick={handleDeletePolicy}
// Logic to download policy content as PDF or text disabled={isDeleting}
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" /> <svg
Download 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> </Button>
</div> </CardContent>
} </Card>
/>
<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>
</div> </div>
</div> </div>
</> </PageTemplate>
); );
} }
function PolicyOverviewPageFallback() { export function PolicyOverviewPageSkeleton() {
return ( 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="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<Card className="border shadow-sm"> <Card className="border shadow-sm">
@@ -505,7 +500,7 @@ function PolicyOverviewPageFallback() {
</Card> </Card>
</div> </div>
</div> </div>
</div> </PageTemplateSkeleton>
); );
} }
@@ -520,8 +515,12 @@ export default function PolicyOverviewPage() {
loadQuery({ policyId: policyId! }); loadQuery({ policyId: policyId! });
}, [loadQuery, policyId]); }, [loadQuery, policyId]);
if (!queryRef) {
return <PolicyOverviewPageSkeleton />;
}
return ( return (
<Suspense fallback={<PolicyOverviewPageFallback />}> <Suspense fallback={<PolicyOverviewPageSkeleton />}>
{queryRef && <PolicyOverviewPageContent queryRef={queryRef} />} {queryRef && <PolicyOverviewPageContent queryRef={queryRef} />}
</Suspense> </Suspense>
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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