From dfaf1dbdfc12996ae0c7fa4f47a82b5ab97c434c Mon Sep 17 00:00:00 2001 From: fvishw Date: Fri, 27 Feb 2026 19:12:32 +0530 Subject: [PATCH] feat: implement one-on-one meeting notes with Lexical rich text editor Add full meeting management feature allowing admins to schedule 1:1 meetings with employees, write rich-text notes using a Lexical editor, and manage meetings through a filterable table view with CRUD operations. Co-Authored-By: Claude Opus 4.6 --- client/package-lock.json | 407 +++++++++++++++++- client/package.json | 6 + client/src/api/api.ts | 44 +- .../meetings/DeleteMeetingModal.tsx | 49 +++ .../src/components/meetings/LexicalEditor.tsx | 195 +++++++++ .../src/components/meetings/MeetingFilter.tsx | 83 ++++ .../components/meetings/MeetingFormDialog.tsx | 156 +++++++ .../components/meetings/MeetingManagement.tsx | 80 ++++ .../meetings/MeetingNotesDialog.tsx | 93 ++++ .../meetings/meetingTable.config.tsx | 79 ++++ .../meetings/meetingTableAction.tsx | 100 +++++ client/src/components/myGoals/myGoals.tsx | 47 +- client/src/components/side-bar-items.ts | 6 + client/src/pages/outlets/meetingsLayout.tsx | 16 + client/src/routes/routes.ts | 4 + client/src/types/apiResponse.ts | 27 ++ server/src/app.ts | 2 + server/src/controllers/goal.controller.ts | 15 +- server/src/controllers/meeting.controller.ts | 164 +++++++ server/src/models/meeting.model.ts | 68 +++ server/src/routes/meeting.route.ts | 23 + server/src/types/goal.ts | 4 +- server/src/types/meeting.ts | 18 + 23 files changed, 1657 insertions(+), 29 deletions(-) create mode 100644 client/src/components/meetings/DeleteMeetingModal.tsx create mode 100644 client/src/components/meetings/LexicalEditor.tsx create mode 100644 client/src/components/meetings/MeetingFilter.tsx create mode 100644 client/src/components/meetings/MeetingFormDialog.tsx create mode 100644 client/src/components/meetings/MeetingManagement.tsx create mode 100644 client/src/components/meetings/MeetingNotesDialog.tsx create mode 100644 client/src/components/meetings/meetingTable.config.tsx create mode 100644 client/src/components/meetings/meetingTableAction.tsx create mode 100644 client/src/pages/outlets/meetingsLayout.tsx create mode 100644 server/src/controllers/meeting.controller.ts create mode 100644 server/src/models/meeting.model.ts create mode 100644 server/src/routes/meeting.route.ts create mode 100644 server/src/types/meeting.ts diff --git a/client/package-lock.json b/client/package-lock.json index f485ab7..f419ee4 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -13,6 +13,11 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@hookform/error-message": "^2.0.1", + "@lexical/html": "^0.41.0", + "@lexical/link": "^0.41.0", + "@lexical/list": "^0.41.0", + "@lexical/react": "^0.41.0", + "@lexical/rich-text": "^0.41.0", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", @@ -38,6 +43,7 @@ "dayjs": "^1.11.19", "input-otp": "^1.4.2", "jwt-decode": "^4.0.0", + "lexical": "^0.41.0", "lucide-react": "^0.555.0", "next-themes": "^0.4.6", "react": "^19.2.0", @@ -1167,31 +1173,46 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", - "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", + "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", "license": "MIT", "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", - "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", + "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.3", + "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, + "node_modules/@floating-ui/react": { + "version": "0.27.18", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.18.tgz", + "integrity": "sha512-xJWJxvmy3a05j643gQt+pRbht5XnTlGpsEsAPnMi5F5YTOEEJymA90uZKBD8OvIv5XvZ1qi4GcccSlqT3Bq44Q==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.7", + "@floating-ui/utils": "^0.2.10", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", - "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz", + "integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==", "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.7.4" + "@floating-ui/dom": "^1.7.5" }, "peerDependencies": { "react": ">=16.8.0", @@ -1333,6 +1354,289 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lexical/clipboard": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.41.0.tgz", + "integrity": "sha512-Ex5lPkb4NBBX1DCPzOAIeHBJFH1bJcmATjREaqpnTfxCbuOeQkt44wchezUA0oDl+iAxNZ3+pLLWiUju9icoSA==", + "license": "MIT", + "dependencies": { + "@lexical/html": "0.41.0", + "@lexical/list": "0.41.0", + "@lexical/selection": "0.41.0", + "@lexical/utils": "0.41.0", + "lexical": "0.41.0" + } + }, + "node_modules/@lexical/code": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@lexical/code/-/code-0.41.0.tgz", + "integrity": "sha512-0hoNi1KC9/N3SBOGcOcFqnT0OpwmcRRAhfxTKMGqfCtCvAMzULVwZ8RWc9/NV9bKYESgBTW5D9xkDANP2mspHg==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.41.0", + "lexical": "0.41.0", + "prismjs": "^1.30.0" + } + }, + "node_modules/@lexical/devtools-core": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@lexical/devtools-core/-/devtools-core-0.41.0.tgz", + "integrity": "sha512-FzJtluBhBc8bKS11TUZe72KoZN/hnzIyiiM0SPJAsPwGpoXuM01jqpXQGybWf/1bWB+bmmhOae7O4Nywi/Csuw==", + "license": "MIT", + "dependencies": { + "@lexical/html": "0.41.0", + "@lexical/link": "0.41.0", + "@lexical/mark": "0.41.0", + "@lexical/table": "0.41.0", + "@lexical/utils": "0.41.0", + "lexical": "0.41.0" + }, + "peerDependencies": { + "react": ">=17.x", + "react-dom": ">=17.x" + } + }, + "node_modules/@lexical/dragon": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@lexical/dragon/-/dragon-0.41.0.tgz", + "integrity": "sha512-gBEqkk8Q6ZPruvDaRcOdF1EK9suCVBODzOCcR+EnoJTaTjfDkCM7pkPAm4w90Wa1wCZEtFHvCfas+jU9MDSumg==", + "license": "MIT", + "dependencies": { + "@lexical/extension": "0.41.0", + "lexical": "0.41.0" + } + }, + "node_modules/@lexical/extension": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@lexical/extension/-/extension-0.41.0.tgz", + "integrity": "sha512-sF4SPiP72yXvIGchmmIZ7Yg2XZTxNLOpFEIIzdqG7X/1fa1Ham9P/T7VbrblWpF6Ei5LJtK9JgNVB0hb4l3o1g==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.41.0", + "@preact/signals-core": "^1.11.0", + "lexical": "0.41.0" + } + }, + "node_modules/@lexical/hashtag": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@lexical/hashtag/-/hashtag-0.41.0.tgz", + "integrity": "sha512-tFWM74RW4KU0E/sj2aowfWl26vmLUTp331CgVESnhQKcZBfT40KJYd57HEqBDTfQKn4MUhylQCCA0hbpw6EeFQ==", + "license": "MIT", + "dependencies": { + "@lexical/text": "0.41.0", + "@lexical/utils": "0.41.0", + "lexical": "0.41.0" + } + }, + "node_modules/@lexical/history": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.41.0.tgz", + "integrity": "sha512-kGoVWsiOn62+RMjRolRa+NXZl8jFwxav6GNDiHH8yzivtoaH8n1SwUfLJELXCzeqzs81HySqD4q30VLJVTGoDg==", + "license": "MIT", + "dependencies": { + "@lexical/extension": "0.41.0", + "@lexical/utils": "0.41.0", + "lexical": "0.41.0" + } + }, + "node_modules/@lexical/html": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.41.0.tgz", + "integrity": "sha512-3RyZy+H/IDKz2D66rNN/NqYx87xVFrngfEbyu1OWtbY963RUFnopiVHCQvsge/8kT04QSZ7U/DzjVFqeNS6clg==", + "license": "MIT", + "dependencies": { + "@lexical/selection": "0.41.0", + "@lexical/utils": "0.41.0", + "lexical": "0.41.0" + } + }, + "node_modules/@lexical/link": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.41.0.tgz", + "integrity": "sha512-Rjtx5cGWAkKcnacncbVsZ1TqRnUB2Wm4eEVKpaAEG41+kHgqghzM2P+UGT15yROroxJu8KvAC9ISiYFiU4XE1w==", + "license": "MIT", + "dependencies": { + "@lexical/extension": "0.41.0", + "@lexical/utils": "0.41.0", + "lexical": "0.41.0" + } + }, + "node_modules/@lexical/list": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.41.0.tgz", + "integrity": "sha512-RXvB+xcbzVoQLGRDOBRCacztG7V+bI95tdoTwl8pz5xvgPtAaRnkZWMDP+yMNzMJZsqEChdtpxbf0NgtMkun6g==", + "license": "MIT", + "dependencies": { + "@lexical/extension": "0.41.0", + "@lexical/selection": "0.41.0", + "@lexical/utils": "0.41.0", + "lexical": "0.41.0" + } + }, + "node_modules/@lexical/mark": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@lexical/mark/-/mark-0.41.0.tgz", + "integrity": "sha512-UO5WVs9uJAYIKHSlYh4Z1gHrBBchTOi21UCYBIZ7eAs4suK84hPzD+3/LAX5CB7ZltL6ke5Sly3FOwNXv/wfpA==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.41.0", + "lexical": "0.41.0" + } + }, + "node_modules/@lexical/markdown": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@lexical/markdown/-/markdown-0.41.0.tgz", + "integrity": "sha512-bzI73JMXpjGFhqUWNV6KqfjWcgAWzwFT+J3RHtbCF5rysC8HLldBYojOgAAtPfXqfxyv2mDzsY7SoJ75s9uHZA==", + "license": "MIT", + "dependencies": { + "@lexical/code": "0.41.0", + "@lexical/link": "0.41.0", + "@lexical/list": "0.41.0", + "@lexical/rich-text": "0.41.0", + "@lexical/text": "0.41.0", + "@lexical/utils": "0.41.0", + "lexical": "0.41.0" + } + }, + "node_modules/@lexical/offset": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@lexical/offset/-/offset-0.41.0.tgz", + "integrity": "sha512-2RHBXZqC8gm3X9C0AyRb0M8w7zJu5dKiasrif+jSKzsxPjAUeF1m95OtIOsWs1XLNUgASOSUqGovDZxKJslZfA==", + "license": "MIT", + "dependencies": { + "lexical": "0.41.0" + } + }, + "node_modules/@lexical/overflow": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@lexical/overflow/-/overflow-0.41.0.tgz", + "integrity": "sha512-Iy6ZiJip8X14EBYt1zKPOrXyQ4eG9JLBEoPoSVBTiSbVd+lYicdUvaOThT0k0/qeVTN9nqTaEltBjm56IrVKCQ==", + "license": "MIT", + "dependencies": { + "lexical": "0.41.0" + } + }, + "node_modules/@lexical/plain-text": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@lexical/plain-text/-/plain-text-0.41.0.tgz", + "integrity": "sha512-HIsGgmFUYRUNNyvckun33UQfU7LRzDlxymHUq67+Bxd5bXqdZOrStEKJXuDX+LuLh/GXZbaWNbDLqwLBObfbQg==", + "license": "MIT", + "dependencies": { + "@lexical/clipboard": "0.41.0", + "@lexical/dragon": "0.41.0", + "@lexical/selection": "0.41.0", + "@lexical/utils": "0.41.0", + "lexical": "0.41.0" + } + }, + "node_modules/@lexical/react": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@lexical/react/-/react-0.41.0.tgz", + "integrity": "sha512-7+GUdZUm6sofWm+zdsWAs6cFBwKNsvsHezZTrf6k8jrZxL461ZQmbz/16b4DvjCGL9r5P1fR7md9/LCmk8TiCg==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.27.16", + "@lexical/devtools-core": "0.41.0", + "@lexical/dragon": "0.41.0", + "@lexical/extension": "0.41.0", + "@lexical/hashtag": "0.41.0", + "@lexical/history": "0.41.0", + "@lexical/link": "0.41.0", + "@lexical/list": "0.41.0", + "@lexical/mark": "0.41.0", + "@lexical/markdown": "0.41.0", + "@lexical/overflow": "0.41.0", + "@lexical/plain-text": "0.41.0", + "@lexical/rich-text": "0.41.0", + "@lexical/table": "0.41.0", + "@lexical/text": "0.41.0", + "@lexical/utils": "0.41.0", + "@lexical/yjs": "0.41.0", + "lexical": "0.41.0", + "react-error-boundary": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.x", + "react-dom": ">=17.x" + } + }, + "node_modules/@lexical/rich-text": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.41.0.tgz", + "integrity": "sha512-yUcr7ZaaVTZNi8bow4CK1M8jy2qyyls1Vr+5dVjwBclVShOL/F/nFyzBOSb6RtXXRbd3Ahuk9fEleppX/RNIdw==", + "license": "MIT", + "dependencies": { + "@lexical/clipboard": "0.41.0", + "@lexical/dragon": "0.41.0", + "@lexical/selection": "0.41.0", + "@lexical/utils": "0.41.0", + "lexical": "0.41.0" + } + }, + "node_modules/@lexical/selection": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.41.0.tgz", + "integrity": "sha512-1s7/kNyRzcv5uaTwsUL28NpiisqTf5xZ1zNukLsCN1xY+TWbv9RE9OxIv+748wMm4pxNczQe/UbIBODkbeknLw==", + "license": "MIT", + "dependencies": { + "lexical": "0.41.0" + } + }, + "node_modules/@lexical/table": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.41.0.tgz", + "integrity": "sha512-d3SPThBAr+oZ8O74TXU0iXM3rLbrAVC7/HcOnSAq7/AhWQW8yMutT51JQGN+0fMLP9kqoWSAojNtkdvzXfU/+A==", + "license": "MIT", + "dependencies": { + "@lexical/clipboard": "0.41.0", + "@lexical/extension": "0.41.0", + "@lexical/utils": "0.41.0", + "lexical": "0.41.0" + } + }, + "node_modules/@lexical/text": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@lexical/text/-/text-0.41.0.tgz", + "integrity": "sha512-gGA+Anc7ck110EXo4KVKtq6Ui3M7Vz3OpGJ4QE6zJHWW8nV5h273koUGSutAMeoZgRVb6t01Izh3ORoFt/j1CA==", + "license": "MIT", + "dependencies": { + "lexical": "0.41.0" + } + }, + "node_modules/@lexical/utils": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.41.0.tgz", + "integrity": "sha512-Wlsokr5NQCq83D+7kxZ9qs5yQ3dU3Qaf2M+uXxLRoPoDaXqW8xTWZq1+ZFoEzsHzx06QoPa4Vu/40BZR91uQPg==", + "license": "MIT", + "dependencies": { + "@lexical/selection": "0.41.0", + "lexical": "0.41.0" + } + }, + "node_modules/@lexical/yjs": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@lexical/yjs/-/yjs-0.41.0.tgz", + "integrity": "sha512-PaKTxSbVC4fpqUjQ7vUL9RkNF1PjL8TFl5jRe03PqoPYpE33buf3VXX6+cOUEfv9+uknSqLCPHoBS/4jN3a97w==", + "license": "MIT", + "dependencies": { + "@lexical/offset": "0.41.0", + "@lexical/selection": "0.41.0", + "lexical": "0.41.0" + }, + "peerDependencies": { + "yjs": ">=13.5.22" + } + }, + "node_modules/@preact/signals-core": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.13.0.tgz", + "integrity": "sha512-slT6XeTCAbdql61GVLlGU4x7XHI7kCZV5Um5uhE4zLX4ApgiiXc0UYFvVOKq06xcovzp7p+61l68oPi563ARKg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -5698,6 +6002,17 @@ "dev": true, "license": "ISC" }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "license": "MIT", + "peer": true, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -5813,6 +6128,34 @@ "node": ">= 0.8.0" } }, + "node_modules/lexical": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.41.0.tgz", + "integrity": "sha512-pNIm5+n+hVnJHB9gYPDYsIO5Y59dNaDU9rJmPPsfqQhP2ojKFnUoPbcRnrI9FJLXB14sSumcY8LUw7Sq70TZqA==", + "license": "MIT" + }, + "node_modules/lib0": { + "version": "0.2.117", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", + "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==", + "license": "MIT", + "peer": true, + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/lightningcss": { "version": "1.30.2", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", @@ -6424,6 +6767,15 @@ "node": ">= 0.8.0" } }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -6478,6 +6830,15 @@ "react": "^19.2.1" } }, + "node_modules/react-error-boundary": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.1.1.tgz", + "integrity": "sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } + }, "node_modules/react-hook-form": { "version": "7.68.0", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.68.0.tgz", @@ -6866,6 +7227,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", @@ -7266,6 +7633,24 @@ "dev": true, "license": "ISC" }, + "node_modules/yjs": { + "version": "13.6.29", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.29.tgz", + "integrity": "sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/client/package.json b/client/package.json index e407ca2..58a70e0 100644 --- a/client/package.json +++ b/client/package.json @@ -15,6 +15,11 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@hookform/error-message": "^2.0.1", + "@lexical/html": "^0.41.0", + "@lexical/link": "^0.41.0", + "@lexical/list": "^0.41.0", + "@lexical/react": "^0.41.0", + "@lexical/rich-text": "^0.41.0", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", @@ -40,6 +45,7 @@ "dayjs": "^1.11.19", "input-otp": "^1.4.2", "jwt-decode": "^4.0.0", + "lexical": "^0.41.0", "lucide-react": "^0.555.0", "next-themes": "^0.4.6", "react": "^19.2.0", diff --git a/client/src/api/api.ts b/client/src/api/api.ts index 36f1e42..d81d94e 100644 --- a/client/src/api/api.ts +++ b/client/src/api/api.ts @@ -28,6 +28,8 @@ import { GetUserReport, GetUserReports, GetCurrentQuarterStatus, + GetAllMeetings, + GetMeeting, } from "@/types/apiResponse"; import { CheckInPayload, ICheckInPayload } from "@/types/chekin"; import { Goal } from "@/types/goal"; @@ -244,8 +246,13 @@ export class API { markGoalAsComplete(data: ViewGoalFormValues) { return this.request(this.instance.put("/goals/mark-as-complete", data)); } - getGoalsByOwner(): Promise { - return this.request(this.instance.get("/goals/get-by-owner")); + getGoalsByOwner(filter?: { + quarter?: string | null; + year?: string | null; + }): Promise { + return this.request( + this.instance.get("/goals/get-by-owner", { params: filter }), + ); } deleteGoalById(goalId: string) { return this.request( @@ -288,6 +295,39 @@ export class API { generateUserReport(): Promise { return this.request(this.instance.post("/reports/generate-user-report")); } + createMeeting(data: { + title: string; + employee: string; + meetingDate: string; + }) { + return this.request(this.instance.post("/meetings/add", data)); + } + updateMeeting( + meetingId: string, + data: { title?: string; meetingDate?: string; notes?: string; status?: string }, + ) { + return this.request( + this.instance.put(`/meetings/update/${meetingId}`, data), + ); + } + deleteMeeting(meetingId: string) { + return this.request( + this.instance.delete("/meetings/delete", { params: { meetingId } }), + ); + } + getAllMeetings(filters: { + employeeId?: string | null; + quarter?: string | null; + year?: string | null; + status?: string | null; + }): Promise { + return this.request( + this.instance.get("/meetings/get-all", { params: filters }), + ); + } + getMeetingById(meetingId: string): Promise { + return this.request(this.instance.get(`/meetings/get/${meetingId}`)); + } } const Api = new API(); diff --git a/client/src/components/meetings/DeleteMeetingModal.tsx b/client/src/components/meetings/DeleteMeetingModal.tsx new file mode 100644 index 0000000..aa10e22 --- /dev/null +++ b/client/src/components/meetings/DeleteMeetingModal.tsx @@ -0,0 +1,49 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; + +interface DeleteMeetingModalProps { + isOpen: boolean; + onClose: () => void; + onConfirmDelete: () => void; + isPending?: boolean; +} + +export const DeleteMeetingModal = ({ + isOpen, + onClose, + onConfirmDelete, + isPending = false, +}: DeleteMeetingModalProps) => { + return ( + + + + Delete Meeting + + Are you sure you want to delete this meeting? This action cannot be + undone. + + + + + + + + + ); +}; diff --git a/client/src/components/meetings/LexicalEditor.tsx b/client/src/components/meetings/LexicalEditor.tsx new file mode 100644 index 0000000..f9d359d --- /dev/null +++ b/client/src/components/meetings/LexicalEditor.tsx @@ -0,0 +1,195 @@ +import { useEffect } from "react"; +import { LexicalComposer } from "@lexical/react/LexicalComposer"; +import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; +import { ContentEditable } from "@lexical/react/LexicalContentEditable"; +import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; +import { ListPlugin } from "@lexical/react/LexicalListPlugin"; +import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { ListItemNode, ListNode } from "@lexical/list"; +import { HeadingNode } from "@lexical/rich-text"; +import { + $getSelection, + $isRangeSelection, + FORMAT_TEXT_COMMAND, + type EditorState, + type LexicalEditor as LexicalEditorType, +} from "lexical"; +import { + INSERT_ORDERED_LIST_COMMAND, + INSERT_UNORDERED_LIST_COMMAND, +} from "@lexical/list"; +import { useState, useCallback } from "react"; +import { Button } from "@/components/ui/button"; + +const theme = { + paragraph: "mb-1", + text: { + bold: "font-bold", + italic: "italic", + underline: "underline", + }, + list: { + nested: { + listitem: "list-none", + }, + ol: "list-decimal ml-4", + ul: "list-disc ml-4", + listitem: "ml-2", + }, + heading: { + h1: "text-2xl font-bold", + h2: "text-xl font-bold", + h3: "text-lg font-bold", + }, +}; + +function ToolbarPlugin() { + const [editor] = useLexicalComposerContext(); + const [isBold, setIsBold] = useState(false); + const [isItalic, setIsItalic] = useState(false); + const [isUnderline, setIsUnderline] = useState(false); + + const updateToolbar = useCallback(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + setIsBold(selection.hasFormat("bold")); + setIsItalic(selection.hasFormat("italic")); + setIsUnderline(selection.hasFormat("underline")); + } + }, []); + + useEffect(() => { + return editor.registerUpdateListener(({ editorState }) => { + editorState.read(() => { + updateToolbar(); + }); + }); + }, [editor, updateToolbar]); + + return ( +
+ + + +
+ + +
+ ); +} + +function LoadInitialStatePlugin({ + initialState, +}: { + initialState?: string; +}) { + const [editor] = useLexicalComposerContext(); + const [loaded, setLoaded] = useState(false); + + useEffect(() => { + if (initialState && !loaded) { + try { + const parsed = JSON.parse(initialState); + const editorState = editor.parseEditorState(parsed); + editor.setEditorState(editorState); + setLoaded(true); + } catch { + // Invalid state, ignore + } + } + }, [editor, initialState, loaded]); + + return null; +} + +interface LexicalEditorProps { + onChange: (serializedState: string) => void; + initialState?: string; +} + +export default function LexicalEditor({ + onChange, + initialState, +}: LexicalEditorProps) { + const initialConfig = { + namespace: "MeetingNotesEditor", + theme, + nodes: [ListNode, ListItemNode, HeadingNode], + onError: (error: Error) => { + console.error(error); + }, + }; + + const handleChange = ( + editorState: EditorState, + _editor: LexicalEditorType, + ) => { + const json = editorState.toJSON(); + onChange(JSON.stringify(json)); + }; + + return ( + +
+ +
+ + } + ErrorBoundary={({ children }) => <>{children}} + /> +
+ + + + +
+
+ ); +} diff --git a/client/src/components/meetings/MeetingFilter.tsx b/client/src/components/meetings/MeetingFilter.tsx new file mode 100644 index 0000000..db5f683 --- /dev/null +++ b/client/src/components/meetings/MeetingFilter.tsx @@ -0,0 +1,83 @@ +import useUser from "@/hooks/useUser"; +import { UserSelect } from "../common/userSelect"; +import { QuarterSelect } from "../common/quarterSelect"; +import { YearSelect } from "../common/yearOption"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +export type MeetingFilterType = { + employeeId: string | null; + quarter: string | null; + year: string | null; + status: string | null; +}; + +type MeetingFilterProps = { + filter: MeetingFilterType; + onChange: (filter: MeetingFilterType) => void; +}; + +const statusOptions = [ + { label: "All", value: "ALL" }, + { label: "Scheduled", value: "scheduled" }, + { label: "Completed", value: "completed" }, + { label: "Cancelled", value: "cancelled" }, +]; + +const MeetingFilter = ({ filter, onChange }: MeetingFilterProps) => { + const { data: users, isLoading } = useUser(); + + if (isLoading) { + return
Loading users...
; + } + + return ( +
+ onChange({ ...filter, employeeId: val })} + placeholder="Filter by employee" + isLoading={isLoading} + allowAllOption + /> + + onChange({ ...filter, quarter: value })} + value={filter.quarter || undefined} + placeholder="Filter by quarter" + allowAllOption + /> + + onChange({ ...filter, year: value })} + value={filter.year || undefined} + placeholder="Filter by year" + allowAllOption + /> + + +
+ ); +}; + +export default MeetingFilter; diff --git a/client/src/components/meetings/MeetingFormDialog.tsx b/client/src/components/meetings/MeetingFormDialog.tsx new file mode 100644 index 0000000..d857a20 --- /dev/null +++ b/client/src/components/meetings/MeetingFormDialog.tsx @@ -0,0 +1,156 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useForm } from "react-hook-form"; +import { useMutation } from "@tanstack/react-query"; +import Api from "@/api/api"; +import { toast } from "sonner"; +import { queryClient } from "@/utils/queryClient"; +import useUser from "@/hooks/useUser"; +import { UserSelect } from "../common/userSelect"; + +interface MeetingFormValues { + title: string; + employee: string; + meetingDate: string; +} + +interface MeetingFormDialogProps { + isOpen: boolean; + onClose: () => void; + editData?: { + _id: string; + title: string; + employee: string; + meetingDate: string; + }; +} + +function MeetingFormDialog({ isOpen, onClose, editData }: MeetingFormDialogProps) { + const { data: users, isLoading: usersLoading } = useUser(); + const isEditMode = !!editData; + + const { reset, handleSubmit, register, setValue, watch } = + useForm({ + defaultValues: { + title: editData?.title || "", + employee: editData?.employee || "", + meetingDate: editData?.meetingDate + ? new Date(editData.meetingDate).toISOString().split("T")[0] + : "", + }, + }); + + const employeeValue = watch("employee"); + + const { mutate, isPending } = useMutation({ + mutationFn: (data: MeetingFormValues) => { + if (isEditMode) { + return Api.updateMeeting(editData._id, { + title: data.title, + meetingDate: data.meetingDate, + }); + } + return Api.createMeeting(data); + }, + onSuccess: () => { + reset(); + toast.success( + isEditMode + ? "Meeting updated successfully." + : "Meeting scheduled successfully.", + { position: "top-right" }, + ); + queryClient.invalidateQueries({ queryKey: ["meetings"] }); + onClose(); + }, + onError: (error) => { + toast.error(error.message || "Something went wrong."); + }, + }); + + const onSubmit = (data: MeetingFormValues) => { + if (!data.meetingDate) { + toast.error("Meeting date is required."); + return; + } + if (!data.employee && !isEditMode) { + toast.error("Employee is required."); + return; + } + mutate(data); + }; + + return ( + + + + + {isEditMode ? "Edit Meeting" : "Schedule a Meeting"} + + + {isEditMode + ? "Update the meeting details." + : "Schedule a one-on-one meeting with an employee."} + + +
+
+
+ + +
+ + {!isEditMode && ( +
+ + setValue("employee", val)} + placeholder="Select employee" + isLoading={usersLoading} + /> +
+ )} + +
+ + +
+
+ + + + +
+
+
+ ); +} + +export default MeetingFormDialog; diff --git a/client/src/components/meetings/MeetingManagement.tsx b/client/src/components/meetings/MeetingManagement.tsx new file mode 100644 index 0000000..c445b99 --- /dev/null +++ b/client/src/components/meetings/MeetingManagement.tsx @@ -0,0 +1,80 @@ +import { useQuery } from "@tanstack/react-query"; +import { CustomDataTable } from "../customTable"; +import MeetingFormDialog from "./MeetingFormDialog"; +import { columns, MeetingRow } from "./meetingTable.config"; +import Api from "@/api/api"; +import { useMemo, useState } from "react"; +import { Spinner } from "../ui/spinner"; +import ApiErrorMessage from "../ApiErrorMessage"; +import { Button } from "../ui/button"; +import MeetingFilter, { MeetingFilterType } from "./MeetingFilter"; + +export const MeetingManagement = () => { + const [isOpen, setIsOpen] = useState(false); + const [filter, setFilter] = useState({ + employeeId: null, + quarter: null, + year: null, + status: null, + }); + + const { + data, + isLoading, + error, + } = useQuery({ + queryKey: ["meetings", filter], + queryFn: async () => Api.getAllMeetings(filter), + }); + + const meetings: MeetingRow[] = useMemo(() => { + if (!data?.meetings) return []; + return data.meetings.map((m) => ({ + _id: m._id, + title: m.title, + employeeName: m.employeeName, + meetingDate: m.meetingDate, + status: m.status, + notes: m.notes, + })); + }, [data]); + + let contentToDisplay; + + if (isLoading) { + return ( +
+ +
+ ); + } + if (error) { + return ; + } + if (data) { + contentToDisplay = ( +
+ setFilter(newFilter)} + /> + +
+ ); + } + + return ( +
+
+ + {isOpen && ( + setIsOpen(false)} + /> + )} +
+ {contentToDisplay} +
+ ); +}; diff --git a/client/src/components/meetings/MeetingNotesDialog.tsx b/client/src/components/meetings/MeetingNotesDialog.tsx new file mode 100644 index 0000000..2514575 --- /dev/null +++ b/client/src/components/meetings/MeetingNotesDialog.tsx @@ -0,0 +1,93 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import Api from "@/api/api"; +import { toast } from "sonner"; +import { queryClient } from "@/utils/queryClient"; +import { useState } from "react"; +import { Spinner } from "../ui/spinner"; +import LexicalEditor from "./LexicalEditor"; + +interface MeetingNotesDialogProps { + isOpen: boolean; + onClose: () => void; + meetingId: string; +} + +function MeetingNotesDialog({ + isOpen, + onClose, + meetingId, +}: MeetingNotesDialogProps) { + const [notesState, setNotesState] = useState(""); + + const { isLoading } = useQuery({ + queryKey: ["meeting", meetingId], + queryFn: () => Api.getMeetingById(meetingId), + enabled: !!meetingId, + select: (data) => { + if (data.meeting.notes) { + setNotesState(data.meeting.notes); + } + return data.meeting; + }, + }); + + const { mutate, isPending } = useMutation({ + mutationFn: (notes: string) => + Api.updateMeeting(meetingId, { notes, status: "completed" }), + onSuccess: () => { + toast.success("Meeting notes saved.", { position: "top-right" }); + queryClient.invalidateQueries({ queryKey: ["meetings"] }); + queryClient.invalidateQueries({ queryKey: ["meeting", meetingId] }); + onClose(); + }, + onError: (error) => { + toast.error(error.message || "Failed to save notes."); + }, + }); + + const handleSave = () => { + mutate(notesState); + }; + + return ( + + + + Meeting Notes + + Write or edit meeting notes using the rich text editor below. + + + {isLoading ? ( +
+ +
+ ) : ( + + )} + + + + +
+
+ ); +} + +export default MeetingNotesDialog; diff --git a/client/src/components/meetings/meetingTable.config.tsx b/client/src/components/meetings/meetingTable.config.tsx new file mode 100644 index 0000000..634c365 --- /dev/null +++ b/client/src/components/meetings/meetingTable.config.tsx @@ -0,0 +1,79 @@ +import { type ColumnDef } from "@tanstack/react-table"; +import dayjs from "dayjs"; +import { Badge } from "../ui/badge"; +import { MeetingTableAction } from "./meetingTableAction"; +import { IBadgeVariant } from "@/types/badge"; + +export type MeetingRow = { + _id: string; + title: string; + employeeName: string; + meetingDate: string; + status: "scheduled" | "completed" | "cancelled"; + notes: string; +}; + +const statusStyles: Record = { + Scheduled: "secondary", + Completed: "default", + Cancelled: "destructive", +}; + +const statusMapping: Record = { + scheduled: "Scheduled", + completed: "Completed", + cancelled: "Cancelled", +}; + +export const columns: ColumnDef[] = [ + { + accessorKey: "title", + header: () =>
Title
, + cell: ({ row }) => ( +
{row.getValue("title")}
+ ), + }, + { + accessorKey: "employeeName", + header: () =>
Employee
, + cell: ({ row }) => ( +
+ {row.getValue("employeeName")} +
+ ), + }, + { + accessorKey: "meetingDate", + header: () =>
Meeting Date
, + cell: ({ row }) => { + const raw: Date | string = row.getValue("meetingDate"); + const formattedDate = raw ? dayjs(raw).format("D MMM YY") : "-"; + return
{formattedDate}
; + }, + }, + { + accessorKey: "status", + header: () =>
Status
, + cell: ({ row }) => { + const status: string = row.getValue("status"); + const currentStatus = statusMapping[status]; + const statusStyle = statusStyles[currentStatus]; + return ( +
+ + {currentStatus} + +
+ ); + }, + }, + { + accessorKey: "actions", + header: () =>
Actions
, + cell: ({ row }) => ( +
+ +
+ ), + }, +]; diff --git a/client/src/components/meetings/meetingTableAction.tsx b/client/src/components/meetings/meetingTableAction.tsx new file mode 100644 index 0000000..148618d --- /dev/null +++ b/client/src/components/meetings/meetingTableAction.tsx @@ -0,0 +1,100 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Button } from "../ui/button"; +import { MoreVertical } from "lucide-react"; +import { useState } from "react"; +import { DeleteMeetingModal } from "./DeleteMeetingModal"; +import { useMutation } from "@tanstack/react-query"; +import Api from "@/api/api"; +import { toast } from "sonner"; +import { queryClient } from "@/utils/queryClient"; +import MeetingFormDialog from "./MeetingFormDialog"; +import MeetingNotesDialog from "./MeetingNotesDialog"; +import type { MeetingRow } from "./meetingTable.config"; + +type MeetingModalType = "edit" | "delete" | "notes" | null; + +export const MeetingTableAction = ({ meeting }: { meeting: MeetingRow }) => { + const [modal, setModal] = useState(null); + + const handleCloseModal = () => { + setModal(null); + }; + + const { mutate, isPending } = useMutation({ + mutationFn: (meetingId: string) => Api.deleteMeeting(meetingId), + onSuccess: () => { + handleCloseModal(); + toast.success("Meeting deleted successfully", { + position: "top-right", + }); + queryClient.invalidateQueries({ queryKey: ["meetings"] }); + }, + onError: () => { + toast.error("Failed to delete meeting", { + position: "top-right", + }); + }, + }); + + const handleConfirmDelete = () => { + mutate(meeting._id); + }; + + return ( + <> +
+ + + + + + setModal("notes")}> + Edit Notes + + setModal("edit")}> + Edit Meeting + + setModal("delete")}> + Delete + + + + {modal === "notes" && ( + + )} + {modal === "edit" && ( + + )} + {modal === "delete" && ( + + )} +
+ + ); +}; diff --git a/client/src/components/myGoals/myGoals.tsx b/client/src/components/myGoals/myGoals.tsx index 3b90d10..abebc84 100644 --- a/client/src/components/myGoals/myGoals.tsx +++ b/client/src/components/myGoals/myGoals.tsx @@ -9,14 +9,25 @@ import { Spinner } from "../ui/spinner"; import ApiErrorMessage from "../ApiErrorMessage"; import { useAuth } from "@/hooks/useAuthContext"; import { getTransformedGoals } from "../goalManagement/transformedGoals"; +import { QuarterSelect } from "../common/quarterSelect"; +import { YearSelect } from "../common/yearOption"; export type IGoal = GoalRow; +type MyGoalFilter = { + quarter: string | null; + year: string | null; +}; + const MyGoals = () => { const { user } = useAuth(); + const [filter, setFilter] = useState({ + quarter: null, + year: null, + }); const { data, isLoading, error } = useQuery({ - queryKey: ["goals", user?._id], - queryFn: async () => Api.getGoalsByOwner(), + queryKey: ["goals", user?._id, filter], + queryFn: async () => Api.getGoalsByOwner(filter), }); const goals: GoalRow[] = data ? getTransformedGoals(data?.goals) : []; @@ -51,15 +62,31 @@ const MyGoals = () => { } return ( -
- {contentToRender} - {selectedGoalId && ( - setIsOpen(false)} +
+
+ setFilter({ ...filter, quarter: value })} + value={filter.quarter || undefined} + placeholder="Filter by quarter" + allowAllOption + /> + setFilter({ ...filter, year: value })} + value={filter.year || undefined} + placeholder="Filter by year" + allowAllOption /> - )} +
+
+ {contentToRender} + {selectedGoalId && ( + setIsOpen(false)} + /> + )} +
); }; diff --git a/client/src/components/side-bar-items.ts b/client/src/components/side-bar-items.ts index 8e28aa2..9b53104 100644 --- a/client/src/components/side-bar-items.ts +++ b/client/src/components/side-bar-items.ts @@ -5,6 +5,7 @@ import { IconEyeEdit, IconListCheck, IconListDetails, + IconMessageCircle, IconTargetArrow, IconTrophy, IconUser, @@ -50,6 +51,11 @@ export const sidebarItems: Record = { url: "review-appraisals", icon: IconEyeEdit, }, + { + title: "1:1 Meetings", + url: "meetings", + icon: IconMessageCircle, + }, { title: "Personal Details", url: "me", diff --git a/client/src/pages/outlets/meetingsLayout.tsx b/client/src/pages/outlets/meetingsLayout.tsx new file mode 100644 index 0000000..136dff1 --- /dev/null +++ b/client/src/pages/outlets/meetingsLayout.tsx @@ -0,0 +1,16 @@ +import { MeetingManagement } from "@/components/meetings/MeetingManagement"; +import OutletWrapper from "@/components/mainLayout"; +import { SiteHeader } from "@/components/site-header"; + +function MeetingsLayout() { + return ( + <> + + + + + + ); +} + +export default MeetingsLayout; diff --git a/client/src/routes/routes.ts b/client/src/routes/routes.ts index bbf5092..26208cc 100644 --- a/client/src/routes/routes.ts +++ b/client/src/routes/routes.ts @@ -113,6 +113,10 @@ const router = createBrowserRouter([ path: "reports", Component: lazy(() => import("@/pages/outlets/reportsLayout")), }, + { + path: "meetings", + Component: lazy(() => import("@/pages/outlets/meetingsLayout")), + }, ], }, ]); diff --git a/client/src/types/apiResponse.ts b/client/src/types/apiResponse.ts index 1ae0588..19f1588 100644 --- a/client/src/types/apiResponse.ts +++ b/client/src/types/apiResponse.ts @@ -172,6 +172,30 @@ interface GetCurrentQuarterStatus { isAppraisalCompleted: boolean; } +interface Meeting { + _id: string; + title: string; + admin: { _id: string; fullName: string }; + employee: { _id: string; fullName: string; email: string }; + meetingDate: string; + notes: string; + status: "scheduled" | "completed" | "cancelled"; + quarter: string; + year: number; + employeeName: string; + adminName: string; + createdAt: string; + updatedAt: string; +} + +interface GetAllMeetings { + meetings: Meeting[]; +} + +interface GetMeeting { + meeting: Meeting; +} + export { UserPerformanceFormResponse, GetAllUserResponse, @@ -199,4 +223,7 @@ export { GetUserReports, GetUserReport, GetCurrentQuarterStatus, + Meeting, + GetAllMeetings, + GetMeeting, }; diff --git a/server/src/app.ts b/server/src/app.ts index eba684b..d369ae0 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -28,6 +28,7 @@ import GoalRouter from "./routes/goal.router.js"; import cardsRouter from "./routes/cards.route.js"; import settingsRouter from "./routes/settings.route.js"; import reportRouter from "./routes/report.route.js"; +import meetingRouter from "./routes/meeting.route.js"; app.use("/api/user/auth", userAuth); app.use("/api/user", userRouter); @@ -37,6 +38,7 @@ app.use("/api/goals", GoalRouter); app.use("/api/cards", cardsRouter); app.use("/api/settings", settingsRouter); app.use("/api/reports", reportRouter); +app.use("/api/meetings", meetingRouter); app.use( ( diff --git a/server/src/controllers/goal.controller.ts b/server/src/controllers/goal.controller.ts index cf50215..2c981a1 100644 --- a/server/src/controllers/goal.controller.ts +++ b/server/src/controllers/goal.controller.ts @@ -249,10 +249,17 @@ const getGoalsByOwner = asyncHandler(async (req: Request, res: Response) => { throw new ApiError(401, "User Id not found."); } - const goals = await Goal.find({ owner: userId, isDeleted: false }).populate( - "owner", - "fullName", - ); + const { quarter, year } = req.query; + const filter: Record = { owner: userId, isDeleted: false }; + + if (quarter && quarter !== "ALL") { + filter.quarter = quarter; + } + if (year && year !== "ALL") { + filter.year = Number(year); + } + + const goals = await Goal.find(filter).populate("owner", "fullName"); return res .status(200) diff --git a/server/src/controllers/meeting.controller.ts b/server/src/controllers/meeting.controller.ts new file mode 100644 index 0000000..cde68b7 --- /dev/null +++ b/server/src/controllers/meeting.controller.ts @@ -0,0 +1,164 @@ +import Meeting from "@/models/meeting.model.js"; +import Settings from "@/models/settings.model.js"; +import { MeetingSchema, UpdateMeetingSchema } from "@/types/meeting.js"; +import { ApiError } from "@/utils/ApiError.js"; +import { ApiResponse } from "@/utils/ApiResponse.js"; +import asyncHandler from "@/utils/asyncHandler.js"; +import type { Request, Response } from "express"; +import { Types } from "mongoose"; + +const createMeeting = asyncHandler(async (req: Request, res: Response) => { + const parsedPayload = MeetingSchema.safeParse(req.body); + if (!parsedPayload.success) { + throw new ApiError(400, "Invalid meeting payload"); + } + + const adminId = req.user?.id; + if (!adminId) { + throw new ApiError(401, "Unauthorized"); + } + + const { employee } = parsedPayload.data; + if (!Types.ObjectId.isValid(employee)) { + throw new ApiError(400, "Invalid employee ID"); + } + + const { currentQuarter, currentYear } = + await Settings.getCurrentYearAndQuarter(); + + const meeting = new Meeting({ + ...parsedPayload.data, + admin: adminId, + quarter: currentQuarter, + year: currentYear, + }); + + await meeting.save(); + + return res + .status(201) + .json(new ApiResponse(201, null, "Meeting created successfully")); +}); + +const updateMeeting = asyncHandler(async (req: Request, res: Response) => { + const { meetingId } = req.params; + + if (!meetingId || !Types.ObjectId.isValid(meetingId)) { + throw new ApiError(400, "Invalid meeting ID"); + } + + const parsedPayload = UpdateMeetingSchema.safeParse(req.body); + if (!parsedPayload.success) { + throw new ApiError(400, "Invalid meeting payload"); + } + + const meeting = await Meeting.findOne({ _id: meetingId, isDeleted: false }); + if (!meeting) { + throw new ApiError(404, "Meeting not found"); + } + + const { title, meetingDate, notes, status } = parsedPayload.data; + if (title !== undefined) meeting.title = title; + if (meetingDate !== undefined) meeting.meetingDate = meetingDate; + if (notes !== undefined) meeting.notes = notes; + if (status !== undefined) meeting.status = status; + + await meeting.save(); + + return res + .status(200) + .json(new ApiResponse(200, null, "Meeting updated successfully")); +}); + +const deleteMeeting = asyncHandler(async (req: Request, res: Response) => { + const { meetingId } = req.query; + + if (!meetingId || !Types.ObjectId.isValid(meetingId as string)) { + throw new ApiError(400, "Invalid meeting ID"); + } + + const meeting = await Meeting.findByIdAndUpdate( + meetingId, + { isDeleted: true }, + { new: true }, + ); + + if (!meeting) { + throw new ApiError(404, "Meeting not found"); + } + + return res + .status(200) + .json(new ApiResponse(200, null, "Meeting deleted successfully")); +}); + +const getAllMeetings = asyncHandler(async (req: Request, res: Response) => { + const { employeeId, quarter, year, status } = req.query; + const filter: Record = { isDeleted: false }; + + if (employeeId && employeeId !== "ALL") { + filter.employee = employeeId; + } + if (quarter) { + filter.quarter = quarter; + } + if (year) { + filter.year = Number(year); + } + if (status && status !== "ALL") { + filter.status = status; + } + + const meetings = await Meeting.find(filter) + .populate({ path: "employee", select: "fullName email" }) + .populate({ path: "admin", select: "fullName" }) + .sort({ meetingDate: -1 }); + + const formattedMeetings = meetings.map((meeting) => ({ + ...meeting.toObject(), + employeeName: meeting.employee + ? (meeting.employee as any).fullName + : "Unknown", + adminName: meeting.admin ? (meeting.admin as any).fullName : "Unknown", + })); + + return res + .status(200) + .json( + new ApiResponse( + 200, + { meetings: formattedMeetings }, + "Meetings fetched successfully", + ), + ); +}); + +const getMeetingById = asyncHandler(async (req: Request, res: Response) => { + const { meetingId } = req.params; + + if (!meetingId || !Types.ObjectId.isValid(meetingId)) { + throw new ApiError(400, "Invalid meeting ID"); + } + + const meeting = await Meeting.findOne({ _id: meetingId, isDeleted: false }) + .populate({ path: "employee", select: "fullName email" }) + .populate({ path: "admin", select: "fullName" }); + + if (!meeting) { + throw new ApiError(404, "Meeting not found"); + } + + return res + .status(200) + .json( + new ApiResponse(200, { meeting }, "Meeting fetched successfully"), + ); +}); + +export { + createMeeting, + updateMeeting, + deleteMeeting, + getAllMeetings, + getMeetingById, +}; diff --git a/server/src/models/meeting.model.ts b/server/src/models/meeting.model.ts new file mode 100644 index 0000000..5ab0e20 --- /dev/null +++ b/server/src/models/meeting.model.ts @@ -0,0 +1,68 @@ +import { Schema, Types, model, Document } from "mongoose"; + +interface IMeeting extends Document { + title: string; + admin: Types.ObjectId; + employee: Types.ObjectId; + meetingDate: Date; + notes: string; + status: "scheduled" | "completed" | "cancelled"; + quarter: string; + year: number; + isDeleted: boolean; + createdAt: Date; + updatedAt: Date; +} + +const meetingSchema = new Schema( + { + title: { + type: String, + required: true, + }, + admin: { + type: Schema.Types.ObjectId, + ref: "User", + required: true, + }, + employee: { + type: Schema.Types.ObjectId, + ref: "User", + required: true, + }, + meetingDate: { + type: Date, + required: true, + }, + notes: { + type: String, + default: "", + }, + status: { + type: String, + enum: ["scheduled", "completed", "cancelled"], + default: "scheduled", + }, + quarter: { + type: String, + required: true, + }, + year: { + type: Number, + required: true, + }, + isDeleted: { + type: Boolean, + default: false, + }, + }, + { timestamps: true }, +); + +meetingSchema.index({ isDeleted: 1, quarter: 1, year: 1 }); +meetingSchema.index({ employee: 1, isDeleted: 1 }); +meetingSchema.index({ admin: 1, isDeleted: 1 }); + +const Meeting = model("Meeting", meetingSchema); + +export default Meeting; diff --git a/server/src/routes/meeting.route.ts b/server/src/routes/meeting.route.ts new file mode 100644 index 0000000..58305e8 --- /dev/null +++ b/server/src/routes/meeting.route.ts @@ -0,0 +1,23 @@ +import { + createMeeting, + updateMeeting, + deleteMeeting, + getAllMeetings, + getMeetingById, +} from "@/controllers/meeting.controller.js"; +import authMiddleware from "@/middlewares/auth.middleware.js"; +import { Router } from "express"; + +const router = Router(); + +router.post("/add", authMiddleware(["admin"]), createMeeting); + +router.put("/update/:meetingId", authMiddleware(["admin"]), updateMeeting); + +router.delete("/delete", authMiddleware(["admin"]), deleteMeeting); + +router.get("/get-all", authMiddleware(["admin"]), getAllMeetings); + +router.get("/get/:meetingId", authMiddleware(["admin"]), getMeetingById); + +export default router; diff --git a/server/src/types/goal.ts b/server/src/types/goal.ts index c960f96..cfc16b8 100644 --- a/server/src/types/goal.ts +++ b/server/src/types/goal.ts @@ -1,11 +1,11 @@ import z from "zod"; const subTask = z.object({ - title: z.string(), + title: z.string().min(1, "Subtask title cannot be empty"), }); const GoalSchema = z.object({ - title: z.string(), + title: z.string().min(1, "Title cannot be empty"), subTasks: z.array(subTask), owner: z.string(), dueDate: z.coerce.date(), diff --git a/server/src/types/meeting.ts b/server/src/types/meeting.ts new file mode 100644 index 0000000..3d82299 --- /dev/null +++ b/server/src/types/meeting.ts @@ -0,0 +1,18 @@ +import z from "zod"; + +const MeetingSchema = z.object({ + title: z.string().min(1, "Title cannot be empty"), + employee: z.string().min(1, "Employee is required"), + meetingDate: z.coerce.date(), + notes: z.string().optional().default(""), + status: z.enum(["scheduled", "completed", "cancelled"]).optional(), +}); + +const UpdateMeetingSchema = z.object({ + title: z.string().min(1, "Title cannot be empty").optional(), + meetingDate: z.coerce.date().optional(), + notes: z.string().optional(), + status: z.enum(["scheduled", "completed", "cancelled"]).optional(), +}); + +export { MeetingSchema, UpdateMeetingSchema };