Add meeting and meeting summary objects

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Antoine Bouchardy
2025-11-09 13:29:53 +01:00
committed by Bryan Frimin
parent 0a9033aaaf
commit 139f5984e2
63 changed files with 9395 additions and 1219 deletions

View File

@@ -0,0 +1,156 @@
import { Avatar, Field, Option, Select, Badge, Button, IconCrossLargeX } from "@probo/ui";
import { Suspense, useState, type ComponentProps } from "react";
import { useTranslate } from "@probo/i18n";
import { type Control, Controller, type FieldValues } from "react-hook-form";
import { usePeople } from "/hooks/graph/PeopleGraph.ts";
import type { Path } from "react-hook-form";
type Person = {
id: string;
fullName: string;
primaryEmailAddress?: string | null;
};
type Props<T extends FieldValues = FieldValues> = {
organizationId: string;
control: Control<T>;
name: string;
label?: string;
error?: string;
selectedPeople?: Person[];
} & ComponentProps<typeof Field>;
export function PeopleMultiSelectField<T extends FieldValues = FieldValues>({
organizationId,
control,
selectedPeople = [],
...props
}: Props<T>) {
return (
<Field {...props}>
<Suspense
fallback={<Select variant="editor" disabled placeholder="Loading..." />}
>
<PeopleMultiSelectWithQuery
organizationId={organizationId}
control={control}
name={props.name}
disabled={props.disabled}
selectedPeople={selectedPeople}
/>
</Suspense>
</Field>
);
}
function PeopleMultiSelectWithQuery<T extends FieldValues = FieldValues>(
props: Pick<Props<T>, "organizationId" | "control" | "name" | "disabled" | "selectedPeople">
) {
const { __ } = useTranslate();
const { name, organizationId, control, selectedPeople = [] } = props;
const people = usePeople(organizationId, { excludeContractEnded: true });
const [isOpen, setIsOpen] = useState(false);
const allPeople = [...people];
selectedPeople.forEach(selectedPerson => {
if (!allPeople.find(p => p.id === selectedPerson.id)) {
allPeople.push({
id: selectedPerson.id,
fullName: selectedPerson.fullName,
primaryEmailAddress: selectedPerson.primaryEmailAddress ?? "",
});
}
});
return (
<>
<Controller
control={control}
name={name as Path<T>}
render={({ field }) => {
const selectedPeopleIds = (Array.isArray(field.value) ? field.value : []) as string[];
const selectedPeople = allPeople.filter(p => selectedPeopleIds.includes(p.id));
const availablePeople = allPeople.filter(p => !selectedPeopleIds.includes(p.id));
const handleAddPerson = (personId: string) => {
const newValue = [...selectedPeopleIds, personId];
field.onChange(newValue);
setIsOpen(false);
};
const handleRemovePerson = (personId: string) => {
const newValue = selectedPeopleIds.filter((id: string) => id !== personId);
field.onChange(newValue);
};
return (
<div className="space-y-2">
{availablePeople.length > 0 && !props.disabled && (
<Select
disabled={props.disabled}
id={name}
variant="editor"
placeholder={__("Add attendees...")}
onValueChange={handleAddPerson}
key={`${selectedPeopleIds.length}-${people.length}`}
className="w-full"
value=""
open={isOpen}
onOpenChange={setIsOpen}
>
{availablePeople.map((person) => (
<Option key={person.id} value={person.id} className="flex gap-2">
<Avatar
name={person.fullName}
size="s"
/>
<div className="flex flex-col">
<span>{person.fullName}</span>
{person.primaryEmailAddress && (
<span className="text-xs text-txt-secondary">
{person.primaryEmailAddress}
</span>
)}
</div>
</Option>
))}
</Select>
)}
{selectedPeople.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedPeople.map((person) => (
<Badge key={person.id} variant="neutral" className="flex items-center gap-2">
<Avatar
name={person.fullName}
size="s"
/>
<span>{person.fullName}</span>
{!props.disabled && (
<Button
type="button"
variant="tertiary"
icon={IconCrossLargeX}
onClick={() => handleRemovePerson(person.id)}
className="h-4 w-4 p-0 hover:bg-transparent"
/>
)}
</Badge>
))}
</div>
)}
{selectedPeople.length === 0 && availablePeople.length === 0 && (
<div className="text-sm text-txt-secondary py-2">
{__("No people available")}
</div>
)}
</div>
);
}}
/>
</>
);
}

View File

@@ -0,0 +1,105 @@
import { useTranslate } from "@probo/i18n";
import { graphql } from "relay-runtime";
import { useMutationWithToasts } from "../useMutationWithToasts";
import type { MeetingGraphDeleteMutation } from "./__generated__/MeetingGraphDeleteMutation.graphql";
import type { MeetingGraphUpdateMutation } from "./__generated__/MeetingGraphUpdateMutation.graphql";
export const meetingsQuery = graphql`
query MeetingGraphListQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
id
...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"),
}
);
}

View File

@@ -0,0 +1,210 @@
/**
* @generated SignedSource<<15692efdcfe280221e1be99acfbf206b>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type CreateMeetingInput = {
attendeeIds?: ReadonlyArray<string> | null | undefined;
date: any;
minutes?: string | null | undefined;
name: string;
organizationId: string;
};
export type MeetingGraphCreateMutation$variables = {
connections: ReadonlyArray<string>;
input: CreateMeetingInput;
};
export type MeetingGraphCreateMutation$data = {
readonly createMeeting: {
readonly meetingEdge: {
readonly node: {
readonly attendees: ReadonlyArray<{
readonly fullName: string;
readonly id: string;
}>;
readonly date: any;
readonly id: string;
readonly minutes: string | null | undefined;
readonly name: string;
};
};
};
};
export type MeetingGraphCreateMutation = {
response: MeetingGraphCreateMutation$data;
variables: MeetingGraphCreateMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "connections"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
},
v2 = [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v4 = {
"alias": null,
"args": null,
"concreteType": "MeetingEdge",
"kind": "LinkedField",
"name": "meetingEdge",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Meeting",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "date",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "minutes",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "People",
"kind": "LinkedField",
"name": "attendees",
"plural": true,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "MeetingGraphCreateMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateMeetingPayload",
"kind": "LinkedField",
"name": "createMeeting",
"plural": false,
"selections": [
(v4/*: any*/)
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "MeetingGraphCreateMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateMeetingPayload",
"kind": "LinkedField",
"name": "createMeeting",
"plural": false,
"selections": [
(v4/*: any*/),
{
"alias": null,
"args": null,
"filters": null,
"handle": "prependEdge",
"key": "",
"kind": "LinkedHandle",
"name": "meetingEdge",
"handleArgs": [
{
"kind": "Variable",
"name": "connections",
"variableName": "connections"
}
]
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "1d0c66e942482f999e3b986af16df678",
"id": null,
"metadata": {},
"name": "MeetingGraphCreateMutation",
"operationKind": "mutation",
"text": "mutation MeetingGraphCreateMutation(\n $input: CreateMeetingInput!\n) {\n createMeeting(input: $input) {\n meetingEdge {\n node {\n id\n name\n date\n minutes\n attendees {\n id\n fullName\n }\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "27fd6781022144fe6fd247b7e2ce1aaf";
export default node;

View File

@@ -0,0 +1,115 @@
/**
* @generated SignedSource<<f903f8daaf7c28c3d165a215c98e05b6>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type DeleteMeetingInput = {
meetingId: string;
};
export type MeetingGraphDeleteMutation$variables = {
input: DeleteMeetingInput;
};
export type MeetingGraphDeleteMutation$data = {
readonly deleteMeeting: {
readonly deletedMeetingId: string;
};
};
export type MeetingGraphDeleteMutation = {
response: MeetingGraphDeleteMutation$data;
variables: MeetingGraphDeleteMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "deletedMeetingId",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "MeetingGraphDeleteMutation",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": "DeleteMeetingPayload",
"kind": "LinkedField",
"name": "deleteMeeting",
"plural": false,
"selections": [
(v2/*: any*/)
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "MeetingGraphDeleteMutation",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": "DeleteMeetingPayload",
"kind": "LinkedField",
"name": "deleteMeeting",
"plural": false,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"filters": null,
"handle": "deleteRecord",
"key": "",
"kind": "ScalarHandle",
"name": "deletedMeetingId"
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "f88bdc6e79cef363e7c2aaf91dce1d16",
"id": null,
"metadata": {},
"name": "MeetingGraphDeleteMutation",
"operationKind": "mutation",
"text": "mutation MeetingGraphDeleteMutation(\n $input: DeleteMeetingInput!\n) {\n deleteMeeting(input: $input) {\n deletedMeetingId\n }\n}\n"
}
};
})();
(node as any).hash = "4d70861c6a4d4b06c753863cba054fc8";
export default node;

View File

@@ -0,0 +1,295 @@
/**
* @generated SignedSource<<ab4ee2b9dc7e4e5443dea3bd188d6dac>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type MeetingGraphListQuery$variables = {
organizationId: string;
};
export type MeetingGraphListQuery$data = {
readonly organization: {
readonly id: string;
readonly " $fragmentSpreads": FragmentRefs<"MeetingsPageListFragment">;
};
};
export type MeetingGraphListQuery = {
response: MeetingGraphListQuery$data;
variables: MeetingGraphListQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "organizationId"
}
],
v1 = [
{
"kind": "Variable",
"name": "id",
"variableName": "organizationId"
}
],
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
v4 = [
{
"kind": "Literal",
"name": "first",
"value": 50
},
{
"kind": "Literal",
"name": "orderBy",
"value": {
"direction": "DESC",
"field": "DATE"
}
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "MeetingGraphListQuery",
"selections": [
{
"alias": "organization",
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"args": null,
"kind": "FragmentSpread",
"name": "MeetingsPageListFragment"
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "MeetingGraphListQuery",
"selections": [
{
"alias": "organization",
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v3/*: any*/),
(v2/*: any*/),
{
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": null,
"concreteType": "OrganizationContext",
"kind": "LinkedField",
"name": "context",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "summary",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": (v4/*: any*/),
"concreteType": "MeetingConnection",
"kind": "LinkedField",
"name": "meetings",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "MeetingEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Meeting",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "date",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "People",
"kind": "LinkedField",
"name": "attendees",
"plural": true,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
}
],
"storageKey": null
},
(v3/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasPreviousPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "startCursor",
"storageKey": null
}
],
"storageKey": null
},
{
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
}
],
"storageKey": "meetings(first:50,orderBy:{\"direction\":\"DESC\",\"field\":\"DATE\"})"
},
{
"alias": null,
"args": (v4/*: any*/),
"filters": [
"orderBy"
],
"handle": "connection",
"key": "MeetingsListQuery_meetings",
"kind": "LinkedHandle",
"name": "meetings"
}
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "e320d73109abe57bb9fa89ade5fa0892",
"id": null,
"metadata": {},
"name": "MeetingGraphListQuery",
"operationKind": "query",
"text": "query MeetingGraphListQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n id\n ...MeetingsPageListFragment\n }\n}\n\nfragment MeetingsPageListFragment on Organization {\n id\n context {\n summary\n }\n meetings(first: 50, orderBy: {field: DATE, direction: DESC}) {\n edges {\n node {\n id\n ...MeetingsPageRowFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}\n\nfragment MeetingsPageRowFragment on Meeting {\n id\n name\n date\n attendees {\n id\n fullName\n }\n}\n"
}
};
})();
(node as any).hash = "be13ec2953f71c4955a5f46b93f0131f";
export default node;

View File

@@ -0,0 +1,162 @@
/**
* @generated SignedSource<<c19b786d81cd6f7adc4da4a4c3ad9346>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type MeetingGraphNodeQuery$variables = {
meetingId: string;
};
export type MeetingGraphNodeQuery$data = {
readonly node: {
readonly " $fragmentSpreads": FragmentRefs<"MeetingDetailPageMeetingFragment">;
};
};
export type MeetingGraphNodeQuery = {
response: MeetingGraphNodeQuery$data;
variables: MeetingGraphNodeQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "meetingId"
}
],
v1 = [
{
"kind": "Variable",
"name": "id",
"variableName": "meetingId"
}
],
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "MeetingGraphNodeQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"args": null,
"kind": "FragmentSpread",
"name": "MeetingDetailPageMeetingFragment"
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "MeetingGraphNodeQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
(v2/*: any*/),
{
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "date",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "minutes",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "People",
"kind": "LinkedField",
"name": "attendees",
"plural": true,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
}
],
"storageKey": null
}
],
"type": "Meeting",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "a4df3c8c55dc1bf379bba19af16448d7",
"id": null,
"metadata": {},
"name": "MeetingGraphNodeQuery",
"operationKind": "query",
"text": "query MeetingGraphNodeQuery(\n $meetingId: ID!\n) {\n node(id: $meetingId) {\n __typename\n ...MeetingDetailPageMeetingFragment\n id\n }\n}\n\nfragment MeetingDetailPageMeetingFragment on Meeting {\n id\n name\n date\n minutes\n attendees {\n id\n fullName\n }\n}\n"
}
};
})();
(node as any).hash = "08844ea87fd5de5c7fe55583373e0468";
export default node;

View File

@@ -0,0 +1,157 @@
/**
* @generated SignedSource<<3a5a992a984a073bb90922f29120be83>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type UpdateMeetingInput = {
attendeeIds?: ReadonlyArray<string> | null | undefined;
date?: any | null | undefined;
meetingId: string;
minutes?: string | null | undefined;
name?: string | null | undefined;
};
export type MeetingGraphUpdateMutation$variables = {
input: UpdateMeetingInput;
};
export type MeetingGraphUpdateMutation$data = {
readonly updateMeeting: {
readonly meeting: {
readonly attendees: ReadonlyArray<{
readonly fullName: string;
readonly id: string;
}>;
readonly date: any;
readonly id: string;
readonly minutes: string | null | undefined;
readonly name: string;
};
};
};
export type MeetingGraphUpdateMutation = {
response: MeetingGraphUpdateMutation$data;
variables: MeetingGraphUpdateMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v2 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "UpdateMeetingPayload",
"kind": "LinkedField",
"name": "updateMeeting",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Meeting",
"kind": "LinkedField",
"name": "meeting",
"plural": false,
"selections": [
(v1/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "date",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "minutes",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "People",
"kind": "LinkedField",
"name": "attendees",
"plural": true,
"selections": [
(v1/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "MeetingGraphUpdateMutation",
"selections": (v2/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "MeetingGraphUpdateMutation",
"selections": (v2/*: any*/)
},
"params": {
"cacheID": "e40a6b6cf200c6c90ca61a21c15452c1",
"id": null,
"metadata": {},
"name": "MeetingGraphUpdateMutation",
"operationKind": "mutation",
"text": "mutation MeetingGraphUpdateMutation(\n $input: UpdateMeetingInput!\n) {\n updateMeeting(input: $input) {\n meeting {\n id\n name\n date\n minutes\n attendees {\n id\n fullName\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "b3d9ca4361201376b0d5ab78ec1ef316";
export default node;

View File

@@ -21,6 +21,7 @@ import {
IconRotateCw,
IconCircleProgress,
IconMedal,
IconCalendar1,
Layout,
SidebarItem,
UserDropdown as UserDropdownRoot,
@@ -95,6 +96,11 @@ export function MainLayout() {
}
sidebar={
<ul className="space-y-[2px]">
<SidebarItem
label={__("Meetings")}
icon={IconCalendar1}
to={`${prefix}/meetings`}
/>
<SidebarItem
label={__("Tasks")}
icon={IconInboxEmpty}

View File

@@ -0,0 +1,148 @@
import type { PreloadedQuery } from "react-relay";
import { graphql, useFragment, usePreloadedQuery } from "react-relay";
import type { MeetingGraphNodeQuery } from "/hooks/graph/__generated__/MeetingGraphNodeQuery.graphql";
import { usePageTitle } from "@probo/hooks";
import type { MeetingDetailPageMeetingFragment$key } from "./__generated__/MeetingDetailPageMeetingFragment.graphql";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
Avatar,
Breadcrumb,
DropdownItem,
IconPencil,
IconTrashCan,
PageHeader,
useConfirm,
} from "@probo/ui";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { sprintf, formatDate } from "@probo/helpers";
import { Link, Outlet, useNavigate } from "react-router";
import {
UpdateMeetingMinutesDialog,
type UpdateMeetingMinutesDialogRef,
} from "./dialogs/UpdateMeetingMinutesDialog";
import { useRef } from "react";
import {
meetingNodeQuery,
useDeleteMeetingMutation,
} from "/hooks/graph/MeetingGraph";
const meetingFragment = graphql`
fragment MeetingDetailPageMeetingFragment on Meeting {
id
name
date
minutes
attendees {
id
fullName
}
}
`;
type Props = {
queryRef: PreloadedQuery<MeetingGraphNodeQuery>;
};
export default function MeetingDetailPage(props: Props) {
const node = usePreloadedQuery(meetingNodeQuery, props.queryRef).node;
const meeting = useFragment<MeetingDetailPageMeetingFragment$key>(
meetingFragment,
node
);
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const navigate = useNavigate();
if (!meeting) {
return <div>{__("Meeting not found")}</div>;
}
const [deleteMeeting, isDeleting] = useDeleteMeetingMutation();
const confirm = useConfirm();
const updateMinutesDialogRef = useRef<UpdateMeetingMinutesDialogRef>(null);
usePageTitle(meeting.name);
const handleDelete = () => {
confirm(
() =>
deleteMeeting({
variables: {
input: { meetingId: meeting.id },
},
onSuccess: () => {
navigate(`/organizations/${organizationId}/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}/meetings`,
},
{
label: meeting.name,
},
]}
/>
<ActionDropdown variant="secondary">
<DropdownItem
onClick={() => updateMinutesDialogRef.current?.open()}
icon={IconPencil}
>
{__("Edit minutes")}
</DropdownItem>
<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

@@ -0,0 +1,381 @@
import { useTranslate } from "@probo/i18n";
import {
PageHeader,
Tbody,
Thead,
Tr,
Th,
Td,
Avatar,
IconTrashCan,
Button,
IconPlusLarge,
useConfirm,
ActionDropdown,
DropdownItem,
Card,
Textarea,
Markdown,
IconPencil,
IconCheckmark1,
IconCrossLargeX,
} from "@probo/ui";
import {
useFragment,
usePaginationFragment,
usePreloadedQuery,
type PreloadedQuery,
} from "react-relay";
import { graphql } from "relay-runtime";
import type { MeetingGraphListQuery } from "/hooks/graph/__generated__/MeetingGraphListQuery.graphql";
import {
meetingsQuery,
useDeleteMeetingMutation,
} from "/hooks/graph/MeetingGraph";
import type { MeetingsPageListFragment$key } from "./__generated__/MeetingsPageListFragment.graphql";
import { usePageTitle } from "@probo/hooks";
import { formatDate, sprintf } from "@probo/helpers";
import { CreateMeetingDialog } from "./dialogs/CreateMeetingDialog";
import type { MeetingsPageRowFragment$key } from "./__generated__/MeetingsPageRowFragment.graphql";
import { SortableTable, SortableTh } from "/components/SortableTable";
import { Link } from "react-router";
import { useState, useEffect, useRef } from "react";
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
import type { MeetingsPage_UpdateSummaryMutation } from "./__generated__/MeetingsPage_UpdateSummaryMutation.graphql";
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
context {
summary
}
meetings(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
) @connection(key: "MeetingsListQuery_meetings") {
__id
edges {
node {
id
...MeetingsPageRowFragment
}
}
}
}
`;
type Props = {
queryRef: PreloadedQuery<MeetingGraphListQuery>;
};
export default function MeetingsPage(props: Props) {
const { __ } = useTranslate();
const organization = usePreloadedQuery(
meetingsQuery,
props.queryRef
).organization;
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;
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);
// Update summary text when context.summary changes (but not while editing)
useEffect(() => {
if (!isEditing && !justSavedRef.current) {
const newSummary = pagination.data.context?.summary || "";
setSummaryText(newSummary);
setDisplayedSummary(newSummary);
}
// Reset the flag after the effect runs
if (justSavedRef.current) {
justSavedRef.current = false;
}
}, [pagination.data.context?.summary, isEditing]);
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 = () => {
const valueToSave = summaryText.trim();
const previousValue = pagination.data.context?.summary || "";
setDisplayedSummary(valueToSave);
justSavedRef.current = true;
setIsEditing(false);
const valueToSend = valueToSave.length > 0 ? valueToSave : "";
updateSummary({
variables: {
input: {
organizationId: organization.id,
summary: valueToSend || null,
},
},
onError: () => {
// Roll back optimistic update on error
setDisplayedSummary(previousValue);
justSavedRef.current = false;
},
onCompleted: (_response, error) => {
if (error) {
// Roll back optimistic update on GraphQL error
setDisplayedSummary(previousValue);
justSavedRef.current = 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={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>
<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."
)}
>
<CreateMeetingDialog connectionId={connectionId}>
<Button icon={IconPlusLarge}>{__("Add meeting")}</Button>
</CreateMeetingDialog>
</PageHeader>
{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
}
}
`;
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}/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>
<Td noLink width={50} className="text-end w-18">
<ActionDropdown>
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={handleDelete}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
</Td>
</Tr>
);
}

View File

@@ -0,0 +1,92 @@
/**
* @generated SignedSource<<3684f6d6c3a9746dcfcb8bb0156d2201>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type MeetingDetailPageMeetingFragment$data = {
readonly attendees: ReadonlyArray<{
readonly fullName: string;
readonly id: string;
}>;
readonly date: any;
readonly id: string;
readonly minutes: string | null | undefined;
readonly name: string;
readonly " $fragmentType": "MeetingDetailPageMeetingFragment";
};
export type MeetingDetailPageMeetingFragment$key = {
readonly " $data"?: MeetingDetailPageMeetingFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"MeetingDetailPageMeetingFragment">;
};
const node: ReaderFragment = (function(){
var v0 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
};
return {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "MeetingDetailPageMeetingFragment",
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "date",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "minutes",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "People",
"kind": "LinkedField",
"name": "attendees",
"plural": true,
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
}
],
"storageKey": null
}
],
"type": "Meeting",
"abstractKey": null
};
})();
(node as any).hash = "ae1564a2e5115bfffe20359c5288d4eb";
export default node;

View File

@@ -0,0 +1,370 @@
/**
* @generated SignedSource<<829d26690dc2a6f09541f02ef8981395>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type MeetingOrderField = "CREATED_AT" | "DATE" | "NAME";
export type OrderDirection = "ASC" | "DESC";
export type MeetingOrder = {
direction: OrderDirection;
field: MeetingOrderField;
};
export type MeetingsListQuery$variables = {
after?: any | null | undefined;
before?: any | null | undefined;
first?: number | null | undefined;
id: string;
last?: number | null | undefined;
order?: MeetingOrder | null | undefined;
};
export type MeetingsListQuery$data = {
readonly node: {
readonly " $fragmentSpreads": FragmentRefs<"MeetingsPageListFragment">;
};
};
export type MeetingsListQuery = {
response: MeetingsListQuery$data;
variables: MeetingsListQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "after"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "before"
},
v2 = {
"defaultValue": 50,
"kind": "LocalArgument",
"name": "first"
},
v3 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "id"
},
v4 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "last"
},
v5 = {
"defaultValue": {
"direction": "DESC",
"field": "DATE"
},
"kind": "LocalArgument",
"name": "order"
},
v6 = [
{
"kind": "Variable",
"name": "id",
"variableName": "id"
}
],
v7 = {
"kind": "Variable",
"name": "after",
"variableName": "after"
},
v8 = {
"kind": "Variable",
"name": "before",
"variableName": "before"
},
v9 = {
"kind": "Variable",
"name": "first",
"variableName": "first"
},
v10 = {
"kind": "Variable",
"name": "last",
"variableName": "last"
},
v11 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
v12 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v13 = [
(v7/*: any*/),
(v8/*: any*/),
(v9/*: any*/),
(v10/*: any*/),
{
"kind": "Variable",
"name": "orderBy",
"variableName": "order"
}
];
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/),
(v2/*: any*/),
(v3/*: any*/),
(v4/*: any*/),
(v5/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "MeetingsListQuery",
"selections": [
{
"alias": null,
"args": (v6/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"args": [
(v7/*: any*/),
(v8/*: any*/),
(v9/*: any*/),
(v10/*: any*/),
{
"kind": "Variable",
"name": "order",
"variableName": "order"
}
],
"kind": "FragmentSpread",
"name": "MeetingsPageListFragment"
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/),
(v2/*: any*/),
(v4/*: any*/),
(v5/*: any*/),
(v3/*: any*/)
],
"kind": "Operation",
"name": "MeetingsListQuery",
"selections": [
{
"alias": null,
"args": (v6/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v11/*: any*/),
(v12/*: any*/),
{
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": null,
"concreteType": "OrganizationContext",
"kind": "LinkedField",
"name": "context",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "summary",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": (v13/*: any*/),
"concreteType": "MeetingConnection",
"kind": "LinkedField",
"name": "meetings",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "MeetingEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Meeting",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v12/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "date",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "People",
"kind": "LinkedField",
"name": "attendees",
"plural": true,
"selections": [
(v12/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
}
],
"storageKey": null
},
(v11/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasPreviousPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "startCursor",
"storageKey": null
}
],
"storageKey": null
},
{
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
}
],
"storageKey": null
},
{
"alias": null,
"args": (v13/*: any*/),
"filters": [
"orderBy"
],
"handle": "connection",
"key": "MeetingsListQuery_meetings",
"kind": "LinkedHandle",
"name": "meetings"
}
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "e5e74555d8a277638d2278c5b71b930b",
"id": null,
"metadata": {},
"name": "MeetingsListQuery",
"operationKind": "query",
"text": "query MeetingsListQuery(\n $after: CursorKey = null\n $before: CursorKey = null\n $first: Int = 50\n $last: Int = null\n $order: MeetingOrder = {field: DATE, direction: DESC}\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...MeetingsPageListFragment_16fISc\n id\n }\n}\n\nfragment MeetingsPageListFragment_16fISc on Organization {\n id\n context {\n summary\n }\n meetings(first: $first, after: $after, last: $last, before: $before, orderBy: $order) {\n edges {\n node {\n id\n ...MeetingsPageRowFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}\n\nfragment MeetingsPageRowFragment on Meeting {\n id\n name\n date\n attendees {\n id\n fullName\n }\n}\n"
}
};
})();
(node as any).hash = "2fe5f058a857ddd4566169d220fb3310";
export default node;

View File

@@ -0,0 +1,249 @@
/**
* @generated SignedSource<<5609ac7dd3b3fb18c95cfa91381582cf>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type MeetingsPageListFragment$data = {
readonly context: {
readonly summary: string | null | undefined;
} | null | undefined;
readonly id: string;
readonly meetings: {
readonly __id: string;
readonly edges: ReadonlyArray<{
readonly node: {
readonly id: string;
readonly " $fragmentSpreads": FragmentRefs<"MeetingsPageRowFragment">;
};
}>;
};
readonly " $fragmentType": "MeetingsPageListFragment";
};
export type MeetingsPageListFragment$key = {
readonly " $data"?: MeetingsPageListFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"MeetingsPageListFragment">;
};
import MeetingsListQuery_graphql from './MeetingsListQuery.graphql';
const node: ReaderFragment = (function(){
var v0 = [
"meetings"
],
v1 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
};
return {
"argumentDefinitions": [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "after"
},
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "before"
},
{
"defaultValue": 50,
"kind": "LocalArgument",
"name": "first"
},
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "last"
},
{
"defaultValue": {
"direction": "DESC",
"field": "DATE"
},
"kind": "LocalArgument",
"name": "order"
}
],
"kind": "Fragment",
"metadata": {
"connection": [
{
"count": null,
"cursor": null,
"direction": "bidirectional",
"path": (v0/*: any*/)
}
],
"refetch": {
"connection": {
"forward": {
"count": "first",
"cursor": "after"
},
"backward": {
"count": "last",
"cursor": "before"
},
"path": (v0/*: any*/)
},
"fragmentPathInResult": [
"node"
],
"operation": MeetingsListQuery_graphql,
"identifierInfo": {
"identifierField": "id",
"identifierQueryVariableName": "id"
}
}
},
"name": "MeetingsPageListFragment",
"selections": [
(v1/*: any*/),
{
"alias": null,
"args": null,
"concreteType": "OrganizationContext",
"kind": "LinkedField",
"name": "context",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "summary",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": "meetings",
"args": [
{
"kind": "Variable",
"name": "orderBy",
"variableName": "order"
}
],
"concreteType": "MeetingConnection",
"kind": "LinkedField",
"name": "__MeetingsListQuery_meetings_connection",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "MeetingEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Meeting",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v1/*: any*/),
{
"args": null,
"kind": "FragmentSpread",
"name": "MeetingsPageRowFragment"
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasPreviousPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "startCursor",
"storageKey": null
}
],
"storageKey": null
},
{
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
}
],
"storageKey": null
}
],
"type": "Organization",
"abstractKey": null
};
})();
(node as any).hash = "2fe5f058a857ddd4566169d220fb3310";
export default node;

View File

@@ -0,0 +1,84 @@
/**
* @generated SignedSource<<7efb971ec8052cecbd96f86ea6d3af4c>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type MeetingsPageRowFragment$data = {
readonly attendees: ReadonlyArray<{
readonly fullName: string;
readonly id: string;
}>;
readonly date: any;
readonly id: string;
readonly name: string;
readonly " $fragmentType": "MeetingsPageRowFragment";
};
export type MeetingsPageRowFragment$key = {
readonly " $data"?: MeetingsPageRowFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"MeetingsPageRowFragment">;
};
const node: ReaderFragment = (function(){
var v0 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
};
return {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "MeetingsPageRowFragment",
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "date",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "People",
"kind": "LinkedField",
"name": "attendees",
"plural": true,
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
}
],
"storageKey": null
}
],
"type": "Meeting",
"abstractKey": null
};
})();
(node as any).hash = "91ba43abb559bed5acdda60b8f61bda9";
export default node;

View File

@@ -0,0 +1,106 @@
/**
* @generated SignedSource<<60e0c9d7301cff5c1df299e76debb633>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type UpdateOrganizationContextInput = {
organizationId: string;
summary?: string | null | undefined;
};
export type MeetingsPage_UpdateSummaryMutation$variables = {
input: UpdateOrganizationContextInput;
};
export type MeetingsPage_UpdateSummaryMutation$data = {
readonly updateOrganizationContext: {
readonly context: {
readonly summary: string | null | undefined;
};
};
};
export type MeetingsPage_UpdateSummaryMutation = {
response: MeetingsPage_UpdateSummaryMutation$data;
variables: MeetingsPage_UpdateSummaryMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "UpdateOrganizationContextPayload",
"kind": "LinkedField",
"name": "updateOrganizationContext",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "OrganizationContext",
"kind": "LinkedField",
"name": "context",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "summary",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "MeetingsPage_UpdateSummaryMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "MeetingsPage_UpdateSummaryMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "e6bead1dde5239f3cfd2fa1440191454",
"id": null,
"metadata": {},
"name": "MeetingsPage_UpdateSummaryMutation",
"operationKind": "mutation",
"text": "mutation MeetingsPage_UpdateSummaryMutation(\n $input: UpdateOrganizationContextInput!\n) {\n updateOrganizationContext(input: $input) {\n context {\n summary\n }\n }\n}\n"
}
};
})();
(node as any).hash = "f354a34b18a449f02508c45d0e7d9dc5";
export default node;

View File

@@ -0,0 +1,116 @@
import { useTranslate } from "@probo/i18n";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
Field,
Input,
Spinner,
useDialogRef,
} from "@probo/ui";
import { graphql } from "relay-runtime";
import { useMutation } from "react-relay";
import { z } from "zod";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
import type { CreateMeetingDialogCreateMutation } from "./__generated__/CreateMeetingDialogCreateMutation.graphql";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { PeopleMultiSelectField } from "/components/form/PeopleMultiSelectField";
import { formatDatetime } from "@probo/helpers";
const createMeetingMutation = graphql`
mutation CreateMeetingDialogCreateMutation(
$input: CreateMeetingInput!
$connections: [ID!]!
) {
createMeeting(input: $input) {
meetingEdge @prependEdge(connections: $connections) {
node {
id
name
date
minutes
attendees {
id
fullName
}
}
}
}
}
`;
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 = handleSubmit((data) => {
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={onSubmit}>
<DialogContent>
<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")}
/>
</DialogContent>
<DialogFooter>
<Button disabled={isCreating} type="submit">
{isCreating && <Spinner />}
{__("Create meeting")}
</Button>
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -0,0 +1,93 @@
import { useTranslate } from "@probo/i18n";
import {
Breadcrumb,
Button,
Dialog,
DialogContent,
DialogFooter,
Spinner,
Textarea,
useDialogRef,
} from "@probo/ui";
import { forwardRef, useImperativeHandle } from "react";
import { z } from "zod";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
import { useUpdateMeetingMutation } from "/hooks/graph/MeetingGraph";
import type { MeetingDetailPageMeetingFragment$data } from "../__generated__/MeetingDetailPageMeetingFragment.graphql";
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] = useUpdateMeetingMutation();
const { handleSubmit, register, reset } = useFormWithSchema(minutesSchema, {
defaultValues: {
minutes: meeting.minutes || "",
},
});
useImperativeHandle(ref, () => ({
open: () => {
reset({
minutes: meeting.minutes || "",
});
dialogRef.current?.open();
},
}));
const onSubmit = handleSubmit((data) => {
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={onSubmit}>
<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

@@ -0,0 +1,210 @@
/**
* @generated SignedSource<<fb3a05b463e9f6065118d1b4441ce398>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type CreateMeetingInput = {
attendeeIds?: ReadonlyArray<string> | null | undefined;
date: any;
minutes?: string | null | undefined;
name: string;
organizationId: string;
};
export type CreateMeetingDialogCreateMutation$variables = {
connections: ReadonlyArray<string>;
input: CreateMeetingInput;
};
export type CreateMeetingDialogCreateMutation$data = {
readonly createMeeting: {
readonly meetingEdge: {
readonly node: {
readonly attendees: ReadonlyArray<{
readonly fullName: string;
readonly id: string;
}>;
readonly date: any;
readonly id: string;
readonly minutes: string | null | undefined;
readonly name: string;
};
};
};
};
export type CreateMeetingDialogCreateMutation = {
response: CreateMeetingDialogCreateMutation$data;
variables: CreateMeetingDialogCreateMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "connections"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
},
v2 = [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v4 = {
"alias": null,
"args": null,
"concreteType": "MeetingEdge",
"kind": "LinkedField",
"name": "meetingEdge",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Meeting",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "date",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "minutes",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "People",
"kind": "LinkedField",
"name": "attendees",
"plural": true,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "CreateMeetingDialogCreateMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateMeetingPayload",
"kind": "LinkedField",
"name": "createMeeting",
"plural": false,
"selections": [
(v4/*: any*/)
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "CreateMeetingDialogCreateMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateMeetingPayload",
"kind": "LinkedField",
"name": "createMeeting",
"plural": false,
"selections": [
(v4/*: any*/),
{
"alias": null,
"args": null,
"filters": null,
"handle": "prependEdge",
"key": "",
"kind": "LinkedHandle",
"name": "meetingEdge",
"handleArgs": [
{
"kind": "Variable",
"name": "connections",
"variableName": "connections"
}
]
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "cf37e45c7e8cffd8b44cdc85488209e8",
"id": null,
"metadata": {},
"name": "CreateMeetingDialogCreateMutation",
"operationKind": "mutation",
"text": "mutation CreateMeetingDialogCreateMutation(\n $input: CreateMeetingInput!\n) {\n createMeeting(input: $input) {\n meetingEdge {\n node {\n id\n name\n date\n minutes\n attendees {\n id\n fullName\n }\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "5ba1bb7a0344e0ea19b383af6d75534f";
export default node;

View File

@@ -0,0 +1,22 @@
import { useOutletContext } from "react-router";
import type { MeetingDetailPageMeetingFragment$data } from "../__generated__/MeetingDetailPageMeetingFragment.graphql";
import { Markdown } from "@probo/ui";
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

@@ -29,6 +29,7 @@ import { taskRoutes } from "./routes/taskRoutes.ts";
import { dataRoutes } from "./routes/dataRoutes.ts";
import { assetRoutes } from "./routes/assetRoutes.ts";
import { auditRoutes } from "./routes/auditRoutes.ts";
import { meetingsRoutes } from "./routes/meetingsRoutes.ts";
import { trustCenterRoutes } from "./routes/trustCenterRoutes.ts";
import { nonconformityRoutes } from "./routes/nonconformityRoutes.ts";
import { obligationRoutes } from "./routes/obligationRoutes.ts";
@@ -175,6 +176,7 @@ const routes = [
...assetRoutes,
...dataRoutes,
...auditRoutes,
...meetingsRoutes,
...nonconformityRoutes,
...obligationRoutes,
...continualImprovementRoutes,

View File

@@ -0,0 +1,57 @@
import { Fragment } from "react";
import { loadQuery } from "react-relay";
import type { AppRoute } from "/routes.tsx";
import { relayEnvironment } from "/providers/RelayProviders";
import { meetingsQuery } from "/hooks/graph/MeetingGraph";
import { meetingNodeQuery } from "/hooks/graph/MeetingGraph";
import { PageSkeleton } from "/components/skeletons/PageSkeleton";
import { redirect } from "react-router";
import { lazy } from "@probo/react-lazy";
import { LinkCardSkeleton } from "/components/skeletons/LinkCardSkeleton";
const meetingTabs = (prefix: string) => {
return [
{
path: `${prefix}`,
queryLoader: ({ organizationId, meetingId }) => {
const basePath = `/organizations/${organizationId}/meetings/${meetingId}`;
const redirectPath = `${basePath}/minutes`;
throw redirect(redirectPath);
},
Component: Fragment,
},
{
path: `${prefix}minutes`,
fallback: LinkCardSkeleton,
Component: lazy(
() =>
import(
"../pages/organizations/meetings/tabs/MeetingMinutesTab"
),
),
},
] satisfies AppRoute[];
};
export const meetingsRoutes = [
{
path: "meetings",
fallback: PageSkeleton,
queryLoader: ({ organizationId }) =>
loadQuery(relayEnvironment, meetingsQuery, { organizationId }),
Component: lazy(
() => import("/pages/organizations/meetings/MeetingsPage"),
),
},
{
path: "meetings/:meetingId",
fallback: PageSkeleton,
queryLoader: ({ meetingId }) =>
loadQuery(relayEnvironment, meetingNodeQuery, { meetingId }),
Component: lazy(
() => import("../pages/organizations/meetings/MeetingDetailPage"),
),
children: [...meetingTabs("")],
},
] satisfies AppRoute[];