diff --git a/apps/app/languine.lock b/apps/app/languine.lock index 5690fb7..a689ae0 100644 --- a/apps/app/languine.lock +++ b/apps/app/languine.lock @@ -45,6 +45,7 @@ files: common.status.assessed: 2c051fa737a0d6c7274f4508448b7f8a common.status.active: 4d3d769b812b6faa6b76e1a8abaece2d common.status.inactive: 3cab03c00dbd11bc3569afa0748013f0 + common.status.title: ec53a8c4f07baed5d8825072c89799be common.filters.clear: 0b275442d6556cff30b75f37f1918899 common.filters.search: 2a37324bb4c331c859044121df3f576b common.filters.status: ec53a8c4f07baed5d8825072c89799be @@ -57,7 +58,7 @@ files: common.table.due_date: 14c841eea7774b78caf79c58989f9625 common.table.last_updated: 4221d3e17c6eca2ca6337251a3cf9c4e common.table.no_results: e576c23d915755d83e2d1f47bd9f6c22 - common.empty_states.no_results.title: c49c29bb6656c1f25f2cfac84f83b5ba + common.empty_states.no_results.title: e576c23d915755d83e2d1f47bd9f6c22 common.empty_states.no_results.title_tasks: 8edbb9c85d4b7cce148cb9a886dc1213 common.empty_states.no_results.title_risks: 20c62e3370120c70750d361567eacc26 common.empty_states.no_results.description: 3d7c5718545b591caa83edfd8d5f42b0 @@ -107,6 +108,10 @@ files: common.attachments.toasts.success_uploading_files_target: 293fc6e4068205eefc9df77baa941b5f common.attachments.toasts.uploading_files: a538c6cacc535cf165533cc65510248f common.attachments.toasts.remove_file: b21f085d08623fc7a5a9da204cc66c8c + common.edit: 7dce122004969d56ae2e0245cb754d35 + common.errors.unexpected_error: 9789e322478c6f2d59e93d020808642a + common.description: b5a7adde1af5c87d7fd797b6245c2a39 + common.last_updated: 4221d3e17c6eca2ca6337251a3cf9c4e header.discord.button: 1d403edd5e9a373b882ebba790de6f05 header.feedback.button: bea4c2c8eb82d05891ddd71584881b56 header.feedback.title: d62968e1245fc470aa53ae9e5646215a @@ -136,6 +141,7 @@ files: sub_pages.risk.tasks.task_overview: d5a893e5bfa69efefb2667f8e7ec600c sub_pages.policies.all: 4992678c2f629035e5c3bcde296b596a sub_pages.policies.editor: e2c974366aff19ed493a501108cbf948 + sub_pages.policies.policy_details: 8178fb068d06b0f32a7b0103e832b231 sub_pages.people.all: 78386ec8f2845742c324a3d7d3e800ff sub_pages.people.employee_details: 62fa24399196c3b1bcb1cf2cdf771da4 sub_pages.settings.members: 8196446d7293bfeb288d7c21e36a1886 @@ -180,6 +186,7 @@ files: policies.table.name: 002f7378a5fcfc2f66cfea851a7fd5f4 policies.table.statuses.draft: f03ab16cd58372c77ba45a3d9a5a1cb9 policies.table.statuses.published: 9b9d8a976b42e0bd66381797644943d5 + policies.table.statuses.archived: 7d69b3cb4cada18ae61811304f8fbcb5 policies.table.filters.owner.label: 780f85493892dc48b674ac9da8974133 policies.table.filters.owner.placeholder: 40f417d9ebe868b7aa1e61f27775a03e policies.filters.search: 417253920390e1fdb5f07216a44d61c7 @@ -187,7 +194,22 @@ files: policies.status.draft: f03ab16cd58372c77ba45a3d9a5a1cb9 policies.status.published: 9b9d8a976b42e0bd66381797644943d5 policies.status.needs_review: 994f197b212a0f4dc844cc3534e889c2 + policies.status.archived: 7d69b3cb4cada18ae61811304f8fbcb5 policies.policies: 94422fa6b0fe23d0ab463703c0022a63 + policies.title: 9e476387322a5c250893cf9c5c4ce78c + policies.create_new: 5ff074c18e92b64ab9bad69c28267285 + policies.search_placeholder: 417253920390e1fdb5f07216a44d61c7 + policies.status_filter: 5dd5cd6670a0fc8e47a47aa94d27b8b2 + policies.all_statuses: 96e3d398260724e0a83da53a2e9f20e7 + policies.no_policies_title: 329794e590e4bbc42f2f5ae965a8ba9f + policies.no_policies_description: c6800f5122f4ff305c7923edebca30be + policies.create_first: 6d12b685797010c6f339f916c7c87adb + policies.no_description: 64edba81920f6b19c899f22f474e03d1 + policies.last_updated: d4f9ce5adf5b871e10882a482927aa55 + policies.save: c9cc8cce247e49bae79f15173ce97354 + policies.saving: 575f5f86d2c7ea113c6710c1037c4465 + policies.saved_success: 32ecb65fc52361d4131583219aee82ed + policies.saved_error: 07daa040d0eb0ae927dc05af1b6f3747 evidence_tasks.evidence_tasks: 63b1eb1a39194d82271ec937fba19714 evidence_tasks.overview: 3b878279a04dc47d60932cb294d96259 risk.risks: 2bb550bd7d03efff73cd7152d6092af1 diff --git a/apps/app/package.json b/apps/app/package.json index e97e3e3..d5dd5a5 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -23,6 +23,10 @@ "@prisma/instrumentation": "^6.3.1", "@tanstack/react-query": "^5.66.0", "@tanstack/react-table": "^8.21.2", + "@tiptap/extension-table": "^2.11.5", + "@tiptap/extension-table-cell": "^2.11.5", + "@tiptap/extension-table-header": "^2.11.5", + "@tiptap/extension-table-row": "^2.11.5", "@tiptap/pm": "^2.11.5", "@tiptap/react": "^2.11.5", "@tiptap/starter-kit": "^2.11.5", diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/hooks/useOrganizationControl.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/hooks/useOrganizationControl.ts index bb2bdbd..2f75a88 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/hooks/useOrganizationControl.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/controls/[id]/hooks/useOrganizationControl.ts @@ -1,14 +1,13 @@ "use client"; -import { OrganizationControl } from "@bubba/db"; import useSWR from "swr"; import { + type OrganizationControlResponse, getOrganizationControl, - OrganizationControlResponse, } from "../Actions/getOrganizationControl"; async function fetchOrganizationControl( - controlId: string + controlId: string, ): Promise { const result = await getOrganizationControl({ controlId }); @@ -32,7 +31,7 @@ export function useOrganizationControl(controlId: string) { { revalidateOnFocus: false, revalidateOnReconnect: false, - } + }, ); return { diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/(overview)/components/PoliciesOverview.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/(overview)/components/PoliciesOverview.tsx deleted file mode 100644 index 6b3cbf0..0000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/(overview)/components/PoliciesOverview.tsx +++ /dev/null @@ -1,44 +0,0 @@ -"use client"; - -import { PoliciesByFramework } from "@/components/policies/charts/policies-by-framework"; -import { Skeleton } from "@bubba/ui/skeleton"; -import { usePolicies } from "../../hooks/usePolicies"; - -export function PoliciesOverview() { - const { data, isLoading, error } = usePolicies(); - - if (error) { - return ( -
- Failed to load policy data: {error.message} -
- ); - } - - if (isLoading || !data) { - return ( -
-
- - -
-
- -
-
- ); - } - - return ( -
-
- {/* */} - -
- -
- {/* */} -
-
- ); -} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/(overview)/loading.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/(overview)/loading.tsx deleted file mode 100644 index bb498e7..0000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/(overview)/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { SkeletonLoader } from "@/components/skeleton-loader"; - -export default function PoliciesOverviewLoading() { - return ; -} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[id]/actions/publish-policy.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[id]/actions/publish-policy.ts deleted file mode 100644 index 05a4362..0000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[id]/actions/publish-policy.ts +++ /dev/null @@ -1,76 +0,0 @@ -"use server"; - -import { authActionClient } from "@/actions/safe-action"; -import { type OrganizationPolicy, db } from "@bubba/db"; -import { z } from "zod"; - -const schema = z.object({ - id: z.string(), -}); - -export type PublishPolicyResponse = { - success: boolean; - data?: OrganizationPolicy; - error?: string; -}; - -export const publishPolicy = authActionClient - .schema(schema) - .metadata({ - name: "publish-policy", - track: { - event: "publish-policy", - channel: "server", - }, - }) - .action(async ({ ctx, parsedInput }) => { - const { user } = ctx; - const { id } = parsedInput; - - if (!user.organizationId) { - return { - success: false, - error: "Not authorized - no organization found", - }; - } - - const policy = await db.organizationPolicy.findFirst({ - where: { - policyId: id, - organizationId: user.organizationId!, - }, - select: { - id: true, - }, - }); - - if (!policy?.id) { - return { - success: false, - error: "Policy not found", - }; - } - - try { - const organizationPolicy = await db.organizationPolicy.update({ - where: { - id: policy.id, - organizationId: user.organizationId!, - }, - data: { - status: "published", - updatedAt: new Date(), - }, - }); - - return { - success: true, - data: organizationPolicy, - }; - } catch (error) { - return { - success: false, - error: "Failed to publish policy", - }; - } - }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[id]/layout.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[id]/layout.tsx deleted file mode 100644 index 27c2baa..0000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[id]/layout.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { auth } from "@/auth"; -import { redirect } from "next/navigation"; - -export default async function Layout({ - children, - params, -}: { - children: React.ReactNode; - params: Promise<{ id: string }>; -}) { - const { id } = await params; - const session = await auth(); - - if (!session) { - redirect("/login"); - } - - if (!session.user.organizationId || !id) { - redirect("/policies"); - } - - return ( -
-
{children}
-
- ); -} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[id]/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[id]/page.tsx deleted file mode 100644 index 65812cf..0000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[id]/page.tsx +++ /dev/null @@ -1,59 +0,0 @@ -"use client"; - -import Tiptap from "@/components/editor/editor"; -import { Button } from "@bubba/ui/button"; -import { Separator } from "@bubba/ui/separator"; -import { useAction } from "next-safe-action/hooks"; -import { useParams } from "next/navigation"; -import type { JSONContent } from "novel"; -import { toast } from "sonner"; -import { usePolicy } from "../hooks/usePolicy"; -import { publishPolicy } from "./actions/publish-policy"; - -export default function PolicyPage() { - const { id } = useParams(); - - const { execute, isExecuting } = useAction( - () => publishPolicy({ id: id as string }), - { - onSuccess: () => { - toast.success("Policy published successfully"); - }, - onError: () => { - toast.error("Failed to publish policy, please try again."); - }, - } - ); - - const { data: policy } = usePolicy({ policyId: id as string }); - - console.log({ policy }); - - if (!policy) return null; - - const content = policy.content as JSONContent; - - if (!content) return null; - - return ( -
-
- -
- -
-
-
- -
-
-
-
- ); -} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[policyId]/actions/get-policy-details.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[policyId]/actions/get-policy-details.ts new file mode 100644 index 0000000..990d424 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[policyId]/actions/get-policy-details.ts @@ -0,0 +1,74 @@ +"use server"; + +import { authActionClient } from "@/actions/safe-action"; +import { auth } from "@/auth"; +import { db } from "@bubba/db"; +import { + appErrors, + policyDetailsInputSchema, +} from "../types"; + +export const getPolicyDetails = authActionClient + .schema(policyDetailsInputSchema) + .metadata({ + name: "get-policy-details", + track: { + event: "get-policy-details", + channel: "server", + }, + }) + .action(async ({ parsedInput }) => { + const { policyId } = parsedInput; + + const session = await auth(); + const organizationId = session?.user.organizationId; + + if (!organizationId) { + return { + success: false, + error: appErrors.UNAUTHORIZED.message, + }; + } + + try { + const policy = await db.organizationPolicy.findUnique({ + where: { + id: policyId, + organizationId, + }, + select: { + id: true, + status: true, + content: true, + createdAt: true, + updatedAt: true, + policy: { + select: { + id: true, + name: true, + description: true, + slug: true, + }, + }, + }, + }); + + if (!policy) { + return { + success: false, + error: appErrors.NOT_FOUND.message, + }; + } + + return { + success: true, + data: policy, + }; + } catch (error) { + console.error("Error fetching policy details:", error); + return { + success: false, + error: appErrors.UNEXPECTED_ERROR.message, + }; + } + }); \ No newline at end of file diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[policyId]/actions/update-policy.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[policyId]/actions/update-policy.ts new file mode 100644 index 0000000..c8f78aa --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[policyId]/actions/update-policy.ts @@ -0,0 +1,107 @@ +"use server"; + +import { authActionClient } from "@/actions/safe-action"; +import type { ActionResponse } from "@/actions/types"; +import { auth } from "@/auth"; +import { db } from "@bubba/db"; +import { revalidatePath } from "next/cache"; +import { appErrors, updatePolicySchema } from "../types"; + +export const updatePolicy = authActionClient + .schema(updatePolicySchema) + .metadata({ + name: "update-policy", + track: { + event: "update-policy", + channel: "server", + }, + }) + .action(async ({ parsedInput }): Promise => { + const { policyId, content, status } = parsedInput; + + const session = await auth(); + const organizationId = session?.user.organizationId; + + if (!organizationId) { + return { + success: false, + error: appErrors.UNAUTHORIZED.message, + }; + } + + try { + const existingPolicy = await db.organizationPolicy.findUnique({ + where: { + id: policyId, + organizationId, + }, + }); + + if (!existingPolicy) { + return { + success: false, + error: appErrors.NOT_FOUND.message, + }; + } + + const updateData: Record = {}; + + if (content !== undefined) { + console.log("CONTENT TYPE:", typeof content); + + if (typeof content === 'object' && content !== null) { + if ('type' in content && content.type === 'doc' && 'content' in content && Array.isArray(content.content)) { + updateData.content = content.content; + console.log("Extracted content array from TipTap doc"); + } else if (Array.isArray(content)) { + updateData.content = content; + } else { + console.log("Unknown content format - using as is"); + updateData.content = content; + } + } else { + updateData.content = content; + } + } + + if (status) { + updateData.status = status; + } + + if (Object.keys(updateData).length === 0) { + return { + success: true, + data: { id: policyId, status: existingPolicy.status }, + }; + } + + console.log("Updating policy with data:", JSON.stringify(updateData)); + + const updatedPolicy = await db.organizationPolicy.update({ + where: { + id: policyId, + }, + data: updateData, + select: { + id: true, + status: true, + }, + }); + + revalidatePath(`/policies/${policyId}`); + revalidatePath("/policies"); + revalidatePath(`/[locale]/policies/${policyId}`); + revalidatePath("/[locale]/policies"); + + return { + success: true, + data: updatedPolicy, + }; + } catch (error) { + console.error("Error updating policy:", error); + return { + success: false, + error: appErrors.UNEXPECTED_ERROR.message, + }; + } + }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[policyId]/components/PolicyDetails.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[policyId]/components/PolicyDetails.tsx new file mode 100644 index 0000000..2847e33 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[policyId]/components/PolicyDetails.tsx @@ -0,0 +1,208 @@ +"use client"; + +import { useI18n } from "@/locales/client"; +import { Alert, AlertDescription, AlertTitle } from "@bubba/ui/alert"; +import { Button } from "@bubba/ui/button"; +import { Card, CardContent, CardHeader } from "@bubba/ui/card"; +import { Skeleton } from "@bubba/ui/skeleton"; +import Bold from "@tiptap/extension-bold"; +import BulletList from "@tiptap/extension-bullet-list"; +import Document from "@tiptap/extension-document"; +import Heading from "@tiptap/extension-heading"; +import Italic from "@tiptap/extension-italic"; +import Link from "@tiptap/extension-link"; +import ListItem from "@tiptap/extension-list-item"; +import OrderedList from "@tiptap/extension-ordered-list"; +import Paragraph from "@tiptap/extension-paragraph"; +import Strike from "@tiptap/extension-strike"; +import Table from "@tiptap/extension-table"; +import TableCell from "@tiptap/extension-table-cell"; +import TableHeader from "@tiptap/extension-table-header"; +import TableRow from "@tiptap/extension-table-row"; +import Text from "@tiptap/extension-text"; +import Underline from "@tiptap/extension-underline"; +import { EditorContent, useEditor } from "@tiptap/react"; +import { AlertCircle, Save } from "lucide-react"; +import { redirect } from "next/navigation"; +import { EditorRoot } from "novel"; +import React, { useEffect, useState } from "react"; +import { usePolicyDetails } from "../../hooks/usePolicy"; +import "@bubba/ui/editor.css"; +import { useDebouncedCallback } from "use-debounce"; + +interface PolicyDetailsProps { + policyId: string; +} + +export function PolicyDetails({ policyId }: PolicyDetailsProps) { + const t = useI18n(); + const { policy, isLoading, error, updatePolicy } = usePolicyDetails(policyId); + + const [saveStatus, setSaveStatus] = useState<"Saved" | "Saving" | "Unsaved">( + "Saved", + ); + const [wordCount, setWordCount] = useState(0); + const [currentContent, setCurrentContent] = useState(null); + const [initialLoadComplete, setInitialLoadComplete] = useState(false); + + // Function to save content with debounce + const debouncedSave = useDebouncedCallback(async (content: any) => { + if (!policy) return; + + setSaveStatus("Saving"); + try { + const contentToSave = + content.type === "doc" && Array.isArray(content.content) + ? content.content + : content; + + await updatePolicy({ + ...policy, + content: contentToSave, + }); + + setSaveStatus("Saved"); + } catch (err) { + console.error("Failed to save policy:", err); + setSaveStatus("Unsaved"); + } + }, 1000); + + const editor = useEditor({ + extensions: [ + Document, + Paragraph, + Text, + Bold, + Italic, + Heading.configure({ + levels: [1, 2, 3, 4, 5, 6], + }), + BulletList, + OrderedList, + ListItem, + Underline, + Strike, + Link.configure({ + openOnClick: false, + }), + Table.configure({ + resizable: true, + allowTableNodeSelection: true, + }), + TableRow, + TableHeader, + TableCell, + ], + editorProps: { + attributes: { + class: + "prose dark:prose-invert focus:outline-none h-full w-full focus:outline-none text-foreground px-16 py-16 max-w-[900px] mx-auto", + }, + }, + onUpdate: ({ editor }) => { + const content = editor.getJSON(); + setCurrentContent(content); + + const text = editor.getText(); + const words = text.trim().split(/\s+/); + setWordCount(text ? words.length : 0); + + if (initialLoadComplete) { + setSaveStatus("Unsaved"); + debouncedSave(content); + } + }, + }); + + useEffect(() => { + if (editor && policy?.content && !initialLoadComplete) { + try { + const formattedContent = { + type: "doc", + content: Array.isArray(policy.content) ? policy.content : [], + }; + + setCurrentContent(formattedContent); + editor.commands.setContent(formattedContent); + + const text = editor.getText(); + const words = text.trim().split(/\s+/); + setWordCount(text ? words.length : 0); + + setInitialLoadComplete(true); + } catch (e) { + console.error("Error setting editor content:", e); + } + } + }, [editor, policy, initialLoadComplete]); + + // Manual save function + const handleManualSave = () => { + if (!editor || !policy) return; + debouncedSave.flush(); + }; + + if (error) { + if (error.code === "NOT_FOUND") { + redirect("/policies"); + } + + return ( +
+ + + Error + + {error.message || "An unexpected error occurred"} + + +
+ ); + } + + if (isLoading) { + return ( +
+
+ + +
+ + + + + +
+ + + +
+
+
+
+ ); + } + + if (!policy) return null; + + return ( +
+
+
+ {saveStatus} +
+
+ {wordCount} Words +
+
+ + + + +
+ ); +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[policyId]/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[policyId]/page.tsx new file mode 100644 index 0000000..eed7650 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[policyId]/page.tsx @@ -0,0 +1,49 @@ +import { auth } from "@/auth"; +import { getI18n } from "@/locales/server"; +import type { Metadata } from "next"; +import { setStaticParamsLocale } from "next-international/server"; +import { redirect } from "next/navigation"; +import { PolicyDetails } from "./components/PolicyDetails"; + +export const dynamic = "force-dynamic"; // Force dynamic rendering +export const revalidate = 0; // Disable caching + +export default async function PolicyDetailsPage({ + params, +}: { + params: Promise<{ locale: string; policyId: string }>; +}) { + const { locale, policyId } = await params; + setStaticParamsLocale(locale); + + const session = await auth(); + const organizationId = session?.user.organizationId; + + if (!organizationId) { + redirect("/"); + } + + return ; +} + +// Add these headers to prevent caching +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string; policyId: string }>; +}): Promise { + const { locale } = await params; + + setStaticParamsLocale(locale); + const t = await getI18n(); + + return { + title: t("sub_pages.policies.policy_details"), + // Add cache control headers + other: { + "Cache-Control": "no-cache, no-store, must-revalidate", + Pragma: "no-cache", + Expires: "0", + }, + }; +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[policyId]/types/index.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[policyId]/types/index.ts new file mode 100644 index 0000000..729e8c0 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/[policyId]/types/index.ts @@ -0,0 +1,50 @@ +import { z } from "zod"; + +export const policyDetailsSchema = z.object({ + id: z.string(), + status: z.enum(["draft", "published", "archived"]), + content: z.array(z.any()), + createdAt: z.date(), + updatedAt: z.date(), + policy: z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + slug: z.string(), + }), +}); + +export const policyDetailsInputSchema = z.object({ + policyId: z.string(), + _cache: z.number().optional() +}); + +export const updatePolicySchema = z.object({ + policyId: z.string(), + content: z.any().optional(), + status: z.enum(["draft", "published", "archived"]).optional(), +}); + +export type PolicyDetails = z.infer; +export type PolicyDetailsInput = z.infer; +export type UpdatePolicyInput = z.infer; + +export type AppError = { + code: "NOT_FOUND" | "UNAUTHORIZED" | "UNEXPECTED_ERROR"; + message: string; +}; + +export const appErrors = { + NOT_FOUND: { + code: "NOT_FOUND" as const, + message: "Policy not found", + }, + UNAUTHORIZED: { + code: "UNAUTHORIZED" as const, + message: "You are not authorized to view this policy", + }, + UNEXPECTED_ERROR: { + code: "UNEXPECTED_ERROR" as const, + message: "An unexpected error occurred", + }, +} as const; \ No newline at end of file diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/actions/get-policies.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/actions/get-policies.ts index a5685e0..4011b76 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/actions/get-policies.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/actions/get-policies.ts @@ -1,19 +1,11 @@ "use server"; import { authActionClient } from "@/actions/safe-action"; -import { db, type OrganizationPolicy } from "@bubba/db"; -import { z } from "zod"; - -const schema = z.object({}); - -export type PolicyStatsResponse = { - success: boolean; - data?: OrganizationPolicy[]; - error?: string; -}; +import { db } from "@bubba/db"; +import { appErrors, policiesInputSchema } from "../types"; export const getPolicies = authActionClient - .schema(schema) + .schema(policiesInputSchema) .metadata({ name: "get-policies", track: { @@ -21,34 +13,85 @@ export const getPolicies = authActionClient channel: "server", }, }) - .action(async ({ ctx }) => { + .action(async ({ parsedInput, ctx }) => { + const { search, status, page = 1, per_page = 10 } = parsedInput; const { user } = ctx; if (!user.organizationId) { return { success: false, - error: "Not authorized - no organization found", + error: appErrors.UNAUTHORIZED.message, }; } try { - const policies = await db.organizationPolicy.findMany({ - where: { - organizationId: user.organizationId!, - }, - include: { - policy: true, - }, - }); + const skip = (page - 1) * per_page; + + const [policies, total] = await Promise.all([ + db.organizationPolicy.findMany({ + where: { + organizationId: user.organizationId, + AND: [ + search + ? { + policy: { + OR: [ + { name: { contains: search, mode: "insensitive" } }, + { description: { contains: search, mode: "insensitive" } }, + ], + }, + } + : {}, + status ? { status: status as any } : {}, + ], + }, + select: { + id: true, + status: true, + createdAt: true, + updatedAt: true, + policy: { + select: { + id: true, + name: true, + description: true, + slug: true, + }, + }, + }, + skip, + take: per_page, + orderBy: { updatedAt: 'desc' }, + }), + db.organizationPolicy.count({ + where: { + organizationId: user.organizationId, + AND: [ + search + ? { + policy: { + OR: [ + { name: { contains: search, mode: "insensitive" } }, + { description: { contains: search, mode: "insensitive" } }, + ], + }, + } + : {}, + status ? { status: status as any } : {}, + ], + }, + }), + ]); return { success: true, - data: policies, + data: { policies, total }, }; } catch (error) { + console.error("Error fetching policies:", error); return { success: false, - error: "Failed to fetch policy statistics", + error: appErrors.UNEXPECTED_ERROR.message, }; } - }); + }); \ No newline at end of file diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/actions/get-policy.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/actions/get-policy.ts deleted file mode 100644 index 8f5ef40..0000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/actions/get-policy.ts +++ /dev/null @@ -1,58 +0,0 @@ -"use server"; - -import { authActionClient } from "@/actions/safe-action"; -import { db, type OrganizationPolicy } from "@bubba/db"; -import { z } from "zod"; - -const schema = z.object({ - policyId: z.string(), -}); - -export type PolicyStatsResponse = { - success: boolean; - data?: OrganizationPolicy[]; - error?: string; -}; - -export const getPolicy = authActionClient - .schema(schema) - .metadata({ - name: "get-policy", - track: { - event: "get-policy", - channel: "server", - }, - }) - .action(async ({ ctx, parsedInput }) => { - const { user } = ctx; - const { policyId } = parsedInput; - - if (!user.organizationId) { - return { - success: false, - error: "Not authorized - no organization found", - }; - } - - try { - const policy = await db.organizationPolicy.findFirst({ - where: { - organizationId: user.organizationId!, - policyId, - }, - include: { - policy: true, - }, - }); - - return { - success: true, - data: policy, - }; - } catch (error) { - return { - success: false, - error: "Failed to fetch policy statistics", - }; - } - }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/actions/get-policies.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/actions/get-policies.ts deleted file mode 100644 index 7fea1c5..0000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/actions/get-policies.ts +++ /dev/null @@ -1,64 +0,0 @@ -"use server"; - -import { authActionClient } from "@/actions/safe-action"; -import { db } from "@bubba/db"; -import { z } from "zod"; - -const schema = z.object({ - page: z.number().default(1), - perPage: z.number().default(10), -}); - -export const getPolicies = authActionClient - .schema(schema) - .metadata({ - name: "get-policies", - track: { - event: "get-policies", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - try { - const { user } = ctx; - const { page, perPage } = parsedInput; - - if (!user.organizationId) { - return { - success: false, - error: "User does not have an organization", - }; - } - - const [organizationPolicies, total] = await Promise.all([ - db.organizationPolicy.findMany({ - where: { - organizationId: user.organizationId, - }, - include: { - policy: true, - }, - skip: (page - 1) * perPage, - take: perPage, - }), - db.organizationPolicy.count({ - where: { - organizationId: user.organizationId, - }, - }), - ]); - - return { - success: true, - data: { - items: organizationPolicies, - total, - }, - }; - } catch (error) { - return { - success: false, - error: "Failed to fetch policies", - }; - } - }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/components/PoliciesTable.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/components/PoliciesTable.tsx deleted file mode 100644 index 217f67e..0000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/components/PoliciesTable.tsx +++ /dev/null @@ -1,64 +0,0 @@ -"use client"; - -import { DataTable } from "@/components/tables/policies/data-table"; -import { FilterToolbar } from "@/components/tables/policies/filter-toolbar"; -import { usePolicies } from "../hooks/usePolicies"; -import { useQueryState } from "nuqs"; -import { Skeleton } from "@bubba/ui/skeleton"; - -interface PoliciesTableProps { - columnHeaders: { - name: string; - lastUpdated: string; - status: string; - }; - users: Array<{ id: string; name: string | null }>; -} - -export function PoliciesTable({ columnHeaders, users }: PoliciesTableProps) { - const [search] = useQueryState("search"); - const [sort] = useQueryState("sort"); - const [status] = useQueryState("status", { defaultValue: "all" }); - const [page] = useQueryState("page", { defaultValue: "1" }); - const [perPage] = useQueryState("per_page", { defaultValue: "10" }); - - const { data, isLoading, error } = usePolicies({ - page: Number(page), - perPage: Number(perPage), - search: search || undefined, - sort: sort || undefined, - status: status || "all", - }); - - if (error) { - return ( -
- Failed to load policies: {error.message} -
- ); - } - - if (isLoading || !data) { - return ( -
- - -
- ); - } - - const pageCount = Math.ceil(data.total / Number(perPage)); - - return ( -
- - - -
- ); -} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/hooks/usePolicies.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/hooks/usePolicies.ts deleted file mode 100644 index bae4c46..0000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/hooks/usePolicies.ts +++ /dev/null @@ -1,97 +0,0 @@ -"use client"; - -import type { OrganizationPolicy, Policy } from "@bubba/db"; -import useSWR from "swr"; -import { getPolicies } from "../actions/get-policies"; - -const POLICIES_KEY = "policies"; - -interface PoliciesResponse { - items: Array; - total: number; -} - -interface UsePoliciesOptions { - page: number; - perPage: number; - search?: string; - sort?: string; - status?: string; -} - -async function fetchPolicies({ - page, - perPage, -}: UsePoliciesOptions): Promise { - const response = await getPolicies({ page, perPage }); - - if (!response?.data?.success || !response.data.data) { - throw new Error(response?.data?.error || "Failed to fetch policies"); - } - - return response.data.data; -} - -function filterAndSortPolicies( - data: PoliciesResponse | undefined, - { search, sort, status }: Partial -): PoliciesResponse | undefined { - if (!data) return undefined; - - let filteredItems = [...data.items]; - - // Apply search filter - if (search) { - const searchLower = search.toLowerCase(); - filteredItems = filteredItems.filter((item) => - item.policy.name.toLowerCase().includes(searchLower) - ); - } - - // Apply status filter - if (status && status !== "all") { - filteredItems = filteredItems.filter((item) => item.status === status); - } - - // Apply sorting - if (sort) { - const [field, direction] = sort.split(":"); - const multiplier = direction === "desc" ? -1 : 1; - - filteredItems.sort((a, b) => { - switch (field) { - case "name": - return multiplier * a.policy.name.localeCompare(b.policy.name); - case "lastUpdated": - return multiplier * (a.updatedAt.getTime() - b.updatedAt.getTime()); - default: - return 0; - } - }); - } - - return { - items: filteredItems, - total: data.total, - }; -} - -export function usePolicies(options: UsePoliciesOptions) { - const { data, error, isLoading, mutate } = useSWR( - [POLICIES_KEY, options.page, options.perPage], - () => fetchPolicies(options), - { - revalidateOnFocus: false, - revalidateOnReconnect: false, - } - ); - - const filteredData = filterAndSortPolicies(data, options); - - return { - data: filteredData, - isLoading, - error, - mutate, - }; -} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/layout.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/layout.tsx deleted file mode 100644 index 6df7964..0000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/layout.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { getI18n } from "@/locales/server"; -import { SecondaryMenu } from "@bubba/ui/secondary-menu"; -import { Suspense } from "react"; - -export default async function Layout({ - children, -}: { - children: React.ReactNode; -}) { - const t = await getI18n(); - - return ( -
- Loading...
}> - - - -
{children}
- - ); -} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/page.tsx deleted file mode 100644 index 4c9317e..0000000 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/page.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { auth } from "@/auth"; -import { getServerColumnHeaders } from "@/components/tables/policies/server-columns"; -import { getI18n } from "@/locales/server"; -import { db } from "@bubba/db"; -import { Skeleton } from "@bubba/ui/skeleton"; -import type { Metadata } from "next"; -import { setStaticParamsLocale } from "next-international/server"; -import { redirect } from "next/navigation"; -import { Suspense } from "react"; -import { PoliciesTable } from "./components/PoliciesTable"; - -export default async function PoliciesPage({ - params, -}: { - params: Promise<{ locale: string }>; -}) { - const { locale } = await params; - setStaticParamsLocale(locale); - - const session = await auth(); - - if (!session?.user?.organizationId) { - redirect("/onboarding"); - } - - const [columnHeaders, users] = await Promise.all([ - getServerColumnHeaders(), - db.user.findMany({ - where: { - organizationId: session.user.organizationId, - Artifact: { - some: {}, - }, - }, - select: { - id: true, - name: true, - }, - }), - ]); - - return ( - - - - - } - > - - - ); -} - -export async function generateMetadata({ - params, -}: { - params: Promise<{ locale: string; id: string }>; -}): Promise { - const { locale } = await params; - setStaticParamsLocale(locale); - const t = await getI18n(); - - return { - title: t("sub_pages.policies.all"), - }; -} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/components/PoliciesList.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/components/PoliciesList.tsx new file mode 100644 index 0000000..4425917 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/components/PoliciesList.tsx @@ -0,0 +1,93 @@ +"use client"; + +import type { PolicyType } from "@/components/tables/policies/columns"; +import { DataTable } from "@/components/tables/policies/data-table"; +import { + NoPolicies, + NoResults, +} from "@/components/tables/policies/empty-states"; +import { FilterToolbar } from "@/components/tables/policies/filter-toolbar"; +import { Loading } from "@/components/tables/policies/loading"; +import { useI18n } from "@/locales/client"; +import { Alert, AlertDescription, AlertTitle } from "@bubba/ui/alert"; +import { Button } from "@bubba/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@bubba/ui/card"; +import { AlertTriangle } from "lucide-react"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { usePolicies } from "../hooks/usePolicies"; +import { PoliciesListSkeleton } from "./PoliciesListSkeleton"; + +interface PoliciesListProps { + columnHeaders: { + name: string; + status: string; + updatedAt: string; + }; +} + +export function PoliciesList({ columnHeaders }: PoliciesListProps) { + const t = useI18n(); + const searchParams = useSearchParams(); + const search = searchParams.get("search"); + const status = searchParams.get("status"); + const per_page = Number(searchParams.get("per_page")) || 10; + const page = Number(searchParams.get("page")) || 1; + + const { policies, total, isLoading, error } = usePolicies(); + + if (isLoading) { + return ; + } + + if (error) { + return ( +
+ + + + {error.message || t("common.errors.unexpected_error")} + + +
+ ); + } + + const hasFilters = !!(search || status); + + if (policies.length === 0 && !hasFilters) { + return ( +
+
+

{t("policies.title")}

+ + + +
+ + + +
+ ); + } + + return ( +
+ + + {policies.length > 0 ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/components/PoliciesListSkeleton.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/components/PoliciesListSkeleton.tsx new file mode 100644 index 0000000..8ac48ec --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/components/PoliciesListSkeleton.tsx @@ -0,0 +1,74 @@ +import { Skeleton } from "@bubba/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@bubba/ui/table"; + +export const PoliciesListSkeleton = () => { + return ( +
+
+ + +
+ +
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + {Array.from({ length: 5 }).map((_, index) => ( + + + + + + + + + + + + + + + ))} + +
+
+ +
+ +
+ + + + +
+
+
+ ); +}; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/hooks/usePolicies.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/hooks/usePolicies.ts index d13a075..8650933 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/hooks/usePolicies.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/hooks/usePolicies.ts @@ -1,25 +1,52 @@ "use client"; -import type { OrganizationPolicy } from "@bubba/db"; +import { useSearchParams } from "next/navigation"; +import { useCallback, useState } from "react"; import useSWR from "swr"; + import { getPolicies } from "../actions/get-policies"; -const POLICIES_OVERVIEW_KEY = "policies-overview"; +import type { AppError, PoliciesInput, PoliciesResponse } from "../types"; + +async function fetchPolicies( + input: PoliciesInput +): Promise { + const result = await getPolicies(input); -async function fetchPolicies(): Promise { - const response = await getPolicies({}); + if (!result) { + const error: AppError = { + code: "UNEXPECTED_ERROR", + message: "An unexpected error occurred", + }; + throw error; + } - if (!response?.data?.success || !response.data.data) { - throw new Error(response?.data?.error || "Failed to fetch policy"); + if (result.serverError) { + const error: AppError = { + code: "UNEXPECTED_ERROR", + message: result.serverError || "An unexpected error occurred", + }; + throw error; } - return response.data.data; + return result.data?.data as PoliciesResponse; } export function usePolicies() { - const { data, error, isLoading, mutate } = useSWR( - [POLICIES_OVERVIEW_KEY], - fetchPolicies, + const searchParams = useSearchParams(); + const search = searchParams.get("search") || undefined; + const status = searchParams.get("status") || undefined; + const page = Number(searchParams.get("page")) || 1; + const per_page = Number(searchParams.get("per_page")) || 10; + + const { + data, + error, + isLoading, + mutate: revalidatePolicies, + } = useSWR( + ["policies", { search, status, page, per_page }], + () => fetchPolicies({ search, status, page, per_page }), { revalidateOnFocus: false, revalidateOnReconnect: false, @@ -27,9 +54,10 @@ export function usePolicies() { ); return { - data, + policies: data?.policies ?? [], + total: data?.total ?? 0, isLoading, error, - mutate, + revalidatePolicies, }; -} +} \ No newline at end of file diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/hooks/usePolicy.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/hooks/usePolicy.ts index 5369fbb..ee95b8f 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/hooks/usePolicy.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/hooks/usePolicy.ts @@ -1,42 +1,125 @@ "use client"; -import type { OrganizationPolicy, Policy } from "@bubba/db"; import useSWR from "swr"; -import { getPolicy } from "../actions/get-policy"; +import { getPolicyDetails } from "../[policyId]/actions/get-policy-details"; +import { updatePolicy } from "../[policyId]/actions/update-policy"; +import type { AppError, PolicyDetails, UpdatePolicyInput } from "../[policyId]/types"; -const POLICY_KEY = "policy"; - -type OrganizationPolicyWithPolicy = OrganizationPolicy & { - policy: Policy; -}; +interface ContentNode { + type: string; + content?: ContentNode[]; + text?: string; + attrs?: Record; + marks?: Array<{ type: string; attrs?: Record }>; + [key: string]: any; +} -async function fetchPolicy( +async function fetchPolicyDetails( policyId: string -): Promise { - const response = await getPolicy({ policyId }); +): Promise { + const result = await getPolicyDetails({ + policyId, + _cache: Date.now() + }); + + if (!result) { + const error: AppError = { + code: "UNEXPECTED_ERROR", + message: "An unexpected error occurred", + }; + throw error; + } - if (!response?.data?.success || !response.data.data) { - throw new Error(response?.data?.error || "Failed to fetch policy data"); + if (result.serverError) { + const error: AppError = { + code: "UNEXPECTED_ERROR", + message: result.serverError || "An unexpected error occurred", + }; + throw error; } - return response.data.data; + return result.data?.data as PolicyDetails; } -export function usePolicy({ policyId }: { policyId: string }) { - const { data, error, isLoading, mutate } = - useSWR( - [POLICY_KEY, policyId], - () => fetchPolicy(policyId), - { - revalidateOnFocus: false, - revalidateOnReconnect: false, - } - ); +export function usePolicyDetails(policyId: string) { + const { data, error, isLoading, mutate } = useSWR( + ["policy-details", policyId], + () => fetchPolicyDetails(policyId), + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + revalidateIfStale: true, + dedupingInterval: 0 + } + ); + + const updatePolicyContent = async (updateData: Partial) => { + try { + const { content, status } = updateData; + const processedContent = JSON.parse( + JSON.stringify(processContent(content as unknown as ContentNode)), + ); + + const result = await updatePolicy({ + policyId, + content: processedContent || undefined, + status: status || undefined, + }); + + await mutate(fetchPolicyDetails(policyId), { + revalidate: true, + populateCache: true, + rollbackOnError: false + }); + + return true; + } catch (error) { + console.error("Error updating policy:", error); + return false; + } + }; return { - data, + policy: data, isLoading, error, - mutate, + updatePolicy: updatePolicyContent, + refreshPolicy: () => mutate(fetchPolicyDetails(policyId), true) + }; +} + +function processContent( + content: ContentNode | ContentNode[], +): ContentNode | ContentNode[] { + if (!content) return content; + + // Handle arrays + if (Array.isArray(content)) { + return content.map((node) => processContent(node) as ContentNode); + } + + const processed: ContentNode = { + type: content.type, }; + + if (content.text !== undefined) { + processed.text = content.text; + } + + if (content.attrs) { + processed.attrs = { ...content.attrs }; + } + + if (content.marks) { + processed.marks = content.marks.map((mark) => ({ + type: mark.type, + ...(mark.attrs && { attrs: { ...mark.attrs } }), + })); + } + + if (content.content) { + processed.content = processContent(content.content) as ContentNode[]; + } + + return processed; } diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/(overview)/layout.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/layout.tsx similarity index 64% rename from apps/app/src/app/[locale]/(app)/(dashboard)/policies/(overview)/layout.tsx rename to apps/app/src/app/[locale]/(app)/(dashboard)/policies/layout.tsx index a68a9c8..4c5252c 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/(overview)/layout.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/layout.tsx @@ -1,6 +1,5 @@ import { getI18n } from "@/locales/server"; import { SecondaryMenu } from "@bubba/ui/secondary-menu"; -import { Suspense } from "react"; export default async function Layout({ children, @@ -12,10 +11,7 @@ export default async function Layout({ return (
{children}
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/(overview)/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/page.tsx similarity index 61% rename from apps/app/src/app/[locale]/(app)/(dashboard)/policies/(overview)/page.tsx rename to apps/app/src/app/[locale]/(app)/(dashboard)/policies/page.tsx index cab6584..c02da5d 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/(overview)/page.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/page.tsx @@ -1,11 +1,12 @@ import { auth } from "@/auth"; +import { getServerColumnHeaders } from "@/components/tables/policies/server-columns"; import { getI18n } from "@/locales/server"; import type { Metadata } from "next"; import { setStaticParamsLocale } from "next-international/server"; import { redirect } from "next/navigation"; -import { PoliciesOverview } from "./components/PoliciesOverview"; +import { PoliciesList } from "./components/PoliciesList"; -export default async function PoliciesOverviewPage({ +export default async function PoliciesPage({ params, }: { params: Promise<{ locale: string }>; @@ -14,12 +15,15 @@ export default async function PoliciesOverviewPage({ setStaticParamsLocale(locale); const session = await auth(); + const organizationId = session?.user.organizationId; - if (!session?.user?.organizationId) { - redirect("/onboarding"); + if (!organizationId) { + return redirect("/"); } - return ; + const columnHeaders = await getServerColumnHeaders(); + + return ; } export async function generateMetadata({ @@ -28,6 +32,7 @@ export async function generateMetadata({ params: Promise<{ locale: string }>; }): Promise { const { locale } = await params; + setStaticParamsLocale(locale); const t = await getI18n(); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/types/index.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/types/index.ts new file mode 100644 index 0000000..ff36bd8 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/types/index.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; + +export const policySchema = z.object({ + id: z.string(), + status: z.enum(["draft", "published", "archived"]), + createdAt: z.date(), + updatedAt: z.date(), + policy: z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + slug: z.string(), + }), +}); + +export const policiesInputSchema = z.object({ + search: z.string().optional(), + status: z.string().optional(), + page: z.number().default(1), + per_page: z.number().default(10), +}); + +export type Policy = z.infer; +export type PoliciesInput = z.infer; + +export interface PoliciesResponse { + policies: Policy[]; + total: number; +} + +export type AppError = { + code: "UNAUTHORIZED" | "UNEXPECTED_ERROR"; + message: string; +}; + +export const appErrors = { + UNAUTHORIZED: { + code: "UNAUTHORIZED" as const, + message: "You are not authorized to view policies", + }, + UNEXPECTED_ERROR: { + code: "UNEXPECTED_ERROR" as const, + message: "An unexpected error occurred", + }, +} as const; \ No newline at end of file diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/types/search-params.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/types/search-params.ts new file mode 100644 index 0000000..aea52eb --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/types/search-params.ts @@ -0,0 +1,11 @@ +import { + createSearchParamsCache, + parseAsInteger, + parseAsString, +} from "nuqs/server"; + +export const searchParamsCache = createSearchParamsCache({ + q: parseAsString, + page: parseAsInteger.withDefault(0), + status: parseAsString, +}); \ No newline at end of file diff --git a/apps/app/src/components/editor/actions/ai.ts b/apps/app/src/components/editor/actions/ai.ts deleted file mode 100644 index fe460f0..0000000 --- a/apps/app/src/components/editor/actions/ai.ts +++ /dev/null @@ -1,17 +0,0 @@ -"use server"; - -import { openai } from "@ai-sdk/openai"; -import { type CoreMessage, streamText } from "ai"; -import { createStreamableValue } from "ai/rsc"; - -// Send messages to AI and stream a result back -export async function continueConversation(messages: CoreMessage[]) { - const result = await streamText({ - model: openai("gpt-4o-mini"), - messages, - }); - - const stream = createStreamableValue(result.textStream); - return stream.value; -} - diff --git a/apps/app/src/components/editor/editor.tsx b/apps/app/src/components/editor/editor.tsx deleted file mode 100644 index dec927b..0000000 --- a/apps/app/src/components/editor/editor.tsx +++ /dev/null @@ -1,22 +0,0 @@ -"use client"; - -import { EditorContent, type JSONContent, useEditor } from "@tiptap/react"; -import StarterKit from "@tiptap/starter-kit"; -import { useEffect, useState } from "react"; - -const Tiptap = ({ content }: { content: JSONContent }) => { - const [editorState, setEditorState] = useState(null); - - const editor = useEditor({ - extensions: [StarterKit], - content: content, - }); - - useEffect(() => { - setEditorState(JSON.parse(editor?.getText() || "{}")); - }, [editor]); - - return ; -}; - -export default Tiptap; diff --git a/apps/app/src/components/editor/policy-editor.tsx b/apps/app/src/components/editor/policy-editor.tsx new file mode 100644 index 0000000..f54e29a --- /dev/null +++ b/apps/app/src/components/editor/policy-editor.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { Button } from "@bubba/ui/button"; +import { cn } from "@bubba/ui/cn"; +import Table from "@tiptap/extension-table"; +import TableCell from "@tiptap/extension-table-cell"; +import TableHeader from "@tiptap/extension-table-header"; +import TableRow from "@tiptap/extension-table-row"; +import { BubbleMenu, EditorContent, useEditor } from "@tiptap/react"; +import type { JSONContent } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import { Bold, Italic, ListOrdered, ListTree, Save } from "lucide-react"; +import { useState } from "react"; +import { useDebounce } from "use-debounce"; + +interface PolicyEditorProps { + policyId: string; + content: JSONContent[]; + readOnly?: boolean; +} + +export function PolicyEditor({ + policyId, + content, + readOnly = false, +}: PolicyEditorProps) { + const [editorContent, setEditorContent] = useState(content); + const [isSaving, setIsSaving] = useState(false); + const [isDirty, setIsDirty] = useState(false); + + const debouncedContent = useDebounce(editorContent, 1000); + + const documentContent = { + type: "doc", + content: Array.isArray(content) ? content : [], + }; + + const editor = useEditor({ + extensions: [ + StarterKit, + Table.configure({ + resizable: true, + }), + TableRow, + TableHeader, + TableCell, + ], + content: documentContent, + editable: !readOnly, + editorProps: { + attributes: { + class: + "prose prose-sm dark:prose-invert max-w-none p-4 focus:outline-none min-h-[300px]", + }, + }, + onUpdate: ({ editor }) => { + try { + const json = editor.getJSON().content; + if (json) { + setEditorContent(json as JSONContent[]); + setIsDirty(true); + } + } catch (error) { + console.error("Error updating editor content:", error); + } + }, + }); + + if (!editor) { + return ( +
+ Loading editor... +
+ ); + } + + return ( +
+ {!readOnly && ( +
+ +
+ )} + + {editor && !readOnly && ( + +
+ + + + +
+
+ )} + + + + {isDirty && !readOnly && ( +
+ {isSaving ? "Saving..." : "Unsaved changes"} +
+ )} +
+ ); +} diff --git a/apps/app/src/components/policies/charts/policies-by-framework.tsx b/apps/app/src/components/policies/charts/policies-by-framework.tsx index 2c13fa9..0b0ef6e 100644 --- a/apps/app/src/components/policies/charts/policies-by-framework.tsx +++ b/apps/app/src/components/policies/charts/policies-by-framework.tsx @@ -13,7 +13,7 @@ import { Bar, BarChart, Cell, LabelList, XAxis, YAxis } from "recharts"; export function PoliciesByFramework() { const t = useI18n(); - const { data } = usePolicies(); + const { policies } = usePolicies(); const config = { "SOC 2": { label: "SOC 2" }, @@ -22,7 +22,7 @@ export function PoliciesByFramework() { default: { label: "Other" }, } satisfies ChartConfig; - if (!data?.length) { + if (!policies?.length) { return ( @@ -43,7 +43,7 @@ export function PoliciesByFramework() { - {data.map((entry) => ( - + {policies.map((policy) => ( + ))} diff --git a/apps/app/src/components/policies/policy-overview.tsx b/apps/app/src/components/policies/policy-overview.tsx deleted file mode 100644 index 4b53d44..0000000 --- a/apps/app/src/components/policies/policy-overview.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client"; - -import { publishPolicy } from "@/app/[locale]/(app)/(dashboard)/policies/[id]/actions/publish-policy"; -import { usePolicy } from "@/app/[locale]/(app)/(dashboard)/policies/hooks/usePolicy"; -import { Button } from "@bubba/ui/button"; -import { Separator } from "@bubba/ui/separator"; -import { useAction } from "next-safe-action/hooks"; -import type { JSONContent } from "novel"; -import { toast } from "sonner"; - -export function PolicyOverview({ policyId }: { policyId: string }) { - const { data: policy } = usePolicy({ policyId }); - - const { execute, isExecuting } = useAction( - () => publishPolicy({ id: policyId }), - { - onSuccess: () => { - toast.success("Policy published successfully"); - }, - }, - ); - - if (!policy) return null; - - const content = policy.content as JSONContent; - - if (!content) return null; - - return ( -
-
- -
- -
-
-
- Editor -
-
-
-
- ); -} diff --git a/apps/app/src/components/policies/policy-status.tsx b/apps/app/src/components/status-policies.tsx similarity index 51% rename from apps/app/src/components/policies/policy-status.tsx rename to apps/app/src/components/status-policies.tsx index 9adc1a8..7c8676c 100644 --- a/apps/app/src/components/policies/policy-status.tsx +++ b/apps/app/src/components/status-policies.tsx @@ -1,25 +1,32 @@ import { useI18n } from "@/locales/client"; import { cn } from "@bubba/ui/cn"; -export const STATUS_TYPES = ["draft", "published"] as const; +export const STATUS_TYPES = [ + "draft", + "published", + "archived", + "needs_review", +] as const; export type StatusType = (typeof STATUS_TYPES)[number]; const STATUS_COLORS: Record = { draft: "#ffc107", - published: "#22c55e", + published: "#00DC73", + archived: "#0ea5e9", + needs_review: "#ff0000", } as const; -export function PolicyStatus({ status }: { status: StatusType }) { +export function StatusPolicies({ status }: { status: StatusType }) { const t = useI18n(); return (
- {t(`policies.table.statuses.${status}`)} + {t(`policies.status.${status}`)}
); } diff --git a/apps/app/src/components/tables/policies/columns.tsx b/apps/app/src/components/tables/policies/columns.tsx index 523a322..98440c9 100644 --- a/apps/app/src/components/tables/policies/columns.tsx +++ b/apps/app/src/components/tables/policies/columns.tsx @@ -1,100 +1,70 @@ "use client"; -import { AssignedUser } from "@/components/assigned-user"; -import { - PolicyStatus, - type StatusType, -} from "@/components/policies/policy-status"; -import { StatusDate } from "@/components/status-date"; -import { useI18n } from "@/locales/client"; -import type { OrganizationPolicy, Policy } from "@bubba/db"; +import { StatusPolicies, type StatusType } from "@/components/status-policies"; +import { formatDate } from "@/utils/format"; import { Button } from "@bubba/ui/button"; import type { ColumnDef } from "@tanstack/react-table"; import Link from "next/link"; -export interface PolicyType extends OrganizationPolicy { - policy: Policy; -} +export type PolicyType = { + id: string; + policy: { + id: string; + name: string; + description: string | null; + slug: string; + }; + status: "draft" | "published" | "archived"; + createdAt: string; + updatedAt: string; +}; export function columns(): ColumnDef[] { - const t = useI18n(); - return [ { id: "name", - accessorKey: "name", - header: t("policies.table.name"), + accessorKey: "policy.name", cell: ({ row }) => { - const status = - row.original.status === "published" ? "published" : "draft"; + const name = row.original.policy.name; + const id = row.original.id; + const status = row.original.status; return (
- +
); }, }, { - id: "published", - accessorKey: "published", - header: () => ( - {t("common.table.status")} - ), + id: "status", + accessorKey: "status", cell: ({ row }) => { - const status = - row.original.status === "published" ? "published" : "draft"; + const status = row.original.status; return (
- -
- ); - }, - }, - { - id: "ownerId", - accessorKey: "ownerId", - header: () => ( - - {t("common.table.assigned_to")} - - ), - cell: ({ row }) => { - return ( -
- {/* */} -

Nothing for now

+
); }, }, { - id: "lastUpdated", - accessorKey: "lastUpdated", - header: () => ( - - {t("common.table.last_updated")} - - ), + id: "updatedAt", + accessorKey: "updatedAt", cell: ({ row }) => { const date = row.original.updatedAt; return ( -
- +
+ {formatDate(date, "MMM d, yyyy")}
); }, diff --git a/apps/app/src/components/tables/policies/data-table-header.tsx b/apps/app/src/components/tables/policies/data-table-header.tsx index 8069835..4532b82 100644 --- a/apps/app/src/components/tables/policies/data-table-header.tsx +++ b/apps/app/src/components/tables/policies/data-table-header.tsx @@ -72,53 +72,34 @@ export function DataTableHeader({ table, loading }: Props) { )} - {isVisible("published") && ( + {isVisible("status") && ( )} - {isVisible("ownerId") && ( + {isVisible("updatedAt") && ( - - )} - {isVisible("lastUpdated") && ( - - diff --git a/apps/app/src/components/tables/policies/data-table.tsx b/apps/app/src/components/tables/policies/data-table.tsx index c95c84e..2e32dfe 100644 --- a/apps/app/src/components/tables/policies/data-table.tsx +++ b/apps/app/src/components/tables/policies/data-table.tsx @@ -1,13 +1,15 @@ "use client"; -import { Table, TableBody, TableCell, TableRow } from "@bubba/ui/table"; import { + type ColumnDef, flexRender, getCoreRowModel, useReactTable, } from "@tanstack/react-table"; import { Suspense } from "react"; -import { cn } from "../../../../../../packages/ui/src/utils"; + +import { cn } from "@bubba/ui/cn"; +import { Table, TableBody, TableCell, TableRow } from "@bubba/ui/table"; import { type PolicyType, columns as getColumns } from "./columns"; import { DataTableHeader } from "./data-table-header"; import { DataTablePagination } from "./data-table-pagination"; @@ -16,8 +18,8 @@ import { Loading } from "./loading"; interface DataTableProps { columnHeaders: { name: string; - lastUpdated: string; status: string; + updatedAt: string; }; data: TData[]; pageCount: number; @@ -61,16 +63,15 @@ export function DataTable({ {flexRender( cell.column.columnDef.cell, - cell.getContext() + cell.getContext(), )} ))} @@ -82,7 +83,7 @@ export function DataTable({ colSpan={columns.length} className="h-24 text-center" > - No policies found. + No results. )} diff --git a/apps/app/src/components/tables/policies/empty-states.tsx b/apps/app/src/components/tables/policies/empty-states.tsx new file mode 100644 index 0000000..f08e71c --- /dev/null +++ b/apps/app/src/components/tables/policies/empty-states.tsx @@ -0,0 +1,46 @@ +import { useI18n } from "@/locales/client"; +import { Button } from "@bubba/ui/button"; +import { Card, CardContent } from "@bubba/ui/card"; +import { FileText } from "lucide-react"; +import Link from "next/link"; + +export function NoPolicies() { + const t = useI18n(); + + return ( + + + +

+ {t("policies.no_policies_title")} +

+

+ {t("policies.no_policies_description")} +

+ + + +
+
+ ); +} + +export function NoResults({ hasFilters }: { hasFilters: boolean }) { + const t = useI18n(); + + return ( + + + +

+ {t("common.empty_states.no_results.title")} +

+

+ {hasFilters + ? t("common.empty_states.no_results.description_filters") + : t("common.empty_states.no_results.description")} +

+
+
+ ); +} diff --git a/apps/app/src/components/tables/policies/filter-toolbar.tsx b/apps/app/src/components/tables/policies/filter-toolbar.tsx index de27a43..ab97b13 100644 --- a/apps/app/src/components/tables/policies/filter-toolbar.tsx +++ b/apps/app/src/components/tables/policies/filter-toolbar.tsx @@ -12,20 +12,21 @@ import { SelectValue, } from "@bubba/ui/select"; import { Skeleton } from "@bubba/ui/skeleton"; -import { Search, X } from "lucide-react"; +import { Plus, Search, X } from "lucide-react"; +import Link from "next/link"; import { useQueryState } from "nuqs"; import { useTransition } from "react"; import { useCallback } from "react"; -type Props = { +interface FilterToolbarProps { isEmpty?: boolean; users: { id: string; name: string | null; }[]; -}; +} -export function FilterToolbar({ isEmpty, users }: Props) { +export function FilterToolbar({ isEmpty = false, users }: FilterToolbarProps) { const t = useI18n(); const [isPending, startTransition] = useTransition(); @@ -57,9 +58,17 @@ export function FilterToolbar({ isEmpty, users }: Props) { const hasFilters = search || status || ownerId; + const handleStatusChange = (value: string) => { + setStatus(value === "all" ? null : value); + }; + if (isEmpty) { return (
+
+ +
+
-
- - setSearch(e.target.value || null)} - /> +
+
+
+ + setSearch(e.target.value || null)} + /> +
+ +
+ + + +
-
- + + - {t("policies.filters.all")} + {t("policies.all_statuses")} + {t("common.status.draft")} {t("common.status.published")} - - {t("common.status.needs_review")} + + {t("common.status.archived")} - {t("common.status.draft")} + -
- {hasFilters && ( - - )} + {hasFilters && ( + + )} + + + + +
); } diff --git a/apps/app/src/components/tables/policies/loading.tsx b/apps/app/src/components/tables/policies/loading.tsx index cd9852c..9194d38 100644 --- a/apps/app/src/components/tables/policies/loading.tsx +++ b/apps/app/src/components/tables/policies/loading.tsx @@ -25,11 +25,21 @@ export function Loading({ isEmpty }: { isEmpty: boolean }) { className={cn("h-3.5 w-[80%]", isEmpty && "animate-none")} /> - + + + + + + + ))} diff --git a/apps/app/src/components/tables/policies/server-columns.tsx b/apps/app/src/components/tables/policies/server-columns.tsx index cca2710..5cf03d0 100644 --- a/apps/app/src/components/tables/policies/server-columns.tsx +++ b/apps/app/src/components/tables/policies/server-columns.tsx @@ -5,8 +5,7 @@ export async function getServerColumnHeaders() { return { name: t("policies.table.name"), - lastUpdated: t("common.table.last_updated"), - status: t("common.table.status"), - ownerId: t("common.table.assigned_to"), + status: t("common.status.title"), + updatedAt: t("common.last_updated"), }; } diff --git a/apps/app/src/locales/en.ts b/apps/app/src/locales/en.ts index 42c126a..f5cfa3f 100644 --- a/apps/app/src/locales/en.ts +++ b/apps/app/src/locales/en.ts @@ -57,6 +57,7 @@ export default { assessed: "Assessed", active: "Active", inactive: "Inactive", + title: "Status", }, filters: { clear: "Clear filters", @@ -78,7 +79,7 @@ export default { }, empty_states: { no_results: { - title: "No results", + title: "No results found", title_tasks: "No tasks found", title_risks: "No risks found", description: "Try another search, or adjusting the filters", @@ -146,6 +147,12 @@ export default { remove_file: "Remove file", }, }, + edit: "Edit", + errors: { + unexpected_error: "An unexpected error occurred" + }, + description: "Description", + last_updated: "Last Updated", }, header: { discord: { @@ -196,6 +203,7 @@ export default { policies: { all: "All Policies", editor: "Policy Editor", + policy_details: "Policy Details" }, people: { all: "People", @@ -277,6 +285,7 @@ export default { statuses: { draft: "Draft", published: "Published", + archived: "Archived", }, filters: { owner: { @@ -293,8 +302,23 @@ export default { draft: "Draft", published: "Published", needs_review: "Needs Review", + archived: "Archived", }, policies: "policies", + title: "Policies", + create_new: "Create New Policy", + search_placeholder: "Search policies...", + status_filter: "Filter by status", + all_statuses: "All statuses", + no_policies_title: "No policies yet", + no_policies_description: "Get started by creating your first policy", + create_first: "Create first policy", + no_description: "No description provided", + last_updated: "Last updated: {{date}}", + save: "Save", + saving: "Saving...", + saved_success: "Policy saved successfully", + saved_error: "Failed to save policy" }, evidence_tasks: { evidence_tasks: "Evidence Tasks", diff --git a/apps/app/src/locales/es.ts b/apps/app/src/locales/es.ts index efd9d8a..1f1d299 100644 --- a/apps/app/src/locales/es.ts +++ b/apps/app/src/locales/es.ts @@ -50,7 +50,8 @@ export default { not_assessed: "No Evaluado", assessed: "Evaluado", active: "Activo", - inactive: "Inactivo" + inactive: "Inactivo", + title: "Estado" }, filters: { clear: "Limpiar filtros", @@ -72,7 +73,7 @@ export default { }, empty_states: { no_results: { - title: "Sin resultados", + title: "No se encontraron resultados", title_tasks: "No se encontraron tareas", title_risks: "No se encontraron riesgos", description: "Intenta otra búsqueda o ajusta los filtros", @@ -143,7 +144,13 @@ export default { archive: "Archivo", archive_all: "Archivar todo", no_notifications: "No hay nuevas notificaciones" - } + }, + edit: "Editar", + errors: { + unexpected_error: "Ocurrió un error inesperado" + }, + description: "Descripción", + last_updated: "Última actualización" }, header: { discord: { @@ -247,7 +254,8 @@ export default { name: "Nombre de la Política", statuses: { draft: "Borrador", - published: "Publicado" + published: "Publicado", + archived: "Archivado" }, filters: { owner: { @@ -263,9 +271,24 @@ export default { status: { draft: "Borrador", published: "Publicado", - needs_review: "Necesita Revisión" - }, - policies: "políticas" + needs_review: "Necesita Revisión", + archived: "Archivado" + }, + policies: "políticas", + title: "Políticas", + create_new: "Crear nueva política", + search_placeholder: "Buscar políticas...", + status_filter: "Filtrar por estado", + all_statuses: "Todos los estados", + no_policies_title: "No hay políticas aún", + no_policies_description: "Comienza creando tu primera política", + create_first: "Crear primera política", + no_description: "No se proporcionó descripción", + last_updated: "Última actualización: {{date}}", + save: "Guardar", + saving: "Guardando...", + saved_success: "Política guardada con éxito", + saved_error: "Error al guardar la política" }, evidence_tasks: { evidence_tasks: "Tareas de Evidencia", @@ -682,7 +705,8 @@ export default { }, policies: { all: "Todas las Políticas", - editor: "Editor de Políticas" + editor: "Editor de Políticas", + policy_details: "Detalles de la política" }, people: { all: "Personas", diff --git a/apps/app/src/locales/fr.ts b/apps/app/src/locales/fr.ts index af3787a..339db08 100644 --- a/apps/app/src/locales/fr.ts +++ b/apps/app/src/locales/fr.ts @@ -50,7 +50,8 @@ export default { not_assessed: "Non évalué", assessed: "Évalué", active: "Actif", - inactive: "Inactif" + inactive: "Inactif", + title: "Statut" }, filters: { clear: "Effacer les filtres", @@ -72,7 +73,7 @@ export default { }, empty_states: { no_results: { - title: "Aucun résultat", + title: "Aucun résultat trouvé", title_tasks: "Aucune tâche trouvée", title_risks: "Aucun risque trouvé", description: "Essayez une autre recherche ou ajustez les filtres", @@ -143,7 +144,13 @@ export default { archive: "Archive", archive_all: "Archiver tout", no_notifications: "Aucune nouvelle notification" - } + }, + edit: "Modifier", + errors: { + unexpected_error: "Une erreur inattendue est survenue" + }, + description: "Description", + last_updated: "Dernière mise à jour" }, header: { discord: { @@ -247,7 +254,8 @@ export default { name: "Nom de la politique", statuses: { draft: "Brouillon", - published: "Publié" + published: "Publié", + archived: "Archivé" }, filters: { owner: { @@ -263,9 +271,24 @@ export default { status: { draft: "Brouillon", published: "Publié", - needs_review: "Nécessite une révision" - }, - policies: "politiques" + needs_review: "Nécessite une révision", + archived: "Archivé" + }, + policies: "politiques", + title: "Politiques", + create_new: "Créer une nouvelle politique", + search_placeholder: "Rechercher des politiques...", + status_filter: "Filtrer par statut", + all_statuses: "Tous les statuts", + no_policies_title: "Aucune politique pour le moment", + no_policies_description: "Commencez par créer votre première politique", + create_first: "Créer la première politique", + no_description: "Aucune description fournie", + last_updated: "Dernière mise à jour : {{date}}", + save: "Sauvegarder", + saving: "Sauvegarde en cours...", + saved_success: "Politique sauvegardée avec succès", + saved_error: "Échec de la sauvegarde de la politique" }, evidence_tasks: { evidence_tasks: "Tâches de preuve", @@ -682,7 +705,8 @@ export default { }, policies: { all: "Toutes les Politiques", - editor: "Éditeur de Politique" + editor: "Éditeur de Politique", + policy_details: "Détails de la politique" }, people: { all: "Personnes", diff --git a/apps/app/src/locales/no.ts b/apps/app/src/locales/no.ts index d7e013d..48138df 100644 --- a/apps/app/src/locales/no.ts +++ b/apps/app/src/locales/no.ts @@ -50,7 +50,8 @@ export default { not_assessed: "Ikke vurdert", assessed: "Vurdert", active: "Aktiv", - inactive: "Inaktiv" + inactive: "Inaktiv", + title: "Status" }, filters: { clear: "Tøm filtre", @@ -72,7 +73,7 @@ export default { }, empty_states: { no_results: { - title: "Ingen resultater", + title: "Ingen resultater funnet", title_tasks: "Ingen oppgaver funnet", title_risks: "Ingen risikoer funnet", description: "Prøv et annet søk, eller juster filtrene", @@ -143,7 +144,13 @@ export default { archive: "Arkiv", archive_all: "Arkiver alt", no_notifications: "Ingen nye varsler" - } + }, + edit: "Rediger", + errors: { + unexpected_error: "En uventet feil oppstod" + }, + description: "Beskrivelse", + last_updated: "Sist oppdatert" }, header: { discord: { @@ -247,7 +254,8 @@ export default { name: "Retningslinjenavn", statuses: { draft: "Utkast", - published: "Publisert" + published: "Publisert", + archived: "Arkivert" }, filters: { owner: { @@ -263,9 +271,24 @@ export default { status: { draft: "Utkast", published: "Publisert", - needs_review: "Trenger gjennomgang" - }, - policies: "retningslinjer" + needs_review: "Trenger gjennomgang", + archived: "Arkivert" + }, + policies: "retningslinjer", + title: "Retningslinjer", + create_new: "Opprett ny retningslinje", + search_placeholder: "Søk etter retningslinjer...", + status_filter: "Filtrer etter status", + all_statuses: "Alle statuser", + no_policies_title: "Ingen retningslinjer ennå", + no_policies_description: "Kom i gang med å opprette din første retningslinje", + create_first: "Opprett første retningslinje", + no_description: "Ingen beskrivelse gitt", + last_updated: "Sist oppdatert: {{date}}", + save: "Lagre", + saving: "Lagrer...", + saved_success: "Retningslinje lagret", + saved_error: "Kunne ikke lagre retningslinje" }, evidence_tasks: { evidence_tasks: "Bevisoppgaver", @@ -682,7 +705,8 @@ export default { }, policies: { all: "Alle retningslinjer", - editor: "Retningslinje-redigerer" + editor: "Retningslinje-redigerer", + policy_details: "Policy Detaljer" }, people: { all: "Personer", diff --git a/apps/app/src/locales/pt.ts b/apps/app/src/locales/pt.ts index bcc03ca..5617a11 100644 --- a/apps/app/src/locales/pt.ts +++ b/apps/app/src/locales/pt.ts @@ -50,7 +50,8 @@ export default { not_assessed: "Não Avaliado", assessed: "Avaliado", active: "Ativo", - inactive: "Inativo" + inactive: "Inativo", + title: "Status" }, filters: { clear: "Limpar filtros", @@ -72,7 +73,7 @@ export default { }, empty_states: { no_results: { - title: "Nenhum resultado", + title: "Nenhum resultado encontrado", title_tasks: "Nenhuma tarefa encontrada", title_risks: "Nenhum risco encontrado", description: "Tente outra pesquisa ou ajuste os filtros", @@ -143,7 +144,13 @@ export default { archive: "Arquivo", archive_all: "Arquivar tudo", no_notifications: "Sem novas notificações" - } + }, + edit: "Editar", + errors: { + unexpected_error: "Ocorreu um erro inesperado" + }, + description: "Descrição", + last_updated: "Última atualização" }, header: { discord: { @@ -247,7 +254,8 @@ export default { name: "Nome da Política", statuses: { draft: "Rascunho", - published: "Publicado" + published: "Publicado", + archived: "Arquivado" }, filters: { owner: { @@ -263,9 +271,24 @@ export default { status: { draft: "Rascunho", published: "Publicado", - needs_review: "Necessita Revisão" - }, - policies: "políticas" + needs_review: "Necessita Revisão", + archived: "Arquivado" + }, + policies: "políticas", + title: "Políticas", + create_new: "Criar nova política", + search_placeholder: "Pesquisar políticas...", + status_filter: "Filtrar por status", + all_statuses: "Todos os status", + no_policies_title: "Nenhuma política ainda", + no_policies_description: "Comece criando sua primeira política", + create_first: "Criar primeira política", + no_description: "Nenhuma descrição fornecida", + last_updated: "Última atualização: {{date}}", + save: "Salvar", + saving: "Salvando...", + saved_success: "Política salva com sucesso", + saved_error: "Falha ao salvar a política" }, evidence_tasks: { evidence_tasks: "Tarefas de Evidência", @@ -682,7 +705,8 @@ export default { }, policies: { all: "Todas as Políticas", - editor: "Editor de Políticas" + editor: "Editor de Políticas", + policy_details: "Detalhes da política" }, people: { all: "Pessoas", diff --git a/apps/app/src/types/actions.ts b/apps/app/src/types/actions.ts deleted file mode 100644 index f35dcf3..0000000 --- a/apps/app/src/types/actions.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface ActionResponse { - success: boolean; - error?: string; - data?: unknown; -} diff --git a/apps/app/src/utils/format.ts b/apps/app/src/utils/format.ts index f09d13e..f030545 100644 --- a/apps/app/src/utils/format.ts +++ b/apps/app/src/utils/format.ts @@ -82,7 +82,7 @@ export function calculateAvgBurnRate(data: BurnRateData[] | null) { export function formatDate(date: string, dateFormat?: string) { if (isSameYear(new Date(), new Date(date))) { - return format(new Date(date), "MMM d"); + return format(new Date(date), "MMM dd, yyyy"); } return format(new Date(date), dateFormat ?? "P"); diff --git a/apps/portal/src/app/[locale]/providers.tsx b/apps/portal/src/app/[locale]/providers.tsx index ffa318a..475c112 100644 --- a/apps/portal/src/app/[locale]/providers.tsx +++ b/apps/portal/src/app/[locale]/providers.tsx @@ -2,9 +2,8 @@ import { I18nProviderClient } from "@/app/locales/client"; import { env } from "@/env.mjs"; -import { Analytics, AnalyticsProvider } from "@bubba/analytics"; +import { AnalyticsProvider } from "@bubba/analytics"; import { ThemeProvider } from "next-themes"; -import { useEffect } from "react"; import type { ReactNode } from "react"; type ProviderProps = { @@ -16,15 +15,6 @@ export function Providers({ children, locale }: ProviderProps) { const hasAnalyticsKeys = env.NEXT_PUBLIC_POSTHOG_KEY && env.NEXT_PUBLIC_POSTHOG_HOST; - useEffect(() => { - if (hasAnalyticsKeys) { - Analytics.init({ - apiKey: env.NEXT_PUBLIC_POSTHOG_KEY!, - apiHost: env.NEXT_PUBLIC_POSTHOG_HOST!, - }); - } - }, [hasAnalyticsKeys]); - return ( - - - {slides.map(({ component: SlideComponent }, index) => ( - - - - ))} - - + Loading...
}> +
+ + + {slides.map(({ component: SlideComponent }, index) => ( + + + + ))} + + - {/* Navigation Overlay */} -
-
- api?.scrollTo(index)} - /> + {/* Navigation Overlay */} +
+
+ api?.scrollTo(index)} + /> +
-
+ ); } diff --git a/apps/web/src/app/pitch/page.tsx b/apps/web/src/app/pitch/page.tsx deleted file mode 100644 index f0c0324..0000000 --- a/apps/web/src/app/pitch/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { PitchCarousel } from "@/app/components/pitch/pitch-carousel"; -import type { Metadata } from "next"; - -export const metadata: Metadata = { - title: "Pitch | Comp AI", -}; - -export default function Pitch() { - return ( -
- -
- ); -} diff --git a/apps/web/src/app/providers.tsx b/apps/web/src/app/providers.tsx index fe989d3..f33532c 100644 --- a/apps/web/src/app/providers.tsx +++ b/apps/web/src/app/providers.tsx @@ -1,10 +1,9 @@ "use client"; import { env } from "@/env.mjs"; -import { Analytics, AnalyticsProvider } from "@bubba/analytics"; +import { AnalyticsProvider } from "@bubba/analytics"; import { TooltipProvider } from "@bubba/ui/tooltip"; import { ThemeProvider as NextThemesProvider } from "next-themes"; -import { useEffect } from "react"; import type * as React from "react"; interface ProvidersProps @@ -16,15 +15,6 @@ export function Providers({ children, ...props }: ProvidersProps) { const hasAnalyticsKeys = env.NEXT_PUBLIC_POSTHOG_KEY && env.NEXT_PUBLIC_POSTHOG_HOST; - useEffect(() => { - if (hasAnalyticsKeys) { - Analytics.init({ - apiKey: env.NEXT_PUBLIC_POSTHOG_KEY!, - apiHost: env.NEXT_PUBLIC_POSTHOG_HOST!, - }); - } - }, [hasAnalyticsKeys]); - return hasAnalyticsKeys ? ( { if (pathname && posthog) { - // Track page views const url = searchParams.toString() ? `${pathname}?${searchParams.toString()}` : pathname; - // Track pageview with current URL posthog.capture("$pageview", { $current_url: url, }); diff --git a/packages/analytics/src/components/provider.tsx b/packages/analytics/src/components/provider.tsx index 0d31002..49dfd46 100644 --- a/packages/analytics/src/components/provider.tsx +++ b/packages/analytics/src/components/provider.tsx @@ -2,7 +2,7 @@ import posthog from "posthog-js"; import { PostHogProvider as PHProvider } from "posthog-js/react"; -import { useEffect } from "react"; +import { Suspense, useEffect } from "react"; import { PostHogPageView } from "./page-view"; interface ProviderProps { @@ -18,9 +18,7 @@ export function AnalyticsProvider({ apiHost, userId, }: ProviderProps) { - // Initialize PostHog in a useEffect to avoid hydration issues useEffect(() => { - // Initialize PostHog only once on the client side posthog.init(apiKey, { api_host: apiHost, loaded: (ph) => { @@ -30,16 +28,17 @@ export function AnalyticsProvider({ }, }); - // Clean up return () => { posthog.reset(); }; }, [apiKey, apiHost, userId]); return ( - - - {children} - + + + + {children} + + ); } diff --git a/packages/analytics/src/index.ts b/packages/analytics/src/index.ts index e536dd2..80bd3c2 100644 --- a/packages/analytics/src/index.ts +++ b/packages/analytics/src/index.ts @@ -1,7 +1,6 @@ import posthog from "posthog-js"; import type { Properties } from "posthog-js"; -// Simple wrapper around PostHog for server-side safety export const Analytics = { track: (eventName: string, properties?: Properties) => { if (typeof window === "undefined") return; diff --git a/packages/db/prisma/seed.js b/packages/db/prisma/seed.js index 2aa1f23..4e58060 100644 --- a/packages/db/prisma/seed.js +++ b/packages/db/prisma/seed.js @@ -16,6 +16,8 @@ async function main() { await prisma.organizationCategory.deleteMany(); await prisma.organizationControl.deleteMany(); await prisma.organizationPolicy.deleteMany(); + await prisma.organizationControlRequirement.deleteMany(); + await prisma.organizationEvidence.deleteMany(); await prisma.policy.deleteMany(); await prisma.policyControl.deleteMany(); await prisma.policyFramework.deleteMany(); @@ -23,7 +25,7 @@ async function main() { await prisma.controlRequirement.deleteMany(); await prisma.framework.deleteMany(); await prisma.frameworkCategory.deleteMany(); - await prisma.organizationControlRequirement.deleteMany(); + await prisma.evidence.deleteMany(); console.log("✅ Database cleaned"); } console.log("\n📋 Seeding policies..."); @@ -82,6 +84,7 @@ async function seedPolicies() { description: policyData.metadata.description, content: policyData.content, usedBy: policyData.metadata.usedBy, + frequency: policyData.metadata?.frequency ?? null, }, create: { id: policyData.metadata.id, @@ -90,6 +93,7 @@ async function seedPolicies() { description: policyData.metadata.description, content: policyData.content, usedBy: policyData.metadata.usedBy, + frequency: policyData.metadata?.frequency ?? null, }, }); console.log(` ✅ ${file} processed`); @@ -215,6 +219,7 @@ async function seedFrameworkCategoryControls(frameworkId, categoryCode) { policyId: requirement.type === "policy" ? requirement.policyId : null, + frequency: requirement?.frequency ?? null, }, update: { name: requirement.name, @@ -222,6 +227,7 @@ async function seedFrameworkCategoryControls(frameworkId, categoryCode) { policyId: requirement.type === "policy" ? requirement.policyId : null, + frequency: requirement?.frequency ?? null, }, }); } @@ -288,20 +294,22 @@ async function seedEvidence() { }, }); console.log(`🔄 Processing ${evidenceRequirements.length} evidences`); - for (const evidence of evidenceRequirements) { - console.log(` ⏳ Processing evidence: ${evidence.name}...`); + for (const evidenceReq of evidenceRequirements) { + console.log(` ⏳ Processing evidence: ${evidenceReq.name}...`); await prisma.evidence.upsert({ where: { - id: evidence.id, + id: evidenceReq.id, }, update: { - name: evidence.name, - description: evidence.description, + name: evidenceReq.name, + description: evidenceReq.description, + frequency: evidenceReq.frequency ?? null, }, create: { - id: evidence.id, - name: evidence.name, - description: evidence.description, + id: evidenceReq.id, + name: evidenceReq.name, + description: evidenceReq.description, + frequency: evidenceReq.frequency ?? null, }, }); } diff --git a/packages/ui/package.json b/packages/ui/package.json index 745fc34..4bed2a5 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -89,7 +89,8 @@ "./inner-menu": "./src/components/inner-menu.tsx", "./empty-card": "./src/components/empty-card.tsx", "./text-editor": "./src/text-editor.css", - "./prosemirror": "./src/prosemirror.css" + "./prosemirror": "./src/prosemirror.css", + "./editor.css": "./src/editor.css" }, "dependencies": { "@mui/icons-material": "^6.1.6", diff --git a/packages/ui/src/editor.css b/packages/ui/src/editor.css new file mode 100644 index 0000000..60dd070 --- /dev/null +++ b/packages/ui/src/editor.css @@ -0,0 +1,221 @@ + +pre { + background: #0d0d0d; + color: #fff; + font-family: "JetBrainsMono", monospace; + padding: 0.75rem 1rem; + + code { + background: none; + color: inherit; + font-size: 0.8rem; + padding: 0; + } + + .hljs-comment, + .hljs-quote { + color: #616161; + } + + .hljs-variable, + .hljs-template-variable, + .hljs-attribute, + .hljs-tag, + .hljs-name, + .hljs-regexp, + .hljs-link, + .hljs-name, + .hljs-selector-id, + .hljs-selector-class { + color: #f98181; + } + + .hljs-number, + .hljs-meta, + .hljs-built_in, + .hljs-builtin-name, + .hljs-literal, + .hljs-type, + .hljs-params { + color: #fbbc88; + } + + .hljs-string, + .hljs-symbol, + .hljs-bullet { + color: #b9f18d; + } + + .hljs-title, + .hljs-section { + color: #faf594; + } + + .hljs-keyword, + .hljs-selector-tag { + color: #70cff8; + } + + .hljs-emphasis { + font-style: italic; + } + + .hljs-strong { + font-weight: 700; + } +} + +/* Base editor styles */ +.ProseMirror { + @apply w-full px-8 py-6 text-sm leading-normal; + height: 100%; + min-height: 350px; + overflow-y: auto; +} + +.ProseMirror:focus { + @apply outline-none; +} + +/* Typography */ +.ProseMirror h1 { + @apply text-xl font-semibold mb-6 text-foreground; +} + +.ProseMirror h2 { + @apply text-lg font-semibold mb-4 text-foreground; +} + +.ProseMirror h3 { + @apply text-base font-semibold mb-3 text-foreground; +} + +.ProseMirror h4 { + @apply text-sm font-semibold mb-2 text-foreground; +} + +.ProseMirror p { + @apply text-sm leading-6 mb-4 text-foreground; +} + +/* Lists */ +.ProseMirror ul, +.ProseMirror ol { + @apply my-2 ml-6 space-y-2; +} + +.ProseMirror li { + @apply text-sm leading-6; + margin: 0 !important; +} + +.ProseMirror li > p { + @apply m-0; +} + +/* Numbered lists specific styling */ +.ProseMirror ol { + list-style-type: decimal; + counter-reset: item; +} + +.ProseMirror ol li { + display: block; + position: relative; +} + +.ProseMirror ol li::before { + content: counters(item, ".") "."; + counter-increment: item; + position: absolute; + left: -1.5em; +} + +/* Tables */ +.ProseMirror table { + @apply w-full border-collapse my-4 border border-border; +} + +.ProseMirror th { + @apply bg-muted text-sm font-medium p-3 border border-border; +} + +.ProseMirror td { + @apply text-sm p-3 border border-border; +} + +/* Spacing between sections */ +.ProseMirror div[data-type="heading"] + p, +.ProseMirror h1 + p, +.ProseMirror h2 + p, +.ProseMirror h3 + p, +.ProseMirror h4 + p { + @apply mt-2; +} + +/* Code blocks */ +.ProseMirror pre { + @apply bg-muted p-4 my-4 overflow-x-auto; +} + +.ProseMirror code { + @apply font-mono text-sm; +} + +/* Blockquotes */ +.ProseMirror blockquote { + @apply border-l-4 border-primary pl-4 italic my-4; +} + +/* Task lists */ +.ProseMirror ul[data-type="taskList"] { + @apply list-none p-0; +} + +.ProseMirror ul[data-type="taskList"] li { + @apply flex items-start gap-2; +} + +/* Placeholder */ +.ProseMirror p.is-empty::before { + @apply text-muted-foreground; + content: attr(data-placeholder); + float: left; + height: 0; + pointer-events: none; +} + +/* Editor chrome */ +.tiptap { + @apply relative bg-background; + height: 100%;; +} + +/* Bubble menu */ +.tiptap .bubble-menu { + @apply flex gap-1 p-1 border border-border bg-background shadow-sm; +} + +/* Command menu */ +.tiptap .command-menu { + @apply absolute z-50 w-60 bg-background border border-border; +} + +/* Add custom scrollbar styles */ +.ProseMirror::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.ProseMirror::-webkit-scrollbar-track { + @apply bg-transparent; +} + +.ProseMirror::-webkit-scrollbar-thumb { + @apply bg-muted-foreground/20 rounded-full hover:bg-muted-foreground/30 active:bg-muted-foreground/40; +} + +.ProseMirror { + scrollbar-width: thin; + scrollbar-color: var(--muted-foreground) transparent; +} \ No newline at end of file