Remove meeting feature

Drop meetings and meeting_attendees tables, remove all meeting-related
code across GraphQL, MCP, CLI, N8N, webhooks, frontend, and e2e tests.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-04-16 17:57:34 +02:00
parent c32aff5e9e
commit 6c5c1fa818
45 changed files with 24 additions and 4906 deletions

View File

@@ -225,7 +225,7 @@ export function PersonForm(props: {
{watchedRole === "VIEWER" && <p>{__("Read-only access")}</p>}
{watchedRole === "AUDITOR" && (
<p>
{__("Read-only access without settings, tasks and meetings")}
{__("Read-only access without settings and tasks")}
</p>
)}
{watchedRole === "EMPLOYEE" && (

View File

@@ -14,15 +14,11 @@
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import { PageHeader, TabLink, Tabs } from "@probo/ui";
import { PageHeader } from "@probo/ui";
import { Outlet } from "react-router";
import { useOrganizationId } from "#/hooks/useOrganizationId";
export default function ContextLayoutLoader() {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const prefix = `/organizations/${organizationId}/context`;
usePageTitle(__("Context"));
@@ -31,13 +27,9 @@ export default function ContextLayoutLoader() {
<PageHeader
title={__("Context")}
description={__(
"Structured company information and meetings for AI assistants and compliance workflows.",
"Structured company information for AI assistants and compliance workflows.",
)}
/>
<Tabs>
<TabLink to={prefix} end>{__("Context")}</TabLink>
<TabLink to={`${prefix}/meetings`}>{__("Meetings")}</TabLink>
</Tabs>
<Outlet />
</div>
);

View File

@@ -1,197 +0,0 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { formatDate, sprintf } from "@probo/helpers";
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
Avatar,
Breadcrumb,
DropdownItem,
IconPencil,
IconTrashCan,
PageHeader,
useConfirm,
} from "@probo/ui";
import { useRef } from "react";
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 { MeetingDetailPageQuery } from "#/__generated__/core/MeetingDetailPageQuery.graphql";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import {
UpdateMeetingMinutesDialog,
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
name
date
# eslint-disable-next-line relay/unused-fields
minutes
canUpdate: permission(action: "core:meeting:update")
canDelete: permission(action: "core:meeting:delete")
attendees {
id
fullName
}
}
`;
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<MeetingDetailPageQuery>;
};
export default function MeetingDetailPage(props: Props) {
const node = usePreloadedQuery(meetingDetailPageQuery, props.queryRef).node;
const meeting = useFragment<MeetingDetailPageMeetingFragment$key>(
meetingFragment,
node,
);
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const navigate = useNavigate();
const [deleteMeeting, isDeleting] = useDeleteMeetingMutation();
const confirm = useConfirm();
const updateMinutesDialogRef = useRef<UpdateMeetingMinutesDialogRef>(null);
usePageTitle(meeting.name);
const hasAnyAction = meeting.canUpdate || meeting.canDelete;
const handleDelete = () => {
confirm(
() =>
deleteMeeting({
variables: {
input: { meetingId: meeting.id },
},
onSuccess: () => {
void navigate(`/organizations/${organizationId}/context/meetings`);
},
}),
{
message: sprintf(
__(
"This will permanently delete the meeting \"%s\". This action cannot be undone.",
),
meeting.name,
),
},
);
};
return (
<>
<UpdateMeetingMinutesDialog
ref={updateMinutesDialogRef}
meeting={meeting}
/>
<div className="space-y-6">
<div className="flex justify-between items-center mb-4">
<Breadcrumb
items={[
{
label: __("Meetings"),
to: `/organizations/${organizationId}/context/meetings`,
},
{
label: meeting.name,
},
]}
/>
{hasAnyAction && (
<ActionDropdown variant="secondary">
{meeting.canUpdate && (
<DropdownItem
onClick={() => updateMinutesDialogRef.current?.open()}
icon={IconPencil}
>
{__("Edit minutes")}
</DropdownItem>
)}
{meeting.canDelete && (
<DropdownItem
variant="danger"
icon={IconTrashCan}
disabled={isDeleting}
onClick={handleDelete}
>
{__("Delete meeting")}
</DropdownItem>
)}
</ActionDropdown>
)}
</div>
<PageHeader
title={meeting.name}
description={formatDate(meeting.date)}
/>
{meeting.attendees && meeting.attendees.length > 0 && (
<div className="flex gap-2 items-center flex-wrap">
{meeting.attendees.map(attendee => (
<div key={attendee.id} className="flex gap-2 items-center">
<Avatar name={attendee.fullName ?? ""} />
<Link
to={`/organizations/${organizationId}/people/${attendee.id}`}
className="text-sm hover:underline"
>
{attendee.fullName}
</Link>
</div>
))}
</div>
)}
<Outlet context={{ meeting }} />
</div>
</>
);
}

View File

@@ -1,46 +0,0 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { useEffect } from "react";
import { useQueryLoader } from "react-relay";
import { useParams } from "react-router";
import type { MeetingDetailPageQuery } from "#/__generated__/core/MeetingDetailPageQuery.graphql";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
import { CoreRelayProvider } from "#/providers/CoreRelayProvider";
import MeetingDetailPage, { meetingDetailPageQuery } from "./MeetingDetailPage";
function MeetingDetailPageQueryLoader() {
const { meetingId } = useParams<{ meetingId: string }>();
const [queryRef, loadQuery] = useQueryLoader<MeetingDetailPageQuery>(meetingDetailPageQuery);
useEffect(() => {
if (meetingId) {
loadQuery({ meetingId });
}
}, [meetingId, loadQuery]);
if (!queryRef) return <PageSkeleton />;
return <MeetingDetailPage queryRef={queryRef} />;
}
export default function MeetingDetailPageLoader() {
return (
<CoreRelayProvider>
<MeetingDetailPageQueryLoader />
</CoreRelayProvider>
);
}

View File

@@ -1,281 +0,0 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { formatDate, sprintf } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
Avatar,
Button,
Card,
DropdownItem,
IconPlusLarge,
IconTrashCan,
Tbody,
Td,
Th,
Thead,
Tr,
useConfirm,
} from "@probo/ui";
import {
type PreloadedQuery,
useFragment,
usePaginationFragment,
usePreloadedQuery,
} from "react-relay";
import { Link } from "react-router";
import { graphql } from "relay-runtime";
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 { 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")
@argumentDefinitions(
first: { type: "Int", defaultValue: 50 }
order: {
type: "MeetingOrder"
defaultValue: { field: DATE, direction: DESC }
}
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
) {
id
canCreateMeeting: permission(action: "core:meeting:create")
meetings(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
) @connection(key: "MeetingsListQuery_meetings") {
__id
edges {
node {
id
...MeetingsPageRowFragment
}
}
}
}
`;
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<MeetingsPageQuery>;
};
export default function MeetingsPage(props: Props) {
const { __ } = useTranslate();
const organization = usePreloadedQuery(
meetingsPageQuery,
props.queryRef,
).organization;
// eslint-disable-next-line relay/generated-typescript-types
const pagination = usePaginationFragment(
meetingsFragment,
organization as MeetingsPageListFragment$key,
);
const meetingNodes = pagination.data.meetings.edges
.map(edge => edge.node)
.filter(Boolean);
const connectionId = pagination.data.meetings.__id;
return (
<div className="space-y-6">
{pagination.data.canCreateMeeting && (
<div className="flex justify-end">
<CreateMeetingDialog connectionId={connectionId}>
<Button icon={IconPlusLarge}>{__("Add meeting")}</Button>
</CreateMeetingDialog>
</div>
)}
{meetingNodes.length > 0
? (
<SortableTable {...pagination}>
<Thead>
<Tr>
<SortableTh field="DATE" className="w-40">
{__("Date")}
</SortableTh>
<SortableTh field="NAME" className="min-w-0">
{__("Meeting name")}
</SortableTh>
<Th className="w-60">{__("Attendees")}</Th>
<Th className="w-18"></Th>
</Tr>
</Thead>
<Tbody>
{meetingNodes.map(meeting => (
<MeetingRow
key={meeting.id}
meeting={meeting}
organizationId={organization.id}
/>
))}
</Tbody>
</SortableTable>
)
: (
<Card padded>
<div className="text-center py-12">
<h3 className="text-lg font-semibold mb-2">
{__("No meetings yet")}
</h3>
<p className="text-txt-tertiary mb-4">
{__("Create your first meeting to get started.")}
</p>
</div>
</Card>
)}
</div>
);
}
const rowFragment = graphql`
fragment MeetingsPageRowFragment on Meeting {
id
name
date
attendees {
id
fullName
}
canDelete: permission(action: "core:meeting:delete")
}
`;
function MeetingRow({
meeting: meetingKey,
organizationId,
}: {
meeting: MeetingsPageRowFragment$key;
organizationId: string;
}) {
const meeting = useFragment<MeetingsPageRowFragment$key>(
rowFragment,
meetingKey,
);
const { __ } = useTranslate();
const [deleteMeeting] = useDeleteMeetingMutation();
const confirm = useConfirm();
const handleDelete = () => {
confirm(
() =>
deleteMeeting({
variables: {
input: { meetingId: meeting.id },
},
}),
{
message: sprintf(
__(
"This will permanently delete the meeting \"%s\". This action cannot be undone.",
),
meeting.name,
),
},
);
};
return (
<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>
</Td>
<Td className="w-60">
{meeting.attendees && meeting.attendees.length > 0
? (
<div className="flex gap-2 items-center flex-wrap">
{meeting.attendees.map(attendee => (
<div key={attendee.id} className="flex gap-2 items-center">
<Avatar name={attendee.fullName ?? ""} />
<Link
to={`/organizations/${organizationId}/people/${attendee.id}`}
onClick={(e) => {
e.stopPropagation();
}}
className="text-sm hover:underline"
>
{attendee.fullName}
</Link>
</div>
))}
</div>
)
: (
<span className="text-txt-tertiary text-sm">
{__("No attendees")}
</span>
)}
</Td>
{meeting.canDelete && (
<Td noLink width={50} className="text-end w-18">
<ActionDropdown>
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={handleDelete}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
</Td>
)}
</Tr>
);
}

View File

@@ -1,44 +0,0 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { useEffect } from "react";
import { useQueryLoader } from "react-relay";
import type { MeetingsPageQuery } from "#/__generated__/core/MeetingsPageQuery.graphql";
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { CoreRelayProvider } from "#/providers/CoreRelayProvider";
import MeetingsPage, { meetingsPageQuery } from "./MeetingsPage";
function MeetingsPageQueryLoader() {
const organizationId = useOrganizationId();
const [queryRef, loadQuery] = useQueryLoader<MeetingsPageQuery>(meetingsPageQuery);
useEffect(() => {
loadQuery({ organizationId });
}, [organizationId, loadQuery]);
if (!queryRef) return <LinkCardSkeleton />;
return <MeetingsPage queryRef={queryRef} />;
}
export default function MeetingsPageLoader() {
return (
<CoreRelayProvider>
<MeetingsPageQueryLoader />
</CoreRelayProvider>
);
}

View File

@@ -1,133 +0,0 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { formatDatetime } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
Field,
Input,
Spinner,
useDialogRef,
} from "@probo/ui";
import { useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { CreateMeetingDialogCreateMutation } from "#/__generated__/core/CreateMeetingDialogCreateMutation.graphql";
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useOrganizationId } from "#/hooks/useOrganizationId";
const createMeetingMutation = graphql`
mutation CreateMeetingDialogCreateMutation(
$input: CreateMeetingInput!
$connections: [ID!]!
) {
createMeeting(input: $input) {
meetingEdge @prependEdge(connections: $connections) {
node {
id
name
date
minutes
attendees {
id
fullName
}
canDelete: permission(action: "core:meeting:delete")
}
}
}
}
`;
type Props = {
children: React.ReactElement;
connectionId: string;
};
const meetingSchema = z.object({
name: z.string().min(1, "Meeting name is required"),
date: z.string().min(1, "Date is required"),
attendeeIds: z.array(z.string()).optional(),
});
export function CreateMeetingDialog({ children, connectionId }: Props) {
const { __ } = useTranslate();
const dialogRef = useDialogRef();
const organizationId = useOrganizationId();
const [createMeeting, isCreating]
= useMutation<CreateMeetingDialogCreateMutation>(createMeetingMutation);
const { handleSubmit, register, control } = useFormWithSchema(
meetingSchema,
{},
);
const onSubmit = (data: z.infer<typeof meetingSchema>) => {
createMeeting({
variables: {
input: {
organizationId,
name: data.name,
date: formatDatetime(data.date)!,
attendeeIds: data.attendeeIds || null,
},
connections: [connectionId],
},
onCompleted: () => {
dialogRef.current?.close();
},
});
};
return (
<Dialog ref={dialogRef} trigger={children} title={__("Create meeting")}>
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
<DialogContent padded className="space-y-4">
<Field label={__("Meeting name")} required>
<Input
{...register("name")}
placeholder={__("Enter meeting name")}
autoFocus
/>
</Field>
<Field label={__("Date")} required>
<Input
{...register("date")}
type="date"
placeholder={__("Select date")}
/>
</Field>
<PeopleMultiSelectField
name="attendeeIds"
control={control}
organizationId={organizationId}
label={__("Attendees")}
placeholder={__("Add attendees...")}
/>
</DialogContent>
<DialogFooter>
<Button disabled={isCreating} type="submit">
{isCreating && <Spinner />}
{__("Create meeting")}
</Button>
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -1,133 +0,0 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { useTranslate } from "@probo/i18n";
import {
Breadcrumb,
Button,
Dialog,
DialogContent,
DialogFooter,
Spinner,
Textarea,
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 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;
};
export type UpdateMeetingMinutesDialogRef = {
open: () => void;
};
const minutesSchema = z.object({
minutes: z.string(),
});
export const UpdateMeetingMinutesDialog = forwardRef<
UpdateMeetingMinutesDialogRef,
Props
>(function UpdateMeetingMinutesDialog({ meeting }, ref) {
const { __ } = useTranslate();
const dialogRef = useDialogRef();
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 || "",
},
});
useImperativeHandle(ref, () => ({
open: () => {
reset({
minutes: meeting.minutes || "",
});
dialogRef.current?.open();
},
}));
const onSubmit = async (data: z.infer<typeof minutesSchema>) => {
await updateMeeting({
variables: {
input: {
meetingId: meeting.id,
minutes: data.minutes,
},
},
onSuccess: () => {
dialogRef.current?.close();
},
});
};
return (
<Dialog
ref={dialogRef}
title={<Breadcrumb items={[__("Meetings"), __("Edit minutes")]} />}
>
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
<DialogContent>
<Textarea
id="minutes"
variant="ghost"
autogrow
placeholder={__("Add meeting minutes")}
aria-label={__("Minutes")}
className="p-6"
{...register("minutes")}
/>
</DialogContent>
<DialogFooter>
<Button disabled={isUpdating} type="submit">
{isUpdating && <Spinner />}
{__("Update minutes")}
</Button>
</DialogFooter>
</form>
</Dialog>
);
});

View File

@@ -1,38 +0,0 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { Markdown } from "@probo/ui";
import { useOutletContext } from "react-router";
import type { MeetingDetailPageMeetingFragment$data } from "#/__generated__/core/MeetingDetailPageMeetingFragment.graphql";
export default function MeetingMinutesTab() {
const { meeting } = useOutletContext<{
meeting: MeetingDetailPageMeetingFragment$data;
}>();
return (
<div>
{meeting.minutes
? (
<Markdown content={meeting.minutes} />
)
: (
<div className="text-txt-tertiary text-sm">
{"No minutes recorded yet. Click \"Edit minutes\" to add meeting minutes."}
</div>
)}
</div>
);
}

View File

@@ -155,9 +155,6 @@ const deleteWebhookSubscriptionMutation = graphql`
`;
const EVENT_TYPES = [
{ value: "MEETING_CREATED", label: "meeting:created" },
{ value: "MEETING_UPDATED", label: "meeting:updated" },
{ value: "MEETING_DELETED", label: "meeting:deleted" },
{ value: "VENDOR_CREATED", label: "vendor:created" },
{ value: "VENDOR_UPDATED", label: "vendor:updated" },
{ value: "VENDOR_DELETED", label: "vendor:deleted" },

View File

@@ -33,29 +33,6 @@ export const contextRoutes = [
() => import("#/pages/organizations/context/ContextPageLoader"),
),
},
{
path: "meetings",
Fallback: LinkCardSkeleton,
Component: lazy(
() => import("#/pages/organizations/meetings/MeetingsPageLoader"),
),
},
{
path: "meetings/:meetingId",
Fallback: PageSkeleton,
Component: lazy(
() => import("#/pages/organizations/meetings/MeetingDetailPageLoader"),
),
children: [
{
index: true,
Fallback: LinkCardSkeleton,
Component: lazy(
() => import("#/pages/organizations/meetings/tabs/MeetingMinutesTab"),
),
},
],
},
],
},
] satisfies AppRoute[];