Transform meetings page into context page with tabs
Add structured organization context with 5 markdown sections (Product, Architecture, Team, Processes, Customers) editable inline. Meetings are now a tab within the context page. Moved all GraphQL queries from hooks/graph/MeetingGraph.ts into colocated components following new best practices. Updated database schema, backend services, GraphQL resolvers, and MCP API to support the new context fields and structure. Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
@@ -1,111 +0,0 @@
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { MeetingGraphDeleteMutation } from "#/__generated__/core/MeetingGraphDeleteMutation.graphql";
|
||||
import type { MeetingGraphUpdateMutation } from "#/__generated__/core/MeetingGraphUpdateMutation.graphql";
|
||||
|
||||
import { useMutationWithToasts } from "../useMutationWithToasts";
|
||||
|
||||
/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */
|
||||
|
||||
export const meetingsQuery = graphql`
|
||||
query MeetingGraphListQuery($organizationId: ID!) {
|
||||
organization: node(id: $organizationId) {
|
||||
id
|
||||
... on Organization {
|
||||
canCreateMeeting: permission(action: "core:meeting:create")
|
||||
}
|
||||
...MeetingsPageListFragment
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const meetingNodeQuery = graphql`
|
||||
query MeetingGraphNodeQuery($meetingId: ID!) {
|
||||
node(id: $meetingId) {
|
||||
...MeetingDetailPageMeetingFragment
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const MeetingsConnectionKey = "MeetingsListQuery_meetings";
|
||||
|
||||
const deleteMeetingMutation = graphql`
|
||||
mutation MeetingGraphDeleteMutation($input: DeleteMeetingInput!) {
|
||||
deleteMeeting(input: $input) {
|
||||
deletedMeetingId @deleteRecord
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function useDeleteMeetingMutation() {
|
||||
const { __ } = useTranslate();
|
||||
|
||||
return useMutationWithToasts<MeetingGraphDeleteMutation>(
|
||||
deleteMeetingMutation,
|
||||
{
|
||||
successMessage: __("Meeting deleted successfully."),
|
||||
errorMessage: __("Failed to delete meeting"),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const updateMeetingMutation = graphql`
|
||||
mutation MeetingGraphUpdateMutation($input: UpdateMeetingInput!) {
|
||||
updateMeeting(input: $input) {
|
||||
meeting {
|
||||
id
|
||||
name
|
||||
date
|
||||
minutes
|
||||
attendees {
|
||||
id
|
||||
fullName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function useUpdateMeetingMutation() {
|
||||
const { __ } = useTranslate();
|
||||
|
||||
return useMutationWithToasts<MeetingGraphUpdateMutation>(
|
||||
updateMeetingMutation,
|
||||
{
|
||||
successMessage: __("Meeting updated successfully."),
|
||||
errorMessage: __("Failed to update meeting"),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const createMeetingMutation = graphql`
|
||||
mutation MeetingGraphCreateMutation(
|
||||
$input: CreateMeetingInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
createMeeting(input: $input) {
|
||||
meetingEdge @prependEdge(connections: $connections) {
|
||||
node {
|
||||
id
|
||||
name
|
||||
date
|
||||
minutes
|
||||
attendees {
|
||||
id
|
||||
fullName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function useCreateMeetingMutation() {
|
||||
const { __ } = useTranslate();
|
||||
|
||||
return useMutationWithToasts(createMeetingMutation, {
|
||||
successMessage: __("Meeting created successfully."),
|
||||
errorMessage: __("Failed to create meeting"),
|
||||
});
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
IconBank,
|
||||
IconBook,
|
||||
IconBox,
|
||||
IconCalendar1,
|
||||
IconCircleProgress,
|
||||
IconClock,
|
||||
IconFire3,
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
IconMedal,
|
||||
IconPageCheck,
|
||||
IconPageTextLine,
|
||||
IconPageTextSolid,
|
||||
IconSettingsGear2,
|
||||
IconShield,
|
||||
IconStore,
|
||||
@@ -29,7 +29,7 @@ import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
const fragment = graphql`
|
||||
fragment SidebarFragment on Organization {
|
||||
canListMeetings: permission(action: "core:meeting:list")
|
||||
canGetContext: permission(action: "core:organization-context:get")
|
||||
canListTasks: permission(action: "core:task:list")
|
||||
canListMeasures: permission(action: "core:measure:list")
|
||||
canListRisks: permission(action: "core:risk:list")
|
||||
@@ -67,11 +67,11 @@ export function Sidebar(props: { fKey: SidebarFragment$key }) {
|
||||
|
||||
return (
|
||||
<ul className="space-y-[2px]">
|
||||
{organization.canListMeetings && (
|
||||
{organization.canGetContext && (
|
||||
<SidebarItem
|
||||
label={__("Meetings")}
|
||||
icon={IconCalendar1}
|
||||
to={`${prefix}/meetings`}
|
||||
label={__("Context")}
|
||||
icon={IconPageTextSolid}
|
||||
to={`${prefix}/context`}
|
||||
/>
|
||||
)}
|
||||
{organization.canListTasks && (
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { PageHeader, TabLink, Tabs } from "@probo/ui";
|
||||
import { Outlet } from "react-router";
|
||||
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
export default function ContextLayout() {
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
const prefix = `/organizations/${organizationId}/context`;
|
||||
|
||||
usePageTitle(__("Context"));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={__("Context")}
|
||||
description={__(
|
||||
"Structured company information and meetings for AI assistants and compliance workflows.",
|
||||
)}
|
||||
/>
|
||||
<Tabs>
|
||||
<TabLink to={`${prefix}/overview`}>{__("Context")}</TabLink>
|
||||
<TabLink to={`${prefix}/meetings`}>{__("Meetings")}</TabLink>
|
||||
</Tabs>
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
254
apps/console/src/pages/organizations/context/ContextPage.tsx
Normal file
254
apps/console/src/pages/organizations/context/ContextPage.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
IconCheckmark1,
|
||||
IconCrossLargeX,
|
||||
IconPencil,
|
||||
Markdown,
|
||||
Textarea,
|
||||
} from "@probo/ui";
|
||||
import { useRef, useState } from "react";
|
||||
import { useFragment } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { ContextPage_UpdateMutation } from "#/__generated__/core/ContextPage_UpdateMutation.graphql";
|
||||
import type { ContextPageFragment$key } from "#/__generated__/core/ContextPageFragment.graphql";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
|
||||
/* eslint-disable relay/unused-fields */
|
||||
const fragment = graphql`
|
||||
fragment ContextPageFragment on Organization {
|
||||
id
|
||||
canUpdateContext: permission(action: "core:organization-context:update")
|
||||
context {
|
||||
product
|
||||
architecture
|
||||
team
|
||||
processes
|
||||
customers
|
||||
}
|
||||
}
|
||||
`;
|
||||
/* eslint-enable relay/unused-fields */
|
||||
|
||||
const updateMutation = graphql`
|
||||
mutation ContextPage_UpdateMutation(
|
||||
$input: UpdateOrganizationContextInput!
|
||||
) {
|
||||
updateOrganizationContext(input: $input) {
|
||||
context {
|
||||
organizationId
|
||||
product
|
||||
architecture
|
||||
team
|
||||
processes
|
||||
customers
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type SectionKey = "product" | "architecture" | "team" | "processes" | "customers";
|
||||
|
||||
type SectionConfig = {
|
||||
key: SectionKey;
|
||||
title: string;
|
||||
description: string;
|
||||
placeholder: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
organization: ContextPageFragment$key;
|
||||
};
|
||||
|
||||
export default function ContextPage(props: Props) {
|
||||
const { __ } = useTranslate();
|
||||
const organization = useFragment(fragment, props.organization);
|
||||
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
key: "product",
|
||||
title: __("Product"),
|
||||
description: __("Describe what your product does, its main features, and value proposition."),
|
||||
placeholder: __("Describe your product in markdown format..."),
|
||||
},
|
||||
{
|
||||
key: "architecture",
|
||||
title: __("Architecture"),
|
||||
description: __("Describe your technical architecture, infrastructure, and key design decisions."),
|
||||
placeholder: __("Describe your architecture in markdown format..."),
|
||||
},
|
||||
{
|
||||
key: "team",
|
||||
title: __("Team"),
|
||||
description: __("Describe your team structure, roles, and responsibilities."),
|
||||
placeholder: __("Describe your team in markdown format..."),
|
||||
},
|
||||
{
|
||||
key: "processes",
|
||||
title: __("Processes"),
|
||||
description: __("Describe your key processes, workflows, and operational procedures."),
|
||||
placeholder: __("Describe your processes in markdown format..."),
|
||||
},
|
||||
{
|
||||
key: "customers",
|
||||
title: __("Customers"),
|
||||
description: __("Describe your target market, customer segments, and use cases."),
|
||||
placeholder: __("Describe your customers in markdown format..."),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{sections.map(section => (
|
||||
<ContextSection
|
||||
key={section.key}
|
||||
section={section}
|
||||
organizationId={organization.id}
|
||||
value={organization.context?.[section.key] ?? null}
|
||||
canEdit={organization.canUpdateContext}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextSection({
|
||||
section,
|
||||
organizationId,
|
||||
value,
|
||||
canEdit,
|
||||
}: {
|
||||
section: SectionConfig;
|
||||
organizationId: string;
|
||||
value: string | null;
|
||||
canEdit: boolean;
|
||||
}) {
|
||||
const { __ } = useTranslate();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [text, setText] = useState(value ?? "");
|
||||
const [displayedValue, setDisplayedValue] = useState(value ?? "");
|
||||
const justSavedRef = useRef(false);
|
||||
|
||||
const [updateContext, isUpdating]
|
||||
= useMutationWithToasts<ContextPage_UpdateMutation>(
|
||||
updateMutation,
|
||||
{
|
||||
successMessage: __("Context updated successfully"),
|
||||
errorMessage: __("Failed to update context"),
|
||||
},
|
||||
);
|
||||
|
||||
const handleSave = async () => {
|
||||
const valueToSave = text.trim();
|
||||
const previousValue = value ?? "";
|
||||
setDisplayedValue(valueToSave);
|
||||
justSavedRef.current = true;
|
||||
|
||||
const valueToSend = valueToSave.length > 0 ? valueToSave : null;
|
||||
|
||||
await updateContext({
|
||||
variables: {
|
||||
input: {
|
||||
organizationId,
|
||||
[section.key]: valueToSend,
|
||||
},
|
||||
},
|
||||
onError: () => {
|
||||
setDisplayedValue(previousValue);
|
||||
justSavedRef.current = false;
|
||||
},
|
||||
onCompleted: (_, errors) => {
|
||||
if (errors?.length) {
|
||||
setDisplayedValue(previousValue);
|
||||
justSavedRef.current = false;
|
||||
}
|
||||
|
||||
setIsEditing(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setText(value ?? "");
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card padded>
|
||||
{isEditing
|
||||
? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">{section.title}</h3>
|
||||
<p className="text-xs text-txt-tertiary mt-1">
|
||||
{section.description}
|
||||
</p>
|
||||
</div>
|
||||
<Textarea
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
autogrow
|
||||
className="min-h-32 font-mono text-sm"
|
||||
placeholder={section.placeholder}
|
||||
/>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={IconCrossLargeX}
|
||||
onClick={handleCancel}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
{__("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
icon={IconCheckmark1}
|
||||
onClick={() => void handleSave()}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
{__("Save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">{section.title}</h3>
|
||||
<p className="text-xs text-txt-tertiary mt-1">
|
||||
{section.description}
|
||||
</p>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<Button
|
||||
variant="quaternary"
|
||||
icon={IconPencil}
|
||||
onClick={() => {
|
||||
setText(value ?? "");
|
||||
setIsEditing(true);
|
||||
}}
|
||||
>
|
||||
{__("Edit")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full">
|
||||
{displayedValue
|
||||
? (
|
||||
<div className="prose prose-sm max-w-none w-full [&_.prose]:max-w-none">
|
||||
<Markdown content={displayedValue} />
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="text-txt-tertiary text-sm italic">
|
||||
{__("No content yet. Click Edit to add one.")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { ContextPageLoaderQuery } from "#/__generated__/core/ContextPageLoaderQuery.graphql";
|
||||
|
||||
import ContextPage from "./ContextPage";
|
||||
|
||||
export const contextPageQuery = graphql`
|
||||
query ContextPageLoaderQuery($organizationId: ID!) {
|
||||
organization: node(id: $organizationId) {
|
||||
... on Organization {
|
||||
...ContextPageFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
queryRef: PreloadedQuery<ContextPageLoaderQuery>;
|
||||
};
|
||||
|
||||
export default function ContextPageLoader(props: Props) {
|
||||
const data = usePreloadedQuery(contextPageQuery, props.queryRef);
|
||||
|
||||
return <ContextPage organization={data.organization} />;
|
||||
}
|
||||
@@ -16,12 +16,10 @@ import type { PreloadedQuery } from "react-relay";
|
||||
import { graphql, useFragment, usePreloadedQuery } from "react-relay";
|
||||
import { Link, Outlet, useNavigate } from "react-router";
|
||||
|
||||
import type { MeetingDetailPageDeleteMutation } from "#/__generated__/core/MeetingDetailPageDeleteMutation.graphql";
|
||||
import type { MeetingDetailPageMeetingFragment$key } from "#/__generated__/core/MeetingDetailPageMeetingFragment.graphql";
|
||||
import type { MeetingGraphNodeQuery } from "#/__generated__/core/MeetingGraphNodeQuery.graphql";
|
||||
import {
|
||||
meetingNodeQuery,
|
||||
useDeleteMeetingMutation,
|
||||
} from "#/hooks/graph/MeetingGraph";
|
||||
import type { MeetingDetailPageQuery } from "#/__generated__/core/MeetingDetailPageQuery.graphql";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
import {
|
||||
@@ -29,6 +27,14 @@ import {
|
||||
type UpdateMeetingMinutesDialogRef,
|
||||
} from "./dialogs/UpdateMeetingMinutesDialog";
|
||||
|
||||
export const meetingDetailPageQuery = graphql`
|
||||
query MeetingDetailPageQuery($meetingId: ID!) {
|
||||
node(id: $meetingId) {
|
||||
...MeetingDetailPageMeetingFragment
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const meetingFragment = graphql`
|
||||
fragment MeetingDetailPageMeetingFragment on Meeting {
|
||||
id
|
||||
@@ -45,12 +51,32 @@ const meetingFragment = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
const deleteMeetingMutation = graphql`
|
||||
mutation MeetingDetailPageDeleteMutation($input: DeleteMeetingInput!) {
|
||||
deleteMeeting(input: $input) {
|
||||
deletedMeetingId @deleteRecord
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function useDeleteMeetingMutation() {
|
||||
const { __ } = useTranslate();
|
||||
|
||||
return useMutationWithToasts<MeetingDetailPageDeleteMutation>(
|
||||
deleteMeetingMutation,
|
||||
{
|
||||
successMessage: __("Meeting deleted successfully."),
|
||||
errorMessage: __("Failed to delete meeting"),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
queryRef: PreloadedQuery<MeetingGraphNodeQuery>;
|
||||
queryRef: PreloadedQuery<MeetingDetailPageQuery>;
|
||||
};
|
||||
|
||||
export default function MeetingDetailPage(props: Props) {
|
||||
const node = usePreloadedQuery(meetingNodeQuery, props.queryRef).node;
|
||||
const node = usePreloadedQuery(meetingDetailPageQuery, props.queryRef).node;
|
||||
const meeting = useFragment<MeetingDetailPageMeetingFragment$key>(
|
||||
meetingFragment,
|
||||
node,
|
||||
@@ -75,7 +101,7 @@ export default function MeetingDetailPage(props: Props) {
|
||||
input: { meetingId: meeting.id },
|
||||
},
|
||||
onSuccess: () => {
|
||||
void navigate(`/organizations/${organizationId}/meetings`);
|
||||
void navigate(`/organizations/${organizationId}/context/meetings`);
|
||||
},
|
||||
}),
|
||||
{
|
||||
@@ -101,7 +127,7 @@ export default function MeetingDetailPage(props: Props) {
|
||||
items={[
|
||||
{
|
||||
label: __("Meetings"),
|
||||
to: `/organizations/${organizationId}/meetings`,
|
||||
to: `/organizations/${organizationId}/context/meetings`,
|
||||
},
|
||||
{
|
||||
label: meeting.name,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { formatDate, sprintf } from "@probo/helpers";
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
ActionDropdown,
|
||||
@@ -7,22 +6,15 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
DropdownItem,
|
||||
IconCheckmark1,
|
||||
IconCrossLargeX,
|
||||
IconPencil,
|
||||
IconPlusLarge,
|
||||
IconTrashCan,
|
||||
Markdown,
|
||||
PageHeader,
|
||||
Tbody,
|
||||
Td,
|
||||
Textarea,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
useConfirm,
|
||||
} from "@probo/ui";
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
type PreloadedQuery,
|
||||
useFragment,
|
||||
@@ -32,19 +24,27 @@ import {
|
||||
import { Link } from "react-router";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { MeetingGraphListQuery } from "#/__generated__/core/MeetingGraphListQuery.graphql";
|
||||
import type { MeetingsPage_UpdateSummaryMutation } from "#/__generated__/core/MeetingsPage_UpdateSummaryMutation.graphql";
|
||||
import type { MeetingsPageDeleteMutation } from "#/__generated__/core/MeetingsPageDeleteMutation.graphql";
|
||||
import type { MeetingsPageListFragment$key } from "#/__generated__/core/MeetingsPageListFragment.graphql";
|
||||
import type { MeetingsPageQuery } from "#/__generated__/core/MeetingsPageQuery.graphql";
|
||||
import type { MeetingsPageRowFragment$key } from "#/__generated__/core/MeetingsPageRowFragment.graphql";
|
||||
import { SortableTable, SortableTh } from "#/components/SortableTable";
|
||||
import {
|
||||
meetingsQuery,
|
||||
useDeleteMeetingMutation,
|
||||
} from "#/hooks/graph/MeetingGraph";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
|
||||
import { CreateMeetingDialog } from "./dialogs/CreateMeetingDialog";
|
||||
|
||||
export const meetingsPageQuery = graphql`
|
||||
query MeetingsPageQuery($organizationId: ID!) {
|
||||
organization: node(id: $organizationId) {
|
||||
id
|
||||
... on Organization {
|
||||
canCreateMeeting: permission(action: "core:meeting:create")
|
||||
}
|
||||
...MeetingsPageListFragment
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const meetingsFragment = graphql`
|
||||
fragment MeetingsPageListFragment on Organization
|
||||
@refetchable(queryName: "MeetingsListQuery")
|
||||
@@ -59,9 +59,7 @@ const meetingsFragment = graphql`
|
||||
last: { type: "Int", defaultValue: null }
|
||||
) {
|
||||
id
|
||||
context {
|
||||
summary
|
||||
}
|
||||
canCreateMeeting: permission(action: "core:meeting:create")
|
||||
meetings(
|
||||
first: $first
|
||||
after: $after
|
||||
@@ -80,14 +78,34 @@ const meetingsFragment = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
const deleteMeetingMutation = graphql`
|
||||
mutation MeetingsPageDeleteMutation($input: DeleteMeetingInput!) {
|
||||
deleteMeeting(input: $input) {
|
||||
deletedMeetingId @deleteRecord
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function useDeleteMeetingMutation() {
|
||||
const { __ } = useTranslate();
|
||||
|
||||
return useMutationWithToasts<MeetingsPageDeleteMutation>(
|
||||
deleteMeetingMutation,
|
||||
{
|
||||
successMessage: __("Meeting deleted successfully."),
|
||||
errorMessage: __("Failed to delete meeting"),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
queryRef: PreloadedQuery<MeetingGraphListQuery>;
|
||||
queryRef: PreloadedQuery<MeetingsPageQuery>;
|
||||
};
|
||||
|
||||
export default function MeetingsPage(props: Props) {
|
||||
const { __ } = useTranslate();
|
||||
const organization = usePreloadedQuery(
|
||||
meetingsQuery,
|
||||
meetingsPageQuery,
|
||||
props.queryRef,
|
||||
).organization;
|
||||
|
||||
@@ -102,151 +120,15 @@ export default function MeetingsPage(props: Props) {
|
||||
.filter(Boolean);
|
||||
const connectionId = pagination.data.meetings.__id;
|
||||
|
||||
usePageTitle(__("Meetings"));
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const summary = pagination.data.context?.summary || "";
|
||||
const [summaryText, setSummaryText] = useState(summary);
|
||||
// Local state to track the displayed summary (updated immediately on save)
|
||||
const [displayedSummary, setDisplayedSummary] = useState(summary);
|
||||
// Track if we just saved to prevent useEffect from overwriting our update
|
||||
const justSavedRef = useRef(false);
|
||||
|
||||
const updateSummaryMutation = graphql`
|
||||
mutation MeetingsPage_UpdateSummaryMutation(
|
||||
$input: UpdateOrganizationContextInput!
|
||||
) {
|
||||
updateOrganizationContext(input: $input) {
|
||||
context {
|
||||
organizationId
|
||||
summary
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const [updateSummary, isUpdating]
|
||||
= useMutationWithToasts<MeetingsPage_UpdateSummaryMutation>(
|
||||
updateSummaryMutation,
|
||||
{
|
||||
successMessage: __("Summary updated successfully"),
|
||||
errorMessage: __("Failed to update summary"),
|
||||
},
|
||||
);
|
||||
|
||||
const handleSave = async () => {
|
||||
const valueToSave = summaryText.trim();
|
||||
const previousValue = pagination.data.context?.summary || "";
|
||||
setDisplayedSummary(valueToSave);
|
||||
justSavedRef.current = true;
|
||||
|
||||
const valueToSend = valueToSave.length > 0 ? valueToSave : "";
|
||||
|
||||
await updateSummary({
|
||||
variables: {
|
||||
input: {
|
||||
organizationId: organization.id,
|
||||
summary: valueToSend || null,
|
||||
},
|
||||
},
|
||||
onError: () => {
|
||||
// Roll back optimistic update on error
|
||||
setDisplayedSummary(previousValue);
|
||||
justSavedRef.current = false;
|
||||
},
|
||||
onCompleted: (_, errors) => {
|
||||
if (errors?.length) {
|
||||
// Roll back optimistic update on GraphQL error
|
||||
setDisplayedSummary(previousValue);
|
||||
justSavedRef.current = false;
|
||||
}
|
||||
|
||||
setIsEditing(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setSummaryText(pagination.data.context?.summary || "");
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card padded>
|
||||
{isEditing
|
||||
? (
|
||||
<div className="space-y-4">
|
||||
<Textarea
|
||||
value={summaryText}
|
||||
onChange={e => setSummaryText(e.target.value)}
|
||||
autogrow
|
||||
className="min-h-32 font-mono text-sm"
|
||||
placeholder={__("Enter meetings summary in markdown format")}
|
||||
/>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={IconCrossLargeX}
|
||||
onClick={handleCancel}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
{__("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
icon={IconCheckmark1}
|
||||
onClick={() => void handleSave()}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
{__("Save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-txt-secondary">
|
||||
{__("Summary")}
|
||||
</h3>
|
||||
{organization.canCreateMeeting && (
|
||||
<Button
|
||||
variant="quaternary"
|
||||
icon={IconPencil}
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
{__("Edit")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full">
|
||||
{displayedSummary
|
||||
? (
|
||||
<div className="prose prose-sm max-w-none w-full [&_.prose]:max-w-none">
|
||||
<Markdown content={displayedSummary} />
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="text-txt-tertiary text-sm italic">
|
||||
{__("No summary yet. Click Edit to add one.")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
<PageHeader
|
||||
title={__("Meetings")}
|
||||
description={__(
|
||||
"Track and manage your organization's meetings and their minutes.",
|
||||
)}
|
||||
>
|
||||
{organization.canCreateMeeting && (
|
||||
{pagination.data.canCreateMeeting && (
|
||||
<div className="flex justify-end">
|
||||
<CreateMeetingDialog connectionId={connectionId}>
|
||||
<Button icon={IconPlusLarge}>{__("Add meeting")}</Button>
|
||||
</CreateMeetingDialog>
|
||||
)}
|
||||
</PageHeader>
|
||||
</div>
|
||||
)}
|
||||
{meetingNodes.length > 0
|
||||
? (
|
||||
<SortableTable {...pagination}>
|
||||
@@ -336,7 +218,7 @@ function MeetingRow({
|
||||
};
|
||||
|
||||
return (
|
||||
<Tr to={`/organizations/${organizationId}/meetings/${meeting.id}`}>
|
||||
<Tr to={`/organizations/${organizationId}/context/meetings/${meeting.id}`}>
|
||||
<Td className="w-40">{formatDate(meeting.date)}</Td>
|
||||
<Td className="min-w-0">
|
||||
<div className="flex gap-4 items-center">{meeting.name}</div>
|
||||
|
||||
@@ -10,11 +10,30 @@ import {
|
||||
useDialogRef,
|
||||
} from "@probo/ui";
|
||||
import { forwardRef, useImperativeHandle } from "react";
|
||||
import { graphql } from "relay-runtime";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { MeetingDetailPageMeetingFragment$data } from "#/__generated__/core/MeetingDetailPageMeetingFragment.graphql";
|
||||
import { useUpdateMeetingMutation } from "#/hooks/graph/MeetingGraph";
|
||||
import type { UpdateMeetingMinutesDialogMutation } from "#/__generated__/core/UpdateMeetingMinutesDialogMutation.graphql";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
|
||||
const updateMeetingMutation = graphql`
|
||||
mutation UpdateMeetingMinutesDialogMutation($input: UpdateMeetingInput!) {
|
||||
updateMeeting(input: $input) {
|
||||
meeting {
|
||||
id
|
||||
name
|
||||
date
|
||||
minutes
|
||||
attendees {
|
||||
id
|
||||
fullName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
meeting: MeetingDetailPageMeetingFragment$data;
|
||||
@@ -34,7 +53,14 @@ export const UpdateMeetingMinutesDialog = forwardRef<
|
||||
>(function UpdateMeetingMinutesDialog({ meeting }, ref) {
|
||||
const { __ } = useTranslate();
|
||||
const dialogRef = useDialogRef();
|
||||
const [updateMeeting, isUpdating] = useUpdateMeetingMutation();
|
||||
const [updateMeeting, isUpdating]
|
||||
= useMutationWithToasts<UpdateMeetingMinutesDialogMutation>(
|
||||
updateMeetingMutation,
|
||||
{
|
||||
successMessage: __("Meeting updated successfully."),
|
||||
errorMessage: __("Failed to update meeting"),
|
||||
},
|
||||
);
|
||||
const { handleSubmit, register, reset } = useFormWithSchema(minutesSchema, {
|
||||
defaultValues: {
|
||||
minutes: meeting.minutes || "",
|
||||
|
||||
@@ -19,12 +19,12 @@ import { compliancePageRoutes } from "./pages/organizations/compliance-page/rout
|
||||
import { CurrentUser } from "./providers/CurrentUser";
|
||||
import { assetRoutes } from "./routes/assetRoutes";
|
||||
import { auditRoutes } from "./routes/auditRoutes";
|
||||
import { contextRoutes } from "./routes/contextRoutes";
|
||||
import { dataRoutes } from "./routes/dataRoutes";
|
||||
import { documentsRoutes } from "./routes/documentsRoutes";
|
||||
import { findingRoutes } from "./routes/findingRoutes";
|
||||
import { frameworkRoutes } from "./routes/frameworkRoutes";
|
||||
import { measureRoutes } from "./routes/measureRoutes";
|
||||
import { meetingsRoutes } from "./routes/meetingsRoutes";
|
||||
import { obligationRoutes } from "./routes/obligationRoutes";
|
||||
import { processingActivityRoutes } from "./routes/processingActivityRoutes";
|
||||
import { rightsRequestRoutes } from "./routes/rightsRequestRoutes";
|
||||
@@ -222,7 +222,7 @@ const routes = [
|
||||
...assetRoutes,
|
||||
...dataRoutes,
|
||||
...auditRoutes,
|
||||
...meetingsRoutes,
|
||||
...contextRoutes,
|
||||
...findingRoutes,
|
||||
...obligationRoutes,
|
||||
...rightsRequestRoutes,
|
||||
|
||||
114
apps/console/src/routes/contextRoutes.ts
Normal file
114
apps/console/src/routes/contextRoutes.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { lazy } from "@probo/react-lazy";
|
||||
import {
|
||||
type AppRoute,
|
||||
loaderFromQueryLoader,
|
||||
withQueryRef,
|
||||
} from "@probo/routes";
|
||||
import { Fragment } from "react";
|
||||
import { loadQuery } from "react-relay";
|
||||
import { type LoaderFunctionArgs, redirect } from "react-router";
|
||||
|
||||
import type { ContextPageLoaderQuery } from "#/__generated__/core/ContextPageLoaderQuery.graphql";
|
||||
import type { MeetingDetailPageQuery } from "#/__generated__/core/MeetingDetailPageQuery.graphql";
|
||||
import type { MeetingsPageQuery } from "#/__generated__/core/MeetingsPageQuery.graphql";
|
||||
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
|
||||
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
|
||||
import { coreEnvironment } from "#/environments";
|
||||
import { contextPageQuery } from "#/pages/organizations/context/ContextPageLoader";
|
||||
import { meetingDetailPageQuery } from "#/pages/organizations/meetings/MeetingDetailPage";
|
||||
import { meetingsPageQuery } from "#/pages/organizations/meetings/MeetingsPage";
|
||||
|
||||
const meetingTabs = (prefix: string) => {
|
||||
return [
|
||||
{
|
||||
path: `${prefix}`,
|
||||
loader: ({
|
||||
params: { organizationId, meetingId },
|
||||
}: LoaderFunctionArgs) => {
|
||||
const basePath = `/organizations/${organizationId}/context/meetings/${meetingId}`;
|
||||
const redirectPath = `${basePath}/minutes`;
|
||||
// eslint-disable-next-line
|
||||
throw redirect(redirectPath);
|
||||
},
|
||||
Component: Fragment,
|
||||
},
|
||||
{
|
||||
path: `${prefix}minutes`,
|
||||
Fallback: LinkCardSkeleton,
|
||||
Component: lazy(
|
||||
() => import("../pages/organizations/meetings/tabs/MeetingMinutesTab"),
|
||||
),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const contextRoutes = [
|
||||
{
|
||||
path: "context",
|
||||
Fallback: PageSkeleton,
|
||||
Component: lazy(
|
||||
() => import("#/pages/organizations/context/ContextLayout"),
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
loader: ({
|
||||
params: { organizationId },
|
||||
}: LoaderFunctionArgs) => {
|
||||
// eslint-disable-next-line
|
||||
throw redirect(`/organizations/${organizationId}/context/overview`);
|
||||
},
|
||||
Component: Fragment,
|
||||
},
|
||||
{
|
||||
path: "overview",
|
||||
Fallback: PageSkeleton,
|
||||
loader: loaderFromQueryLoader(({ organizationId }) =>
|
||||
loadQuery<ContextPageLoaderQuery>(
|
||||
coreEnvironment,
|
||||
contextPageQuery,
|
||||
{ organizationId },
|
||||
),
|
||||
),
|
||||
Component: withQueryRef(
|
||||
lazy(
|
||||
() => import("#/pages/organizations/context/ContextPageLoader"),
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "meetings",
|
||||
Fallback: PageSkeleton,
|
||||
loader: loaderFromQueryLoader(({ organizationId }) =>
|
||||
loadQuery<MeetingsPageQuery>(
|
||||
coreEnvironment,
|
||||
meetingsPageQuery,
|
||||
{ organizationId },
|
||||
),
|
||||
),
|
||||
Component: withQueryRef(
|
||||
lazy(
|
||||
() => import("#/pages/organizations/meetings/MeetingsPage"),
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "meetings/:meetingId",
|
||||
Fallback: PageSkeleton,
|
||||
loader: loaderFromQueryLoader(({ meetingId }) =>
|
||||
loadQuery<MeetingDetailPageQuery>(
|
||||
coreEnvironment,
|
||||
meetingDetailPageQuery,
|
||||
{ meetingId },
|
||||
),
|
||||
),
|
||||
Component: withQueryRef(
|
||||
lazy(
|
||||
() => import("../pages/organizations/meetings/MeetingDetailPage"),
|
||||
),
|
||||
),
|
||||
children: [...meetingTabs("")],
|
||||
},
|
||||
],
|
||||
},
|
||||
] satisfies AppRoute[];
|
||||
@@ -1,68 +0,0 @@
|
||||
import { lazy } from "@probo/react-lazy";
|
||||
import {
|
||||
type AppRoute,
|
||||
loaderFromQueryLoader,
|
||||
withQueryRef,
|
||||
} from "@probo/routes";
|
||||
import { Fragment } from "react";
|
||||
import { loadQuery } from "react-relay";
|
||||
import { type LoaderFunctionArgs, redirect } from "react-router";
|
||||
|
||||
import type { MeetingGraphListQuery } from "#/__generated__/core/MeetingGraphListQuery.graphql";
|
||||
import type { MeetingGraphNodeQuery } from "#/__generated__/core/MeetingGraphNodeQuery.graphql";
|
||||
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
|
||||
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
|
||||
import { coreEnvironment } from "#/environments";
|
||||
import { meetingNodeQuery, meetingsQuery } from "#/hooks/graph/MeetingGraph";
|
||||
|
||||
const meetingTabs = (prefix: string) => {
|
||||
return [
|
||||
{
|
||||
path: `${prefix}`,
|
||||
loader: ({
|
||||
params: { organizationId, meetingId },
|
||||
}: LoaderFunctionArgs) => {
|
||||
const basePath = `/organizations/${organizationId}/meetings/${meetingId}`;
|
||||
const redirectPath = `${basePath}/minutes`;
|
||||
// eslint-disable-next-line
|
||||
throw redirect(redirectPath);
|
||||
},
|
||||
Component: Fragment,
|
||||
},
|
||||
{
|
||||
path: `${prefix}minutes`,
|
||||
Fallback: LinkCardSkeleton,
|
||||
Component: lazy(
|
||||
() => import("../pages/organizations/meetings/tabs/MeetingMinutesTab"),
|
||||
),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const meetingsRoutes = [
|
||||
{
|
||||
path: "meetings",
|
||||
Fallback: PageSkeleton,
|
||||
loader: loaderFromQueryLoader(({ organizationId }) =>
|
||||
loadQuery<MeetingGraphListQuery>(coreEnvironment, meetingsQuery, {
|
||||
organizationId,
|
||||
}),
|
||||
),
|
||||
Component: withQueryRef(
|
||||
lazy(() => import("#/pages/organizations/meetings/MeetingsPage")),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "meetings/:meetingId",
|
||||
Fallback: PageSkeleton,
|
||||
loader: loaderFromQueryLoader(({ meetingId }) =>
|
||||
loadQuery<MeetingGraphNodeQuery>(coreEnvironment, meetingNodeQuery, {
|
||||
meetingId,
|
||||
}),
|
||||
),
|
||||
Component: withQueryRef(
|
||||
lazy(() => import("../pages/organizations/meetings/MeetingDetailPage")),
|
||||
),
|
||||
children: [...meetingTabs("")],
|
||||
},
|
||||
] satisfies AppRoute[];
|
||||
Reference in New Issue
Block a user