diff --git a/apps/console/src/components/form/PeopleMultiSelectField.tsx b/apps/console/src/components/form/PeopleMultiSelectField.tsx new file mode 100644 index 000000000..886d9b930 --- /dev/null +++ b/apps/console/src/components/form/PeopleMultiSelectField.tsx @@ -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 = { + organizationId: string; + control: Control; + name: string; + label?: string; + error?: string; + selectedPeople?: Person[]; +} & ComponentProps; + +export function PeopleMultiSelectField({ + organizationId, + control, + selectedPeople = [], + ...props +}: Props) { + return ( + + } + > + + + + ); +} + +function PeopleMultiSelectWithQuery( + props: Pick, "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 ( + <> + } + 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 ( +
+ {availablePeople.length > 0 && !props.disabled && ( + + )} + + {selectedPeople.length > 0 && ( +
+ {selectedPeople.map((person) => ( + + + {person.fullName} + {!props.disabled && ( +
+ )} + + {selectedPeople.length === 0 && availablePeople.length === 0 && ( +
+ {__("No people available")} +
+ )} +
+ ); + }} + /> + + ); +} + diff --git a/apps/console/src/hooks/graph/MeetingGraph.ts b/apps/console/src/hooks/graph/MeetingGraph.ts new file mode 100644 index 000000000..af3c72bf6 --- /dev/null +++ b/apps/console/src/hooks/graph/MeetingGraph.ts @@ -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( + 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( + 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"), + } + ); +} + diff --git a/apps/console/src/hooks/graph/__generated__/MeetingGraphCreateMutation.graphql.ts b/apps/console/src/hooks/graph/__generated__/MeetingGraphCreateMutation.graphql.ts new file mode 100644 index 000000000..9fa70bb2b --- /dev/null +++ b/apps/console/src/hooks/graph/__generated__/MeetingGraphCreateMutation.graphql.ts @@ -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 | null | undefined; + date: any; + minutes?: string | null | undefined; + name: string; + organizationId: string; +}; +export type MeetingGraphCreateMutation$variables = { + connections: ReadonlyArray; + 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; diff --git a/apps/console/src/hooks/graph/__generated__/MeetingGraphDeleteMutation.graphql.ts b/apps/console/src/hooks/graph/__generated__/MeetingGraphDeleteMutation.graphql.ts new file mode 100644 index 000000000..d344fc4ac --- /dev/null +++ b/apps/console/src/hooks/graph/__generated__/MeetingGraphDeleteMutation.graphql.ts @@ -0,0 +1,115 @@ +/** + * @generated SignedSource<> + * @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; diff --git a/apps/console/src/hooks/graph/__generated__/MeetingGraphListQuery.graphql.ts b/apps/console/src/hooks/graph/__generated__/MeetingGraphListQuery.graphql.ts new file mode 100644 index 000000000..70710e787 --- /dev/null +++ b/apps/console/src/hooks/graph/__generated__/MeetingGraphListQuery.graphql.ts @@ -0,0 +1,295 @@ +/** + * @generated SignedSource<> + * @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; diff --git a/apps/console/src/hooks/graph/__generated__/MeetingGraphNodeQuery.graphql.ts b/apps/console/src/hooks/graph/__generated__/MeetingGraphNodeQuery.graphql.ts new file mode 100644 index 000000000..c2afd886f --- /dev/null +++ b/apps/console/src/hooks/graph/__generated__/MeetingGraphNodeQuery.graphql.ts @@ -0,0 +1,162 @@ +/** + * @generated SignedSource<> + * @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; diff --git a/apps/console/src/hooks/graph/__generated__/MeetingGraphUpdateMutation.graphql.ts b/apps/console/src/hooks/graph/__generated__/MeetingGraphUpdateMutation.graphql.ts new file mode 100644 index 000000000..e0f07a85e --- /dev/null +++ b/apps/console/src/hooks/graph/__generated__/MeetingGraphUpdateMutation.graphql.ts @@ -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 | 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; diff --git a/apps/console/src/layouts/MainLayout.tsx b/apps/console/src/layouts/MainLayout.tsx index b4d793306..93ca4f5a7 100644 --- a/apps/console/src/layouts/MainLayout.tsx +++ b/apps/console/src/layouts/MainLayout.tsx @@ -21,6 +21,7 @@ import { IconRotateCw, IconCircleProgress, IconMedal, + IconCalendar1, Layout, SidebarItem, UserDropdown as UserDropdownRoot, @@ -95,6 +96,11 @@ export function MainLayout() { } sidebar={
    + ; +}; + +export default function MeetingDetailPage(props: Props) { + const node = usePreloadedQuery(meetingNodeQuery, props.queryRef).node; + const meeting = useFragment( + meetingFragment, + node + ); + const { __ } = useTranslate(); + const organizationId = useOrganizationId(); + const navigate = useNavigate(); + + if (!meeting) { + return
    {__("Meeting not found")}
    ; + } + + const [deleteMeeting, isDeleting] = useDeleteMeetingMutation(); + const confirm = useConfirm(); + const updateMinutesDialogRef = useRef(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 ( + <> + +
    +
    + + + updateMinutesDialogRef.current?.open()} + icon={IconPencil} + > + {__("Edit minutes")} + + + {__("Delete meeting")} + + +
    + + {meeting.attendees && meeting.attendees.length > 0 && ( +
    + {meeting.attendees.map((attendee) => ( +
    + + + {attendee.fullName} + +
    + ))} +
    + )} + +
    + + ); +} diff --git a/apps/console/src/pages/organizations/meetings/MeetingsPage.tsx b/apps/console/src/pages/organizations/meetings/MeetingsPage.tsx new file mode 100644 index 000000000..05c0f5caa --- /dev/null +++ b/apps/console/src/pages/organizations/meetings/MeetingsPage.tsx @@ -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; +}; + +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( + 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 ( +
    + + {isEditing ? ( +
    +